Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/adr/0031-capability-graduation-and-upstream-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,8 @@ unbuilt. This table is the source of truth for what is real.
| Admission gate, consent store, hook runner, conformance kit (`admission` tier) | **Working** | ADR-0029, merged (PR #149) |
| `ak host adapters trust` CLI (records consent/grants) | **Working** (2026-08-16, wave A) | `list`/`trust`/`revoke` + `--expect-hash` pinning; disclosure prints the full validated manifest (control-char-safe); mirrors every pre-hash admission refusal; `revoke` works with the flag off (fail-safe) |
| External execution (`ak run` drives an admitted host) | **Working** (2026-08-16, wave B) | Manifest `execution.run` hook (coupled to `canRouteActivities`, else refused `execution-not-routable`); derived subprocess adapter behind `executionAdapterFor`; routing is overlay-aware via a lazy `effectiveRoutableHostIds()`. Security-hardened (adversarial review): hooks spawn with `cwd` pinned to the adapter's own resolved directory (never the operator's cwd — a relative hook on a remote source is refused `execution-unanchored`); an unresolved-launch cancellation reports `orphaned` (non-escalating), never an escalatable `timed_out`; handoff data is redacted from public results; stderr is never promoted into a downstream prompt; reserved hook exit codes `77`/`78` express `permission_required`/`auth_required` boundaries; a self-declared `provider` is stamped `inferred`, never `observed` |
| External lifecycle execution wired into setup/sync/uninstall | **Proposed — not built** | Loops are built-in-scoped by design until generalized |
| Tiered conformance harness (`session-driving` … `statusline`) | **Proposed — not built** | Extends the single conformance kit |
| External lifecycle execution wired into setup/sync/uninstall | **Working** (2026-08-16, wave C) | The loops iterate `hostsWithLifecycle()` (built-ins + admitted) through a shape-agnostic renderer; an admitted host's lifecycle runs only when explicitly enabled in `kit.json` **and** the flag is set. Admitted lifecycle hooks are cwd-anchored to the adapter's own directory (per-verb `lifecycle-unanchored` refusal for a relative hook on a remote source), the same F-1 protection as execution. *Known limitation:* the `sync` path is wired but not yet reachable through a real `ak sync` — `status.mjs`'s subsystem derivation is still opencode-scoped; setup and uninstall are fully live. Generalizing `status.mjs` is a tracked follow-up |
| Tiered conformance harness (`session-driving` … `statusline`) | **Working** (2026-08-16, wave C) | `runTieredConformance` + `ak host adapters conformance`: `admission` and `activity-routing` genuinely pass black-box against a real fixture (real subprocess worker); `session-driving`/`primary-eligible`/`statusline` report `gated`/`skipped` honestly because their runtime paths (external session driving, lead/escalation, statusline render) are not built — the harness never fabricates a pass. Passed/gated tiers are recorded into the grant store against the manifest hash; a failed `admission` tier short-circuits every downstream tier so no evidence is laundered |
| Capability-grant store + promotion command | **Partial** (2026-08-16, wave A) | Data layer working (`grants.mjs`): hash-pinned, evidence-gated (grant-bearing tiers require non-empty evidence), edit-invalidated like consent; promotion command pending a later wave |
| Remote manifest sources (npm / URL) + resolve→hash ordering | **Working** (2026-08-16, wave A) | file / https (no redirects, bounded time+bytes) / `npm:` (`npm pack --ignore-scripts` + `tar -xzOf` stdout-only — nothing extracted to disk, package scripts never run); resolver runs before hashing, so a mutated remote surfaces as `consent-stale`. The https fetch is host-unrestricted by design (the source is operator-authored in user-scope `kit.json`; redirects refused, no credentials attached) |
| Upstream request tracking (`gated: <repo>#NNN` against a tier) | **Partial** (2026-08-16, wave A) | Per-tier `gated` records exist in the grant store (`recordTierGate`, ref-format-validated); CLI recording/display pending a later wave |
Expand Down
77 changes: 45 additions & 32 deletions src/commands/setup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { reconcileGuidance } from '../lib/blocks.mjs';
import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs';
import { reconcileOpencodeGuidance } from '../lib/opencode.mjs';
import { runLifecycle } from '../lib/adapters/lifecycle.mjs';
import { builtinHostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.mjs';
import { hostsWithLifecycle, lifecycleAdapterFor, lifecycleExecutionEnabled, detectionBinFor } from '../lib/adapters/lifecycle-registry.mjs';
import { renderApplyReport } from '../lib/adapters/lifecycle-render.mjs';
import { loadKitConfig, saveKitConfig } from '../lib/config.mjs';
import { HOSTS, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter, seedActivityRoutesIfMultiHost, printActivityRoutingTable, aqeSupportsAgentOverrides, ensureCodexMcp, ensureRufloMcpInCodex, applySetupHostFlags, bothHostsEnabled } from '../lib/providers.mjs';
import { installedVersion } from '../lib/versions.mjs';
Expand All @@ -29,6 +30,17 @@ import {
import * as paths from '../lib/paths.mjs';
import { ok, warn, fail, info, heading, bold, dim, reportOutcome } from '../lib/output.mjs';

/** Prints one lifecycle-render.mjs report line at its own level — 'fail'
* (F5, Wave C security review) reaches `fail()`, not a fallback `info()`,
* so a genuinely failed opencode sub-surface never reads as merely
* informational. */
function printReportLine(line) {
if (line.level === 'ok') ok(line.text);
else if (line.level === 'warn') warn(line.text);
else if (line.level === 'fail') fail(line.text);
else info(line.text);
}

export const options = {
'dry-run': { type: 'boolean', default: false },
yes: { type: 'boolean', default: false },
Expand Down Expand Up @@ -215,45 +227,46 @@ export async function run_machine({ flags, pkgRoot, cfg }) {

// 6b. host lifecycle wiring — config-file MCP + skills, lifecycle plugin,
// converted agents, platform skill (each adapter owns its own surfaces
// — opencode.mjs for opencode). Registry-driven: loops
// builtinHostsWithLifecycle() rather than naming opencode, so a second
// BUILT-IN lifecycle host needs no new branch here. Only when the CLI
// is actually present: a declined/failed install must not leave a
// freshly-created config home behind (codex-review #4). The result
// SHAPE consumed below (stack.oc/plugin/agents/skill) is still
// opencode's own — the lifecycle contract doesn't mandate a common
// `apply()` result shape across hosts. builtinHostsWithLifecycle()
// (not hostsWithLifecycle()) deliberately excludes admitted external
// hosts: this loop body is opencode-shaped, and external lifecycle
// execution graduates in a later wave alongside a shape-agnostic body
// (see lifecycle-registry.mjs's registerAdmittedLifecycle comment).
for (const hostId of builtinHostsWithLifecycle()) {
if (!cfg.integrations?.hosts?.[hostId]) continue;
if (!(await have(hostId))) {
// — opencode.mjs for opencode; a subprocess hook for an admitted
// external — see lifecycle-registry.mjs's buildAdmittedLifecycleAdapter).
// Registry-driven: loops hostsWithLifecycle() (built-ins + admitted,
// ADR-0031 P3) rather than naming opencode, so a second lifecycle host
// — built-in or admitted — needs no new branch here. lifecycleExecutionEnabled
// gates each host: a built-in only needs cfg enablement (unchanged); an
// admitted external ALSO needs the experimental flag — an admitted host
// is opt-in exactly like opencode, and this never auto-enables anything.
// Only when the CLI is actually present: a declined/failed install must
// not leave a freshly-created config home behind (codex-review #4).
// lifecycle-render.mjs's renderApplyReport dispatches on the runLifecycle
// result's own shape (opencode's rich per-surface shape vs. an admitted
// host's generic lifecycleResult), so this loop body never destructures
// a host-specific result directly.
for (const hostId of hostsWithLifecycle()) {
if (!lifecycleExecutionEnabled(hostId, cfg)) continue;
if (!(await have(detectionBinFor(hostId)))) {
const pkg = HOSTS.find((h) => h.id === hostId)?.pkg ?? hostId;
warn(`${hostId}: enabled but CLI not installed — wiring skipped (re-run \`ak sync\` after installing ${pkg})`);
continue;
}
const lifecycle = await runLifecycle({
adapter: lifecycleAdapterFor(hostId), action: 'apply', cfg, options: { pkgRoot },
});
const stack = lifecycle.result;
(stack.oc.ok ? ok : warn)(`opencode: ${stack.oc.detail}`);
if (stack.oc.fatal) {
warn(`opencode plugin/agents/skill/guidance skipped — ${stack.oc.detail}`);
return false;
const report = renderApplyReport(hostId, lifecycle);
for (const line of report.lines) printReportLine(line);
if (report.fatal) return false;
// guidance blocks + the startup-reload note are opencode-specific surfaces
// (AGENTS.md blocks, opencode's own load-once-at-startup behavior) with no
// equivalent in the generic hook contract — stays gated on the rich shape.
if (report.shape === 'opencode') {
// guidance blocks for the opencode AGENTS.md land NOW (codex-review #18)
// — not on the next status-driven reconcile. Same shared reconcile pick
// and off use, so every command converges guidance identically.
const guidance = await reconcileOpencodeGuidance({ pkgRoot, cfg, cwd: process.cwd(), enabled: true });
ok(`opencode guidance: ${guidance.detail.replace(/^guidance: /, '')}`);
// opencode loads config/plugins/MCP/agents once at startup — say so now,
// or the user files "hooks don't work" issues (observed live).
info('restart opencode to load the hooks + MCP servers (loaded once at startup)');
}
ok(`opencode plugin: ${stack.plugin.detail}`);
ok(`opencode agents: ${stack.agents.detail}`);
if (stack.skill.changed) ok(`opencode skill: ${stack.skill.detail}`);
// guidance blocks for the opencode AGENTS.md land NOW (codex-review #18)
// — not on the next status-driven reconcile. Same shared reconcile pick
// and off use, so every command converges guidance identically.
const guidance = await reconcileOpencodeGuidance({ pkgRoot, cfg, cwd: process.cwd(), enabled: true });
ok(`opencode guidance: ${guidance.detail.replace(/^guidance: /, '')}`);
// opencode loads config/plugins/MCP/agents once at startup — say so now,
// or the user files "hooks don't work" issues (observed live).
info('restart opencode to load the hooks + MCP servers (loaded once at startup)');
}

// 7. frontier host hint — codex detected but not enabled (opt-in via `ak host pick`)
Expand Down
45 changes: 27 additions & 18 deletions src/commands/sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { fixStatusline, helperStampStale } from '../lib/statusline.mjs';
import { reconcileGuidance } from '../lib/blocks.mjs';
import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs';
import { runLifecycle } from '../lib/adapters/lifecycle.mjs';
import { builtinHostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.mjs';
import { hostsWithLifecycle, lifecycleAdapterFor, lifecycleExecutionEnabled, detectionBinFor } from '../lib/adapters/lifecycle-registry.mjs';
import { renderApplyReport } from '../lib/adapters/lifecycle-render.mjs';
import { listDaemons, staleDaemons, reap } from '../lib/daemons.mjs';
import { loadKitConfig, saveKitConfig } from '../lib/config.mjs';
import { commandHosts, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter, seedActivityRoutesIfMultiHost, migrateRetiredRoutesInConfig, ensureCodexMcp, ensureRufloMcpInCodex, bothHostsEnabled } from '../lib/providers.mjs';
Expand All @@ -24,6 +25,17 @@ import * as paths from '../lib/paths.mjs';
import { ok, warn, fail, info, bold, dim, withProgress, reportOutcome } from '../lib/output.mjs';
import { applyCodexStatusline, projectionFor } from '../lib/codex-statusline.mjs';

/** Prints one lifecycle-render.mjs report line at its own level — 'fail'
* (F5, Wave C security review) reaches `fail()`, not a fallback `info()`,
* so a genuinely failed opencode sub-surface never reads as merely
* informational. */
function printReportLine(line) {
if (line.level === 'ok') ok(line.text);
else if (line.level === 'warn') warn(line.text);
else if (line.level === 'fail') fail(line.text);
else info(line.text);
}

export const options = {
'dry-run': { type: 'boolean', default: false },
'no-upgrade': { type: 'boolean', default: false },
Expand Down Expand Up @@ -188,32 +200,29 @@ export async function run({ flags, pkgRoot, fetchLatest }) {
// Runs BEFORE the blocks branch: the agents-opencode guidance target is gated
// on the config home this branch creates — this order lets a fresh enable
// converge guidance in the SAME sync (a second sync is then a true no-op).
// Registry-driven: loops builtinHostsWithLifecycle() rather than naming
// opencode, so a second BUILT-IN lifecycle host needs no new branch here.
// Only opencode is registered today, so this loop runs exactly once —
// byte-identical to the single-host branch it replaces. The result SHAPE
// consumed below (stack.oc/plugin/agents/skill) is still opencode's own —
// the lifecycle contract doesn't mandate a common `apply()` result shape
// across hosts. builtinHostsWithLifecycle() (not hostsWithLifecycle())
// deliberately excludes admitted external hosts — see lifecycle-registry.mjs.
for (const hostId of builtinHostsWithLifecycle()) {
if (!subsystems.has(hostId) || !cfg.integrations?.hosts?.[hostId]) continue;
if (!(await have(hostId))) {
// Registry-driven: loops hostsWithLifecycle() (built-ins + admitted,
// ADR-0031 P3) rather than naming opencode, so a second lifecycle host
// built-in or admitted — needs no new branch here. lifecycleExecutionEnabled
// gates each host exactly as setup.mjs does (built-in: cfg enablement only;
// admitted: cfg enablement AND the experimental flag — never auto-enabled).
// lifecycle-render.mjs's renderApplyReport dispatches on the runLifecycle
// result's own shape, so this loop body never destructures a host-specific
// result directly; opencode's per-surface lines render exactly as before.
for (const hostId of hostsWithLifecycle()) {
if (!subsystems.has(hostId) || !lifecycleExecutionEnabled(hostId, cfg)) continue;
if (!(await have(detectionBinFor(hostId)))) {
info(`${hostId}: enabled but CLI not installed — wiring skipped (hosts step installs it)`);
continue;
}
const lifecycle = await runLifecycle({
adapter: lifecycleAdapterFor(hostId), action: 'apply', cfg, options: { pkgRoot },
});
const stack = lifecycle.result;
const applyReport = renderApplyReport(hostId, lifecycle);
// persist the markers on ANY refresh (a converged file whose kit.json
// markers are stale/missing still needs the save, or the next teardown
// cannot prove ownership — codex-review r3), not only on file changes.
if (stack.oc.changed || stack.markersChanged) saveKitConfig(cfg);
if (stack.oc.changed || !stack.oc.ok) report('opencode', stack.oc);
report('opencode plugin', stack.plugin);
report('opencode agents', stack.agents);
if (stack.skill.changed || !stack.skill.ok) report('opencode skill', stack.skill);
if (applyReport.ocChanged || applyReport.markersChanged) saveKitConfig(cfg);
for (const line of applyReport.lines) printReportLine(line);
}
// The 'opencode' guard: the opencode branch above can CREATE the config home
// that activates the agents-opencode guidance target — a machine whose other
Expand Down
Loading
Loading