@kylecheng3146/agent-ops 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,8 +67,8 @@ agent-ops uninstall --dry-run --json
67
67
  ### Project-local loop
68
68
 
69
69
  `loop` is an explicit, project-only profile for Codex, Claude Code, or both.
70
- It requires a POSIX-compatible `bash`; the generated native launchers are
71
- `.sh` files, so Windows is not currently a supported loop host. Preview it
70
+ Claude Code supports native Windows through a generated PowerShell launcher;
71
+ Codex's loop launcher still requires POSIX-compatible `bash`. Preview it
72
72
  first, then install only after reviewing the plan. `loop` also implies the
73
73
  `core` baseline, so the project retains the managed rules and routing files:
74
74
 
@@ -77,12 +77,16 @@ agent-ops init --dry-run --scope project --harness codex,claude --profile loop -
77
77
  agent-ops init --scope project --harness codex,claude --profile loop --yes
78
78
  ```
79
79
 
80
- It creates one small managed launcher per selected native host:
80
+ On native Windows, select `--harness claude` unless Codex is running in a
81
+ POSIX environment; the Codex loop still invokes `bash`.
82
+
83
+ It creates managed launchers per selected native host:
81
84
 
82
85
  - Codex: `.codex/hooks/agent-ops-loop.sh`, `.codex/config.toml` when absent,
83
86
  `.codex/loop-goal.md`, `.codex/loop-state.md`, and
84
87
  `.codex/loop-telemetry.jsonl`.
85
- - Claude Code: `.claude/hooks/agent-ops-loop.sh`, `.claude/loop-goal.md`,
88
+ - Claude Code: `.claude/hooks/agent-ops-loop.sh`,
89
+ `.claude/hooks/agent-ops-loop.ps1`, `.claude/loop-goal.md`,
86
90
  `.claude/loop-state.md`, and `.claude/loop-telemetry.jsonl`.
87
91
 
88
92
  The launchers delegate to one installed Node runtime; agent-ops does not copy
@@ -146,8 +150,10 @@ node dist/packages/cli/src/bin.js init \
146
150
  --dry-run --scope project --harness all --profile core --json
147
151
  ```
148
152
 
149
- After reviewing the plan, apply it explicitly with `--yes`. Trust, diagnostics,
150
- updates, and removal are separate commands:
153
+ After reviewing the plan, apply it explicitly with `--yes`. Confirmed
154
+ project-scope init/update automatically grants the displayed trust binding when
155
+ verification commands are configured; uninstall revokes it. Diagnostics and
156
+ manual trust management remain separate commands:
151
157
 
152
158
  ```bash
153
159
  node dist/packages/cli/src/bin.js init --scope project --harness all --profile core --yes
@@ -165,11 +171,9 @@ directory. `update` also requires that the project already has a valid managed
165
171
  `.agent-ops/manifest.json` created by `init`.
166
172
 
167
173
  The commands after `init --yes` are post-apply operations. `doctor` reports
168
- `UNKNOWN` for a probe that has nothing to verify yet: `repository-trust`
169
- until `trust grant` runs (only actionable once `verification.commands` is
170
- configured a project that never runs verification has nothing for trust
171
- to unlock), and `smoke-availability` until the configuration declares a
172
- verification command. Every non-`PASS` check carries a `remediation` string
174
+ `UNKNOWN` for a probe that has nothing to verify yet: `repository-trust` when
175
+ no verification command exists, and `smoke-availability` until the
176
+ configuration declares one. Every non-`PASS` check carries a `remediation` string
173
177
  explaining what, if anything, to do about it; text output prints it as an
174
178
  indented ` → ` line, and `--json` exposes it as a field. `doctor` never
175
179
  writes: it only reports what `agent-ops update` or `agent-ops trust grant`
@@ -239,9 +243,9 @@ only with confirmed commands, for example the relevant config fragment is:
239
243
  }
240
244
  ```
241
245
 
242
- After changing Stop configuration, run `agent-ops update` so native
243
- registrations match the config, then `agent-ops trust grant` to bind the
244
- current config. Stop is report-only: `PASS`, `FAIL`, and `UNKNOWN` continue
246
+ After changing Stop configuration, run `agent-ops update`; its approved plan
247
+ updates native registrations and the exact trust binding together. Stop is
248
+ report-only: `PASS`, `FAIL`, and `UNKNOWN` continue
245
249
  the harness, emit bounded command evidence, and never complete a task.
246
250
  Config v1 migrates to config v2 with Stop disabled; migration invalidates the
247
251
  old trust binding, and pre-v1 binaries cannot read the migrated config.
@@ -277,9 +281,10 @@ the project configuration defines those workflows.
277
281
 
278
282
  `agent-ops review` can hand the review to another agent CLI, so the work is not
279
283
  judged by the agent that produced it. Enable it during `agent-ops init` (the
280
- default is off). Reviews require an attached task, current required
281
- verification evidence, and a deterministic worktree (or `--base`) scope. The
282
- native-schema detailed report is displayed but not persisted.
284
+ default is off). A bare review applies the built-in `change-quality` criterion
285
+ to a deterministic worktree (or `--base`) scope. `--task` reviews keep their
286
+ task criteria and require current verification evidence. The detailed report
287
+ is displayed; a minimal source-fingerprint attestation is persisted after PASS.
283
288
 
284
289
  Each attempt uses a fresh temporary cwd, a small allowlisted environment, and a
285
290
  target-native read-only/context-isolation mode. Currently only Claude safe mode
@@ -376,6 +376,11 @@ export function parseArgs(argv) {
376
376
  (title !== undefined || sessionId !== undefined)) {
377
377
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Review accepts harness, criteria, evidence, scope, dry-run, json, and yes options.");
378
378
  }
379
+ if (command === "review" &&
380
+ taskId === undefined &&
381
+ (criteria.length > 0 || evidence.length > 0)) {
382
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Review criteria and evidence require --task.");
383
+ }
379
384
  if (command === "review" && harness !== undefined && harness.length !== 1) {
380
385
  invalidValue("--harness", harness.join(","));
381
386
  }
@@ -10,15 +10,14 @@ import { parseInstallManifest } from "../../../runtime/src/fs/manifest.js";
10
10
  import { NpmRegistryClient } from "../../../runtime/src/registry/npm.js";
11
11
  import { TaskService } from "../../../runtime/src/task/service.js";
12
12
  import { FileTaskStore } from "../../../runtime/src/task/store.js";
13
- import { FileTrustStore, calculateTrustBinding } from "../../../runtime/src/security/trust.js";
13
+ import { FileTrustStore } from "../../../runtime/src/security/trust.js";
14
14
  import { localStatePaths } from "../../../runtime/src/security/permissions.js";
15
15
  import { calculateConfigHash } from "../../../runtime/src/config/hash.js";
16
- import { sha256 } from "../../../runtime/src/fs/hash.js";
17
16
  import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
18
17
  import { VerificationService } from "../../../runtime/src/verify/service.js";
19
18
  import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
20
19
  import { runCli } from "./cli.js";
21
- import { loadEffectiveConfig, repositoryTrust } from "./context.js";
20
+ import { loadEffectiveConfig, repositoryTrust, repositoryTrustBinding } from "./context.js";
22
21
  import { runHookProcess } from "./hook-process.js";
23
22
  import { selectYesNo, writeBanner } from "./ui.js";
24
23
  import { CLI_VERSION } from "./version.js";
@@ -93,18 +92,28 @@ function gitRunner(root) {
93
92
  }
94
93
  };
95
94
  }
96
- async function confirmInit(plan) {
95
+ async function confirmInit(plan, trust) {
97
96
  writeBanner({
98
97
  isTTY: process.stdout.isTTY === true,
99
98
  columns: process.stdout.columns,
100
99
  write: (value) => process.stdout.write(value)
101
100
  });
102
- return await confirmPlan(formatInstallPlan(plan));
101
+ return await confirmPlan(formatInstallPlan(plan, trust));
103
102
  }
104
103
  async function confirmPlan(text) {
105
104
  process.stdout.write(text);
106
105
  return await selectYesNo("Apply this installation plan?", { input: process.stdin, output: process.stdout }, false);
107
106
  }
107
+ function trustStore() {
108
+ const state = localStatePaths(process.env.AGENT_OPS_HOME ?? homedir());
109
+ return new FileTrustStore(state.trustStore, state.anchorDirectory);
110
+ }
111
+ async function plannedTrustBinding(root, projectConfig) {
112
+ const config = (await loadEffectiveConfig(root, "project", projectConfig)).config;
113
+ return config.verification.commands.length === 0
114
+ ? null
115
+ : await repositoryTrustBinding(root, config, CLI_VERSION);
116
+ }
108
117
  const argv = process.argv.slice(2);
109
118
  if (argv[0] === "hook") {
110
119
  process.exitCode = await runHookProcess(argv.slice(1), {
@@ -140,6 +149,7 @@ else {
140
149
  process.stdin.isTTY === true &&
141
150
  process.stdout.isTTY === true;
142
151
  if (args.command === "init") {
152
+ const store = trustStore();
143
153
  return await runInitCommand({
144
154
  args,
145
155
  root,
@@ -150,7 +160,9 @@ else {
150
160
  ...(args.hookTargets === undefined
151
161
  ? {}
152
162
  : { hookTargets: args.hookTargets }),
153
- confirm: async (plan) => await confirmInit(plan)
163
+ trustStore: store,
164
+ calculateTrustBinding: async (config) => await plannedTrustBinding(root, config),
165
+ confirm: async (plan, trust) => await confirmInit(plan, trust)
154
166
  });
155
167
  }
156
168
  if (args.command === "doctor") {
@@ -222,14 +234,18 @@ else {
222
234
  });
223
235
  }
224
236
  if (args.command === "uninstall") {
237
+ const store = trustStore();
225
238
  return await runUninstallCommand({
226
239
  args,
227
240
  root,
228
241
  isTTY,
229
- confirm: async (plan) => await confirmPlan(formatUninstallPlan(plan))
242
+ trustStore: store,
243
+ calculateTrustBinding: async () => await plannedTrustBinding(root),
244
+ confirm: async (plan, trust) => await confirmPlan(formatUninstallPlan(plan, trust))
230
245
  });
231
246
  }
232
247
  if (args.command === "update") {
248
+ const store = trustStore();
233
249
  return await runUpdateCommand({
234
250
  args,
235
251
  root,
@@ -241,7 +257,9 @@ else {
241
257
  ...(args.hookTargets === undefined
242
258
  ? {}
243
259
  : { hookTargets: args.hookTargets }),
244
- confirm: async (plan) => await confirmPlan(formatUpdatePlan(plan)),
260
+ trustStore: store,
261
+ calculateTrustBinding: async (config) => await plannedTrustBinding(root, config),
262
+ confirm: async (plan, trust) => await confirmPlan(formatUpdatePlan(plan, trust)),
245
263
  ...(args.targetVersion === undefined
246
264
  ? {}
247
265
  : { targetVersion: args.targetVersion })
@@ -326,24 +344,7 @@ else {
326
344
  }
327
345
  if (args.command === "trust") {
328
346
  const config = (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config;
329
- const state = localStatePaths(process.env.AGENT_OPS_HOME ?? homedir());
330
- const remote = (() => {
331
- try {
332
- return execFileSync("git", ["config", "--get", "remote.origin.url"], {
333
- cwd: root,
334
- encoding: "utf8"
335
- }).trim();
336
- }
337
- catch {
338
- return `local:${root}`;
339
- }
340
- })();
341
- const binding = await calculateTrustBinding({
342
- repositoryPath: root,
343
- remoteUrl: remote,
344
- configHash: calculateConfigHash(config),
345
- runtimeHash: sha256(CLI_VERSION)
346
- });
347
+ const binding = await repositoryTrustBinding(root, config, CLI_VERSION);
347
348
  return await runTrustCommand({
348
349
  action: args.action,
349
350
  yes: args.yes,
@@ -351,7 +352,7 @@ else {
351
352
  calculateBinding: async () => binding,
352
353
  presentBinding: async () => undefined,
353
354
  confirmGrant: async () => await confirmPlan(JSON.stringify(binding, null, 2)),
354
- store: new FileTrustStore(state.trustStore, state.anchorDirectory)
355
+ store: trustStore()
355
356
  });
356
357
  }
357
358
  return errorEnvelope("CLI_COMMAND_UNAVAILABLE", `Command is not implemented yet: ${args.command}`);
@@ -4,7 +4,8 @@ import { applyInstallPlan } from "../../../../runtime/src/install/apply.js";
4
4
  import { okEnvelope } from "../output.js";
5
5
  import { formatOperationPlan } from "../plan-output.js";
6
6
  import { toPublicInstallPlan } from "../public-plan.js";
7
- export function formatInstallPlan(plan) {
7
+ import { formatTrustChange, planTrustGrant } from "./trust.js";
8
+ export function formatInstallPlan(plan, trust) {
8
9
  const hooks = plan.manifest.hooks ?? [];
9
10
  const detectedVerification = plan.detectedVerification;
10
11
  return formatOperationPlan({
@@ -13,6 +14,7 @@ export function formatInstallPlan(plan) {
13
14
  `Scope: ${plan.scope}`,
14
15
  `Harness: ${plan.harness}`,
15
16
  `Profiles: ${plan.profiles.join(", ")}`,
17
+ ...(trust === undefined ? [] : formatTrustChange(trust)),
16
18
  ...(hooks.length === 0
17
19
  ? ["Hooks: none selected"]
18
20
  : [
@@ -40,14 +42,27 @@ function appliedMessage(plan) {
40
42
  ...hooks.map((hook) => `- ${hook.harness}: ${hook.path} (${hook.events.join(", ")})`)
41
43
  ].join("\n");
42
44
  }
43
- function initError(code, message, plan) {
45
+ function initError(code, message, plan, trust, applied = false) {
44
46
  return {
45
47
  code,
46
48
  status: "error",
47
- data: { applied: false, plan: toPublicInstallPlan(plan), message },
49
+ data: { applied, plan: toPublicInstallPlan(plan, trust), message },
48
50
  errors: [{ code, message }]
49
51
  };
50
52
  }
53
+ async function trustChange(options, plan) {
54
+ if (plan.scope === "user") {
55
+ return { action: "skipped", reason: "user-scope" };
56
+ }
57
+ if (options.calculateTrustBinding === undefined ||
58
+ options.trustStore === undefined) {
59
+ return { action: "skipped", reason: "not-configured" };
60
+ }
61
+ const binding = await options.calculateTrustBinding(plan.config);
62
+ return binding === null
63
+ ? { action: "skipped", reason: "no-verification-commands" }
64
+ : await planTrustGrant(binding, options.trustStore);
65
+ }
51
66
  export async function runInitCommand(options) {
52
67
  const { args } = options;
53
68
  if (args.command !== "init" ||
@@ -75,26 +90,38 @@ export async function runInitCommand(options) {
75
90
  ? {}
76
91
  : { reviewTargets: args.reviewTargets })
77
92
  });
93
+ const trust = await trustChange(options, plan);
78
94
  if (args.dryRun) {
79
95
  return okEnvelope("INIT_PLAN_READY", {
80
96
  applied: false,
81
- plan: toPublicInstallPlan(plan),
97
+ plan: toPublicInstallPlan(plan, trust),
82
98
  message: "Installation plan calculated; no files were written.",
83
- text: formatInstallPlan(plan)
99
+ text: formatInstallPlan(plan, trust)
84
100
  });
85
101
  }
86
102
  if (!args.yes && !options.isTTY) {
87
- return initError("INIT_CONFIRMATION_REQUIRED", "Non-interactive init requires --yes after all choices are explicit.", plan);
103
+ return initError("INIT_CONFIRMATION_REQUIRED", "Non-interactive init requires --yes after all choices are explicit.", plan, trust);
88
104
  }
89
105
  if (!args.yes &&
90
106
  options.isTTY &&
91
- !(await options.confirm(plan))) {
92
- return initError("INIT_CANCELLED", "Installation was cancelled; no files were written.", plan);
107
+ !(await options.confirm(plan, trust))) {
108
+ return initError("INIT_CANCELLED", "Installation was cancelled; no files were written.", plan, trust);
93
109
  }
94
110
  await applyInstallPlan(options.root, plan);
111
+ if (trust.action === "grant") {
112
+ try {
113
+ if (options.trustStore === undefined) {
114
+ throw new Error("Trust store is unavailable.");
115
+ }
116
+ await options.trustStore.grant(trust.binding);
117
+ }
118
+ catch {
119
+ return initError("INIT_TRUST_FAILED", "Installation was applied, but repository trust was not granted. Run `agent-ops trust grant --yes`.", plan, trust, true);
120
+ }
121
+ }
95
122
  return okEnvelope("INIT_APPLIED", {
96
123
  applied: true,
97
- plan: toPublicInstallPlan(plan),
124
+ plan: toPublicInstallPlan(plan, trust),
98
125
  message: appliedMessage(plan)
99
126
  });
100
127
  }
@@ -1,6 +1,7 @@
1
1
  import { buildReviewPacket } from "../../../../runtime/src/review/packet.js";
2
2
  import { runIndependentReview } from "../../../../runtime/src/review/runner.js";
3
3
  import { renderReviewResult } from "../../../../runtime/src/review/render.js";
4
+ import { saveReviewAttestation } from "../../../../runtime/src/review/attestation.js";
4
5
  import { resolveReviewRole } from "../../../../runtime/src/review/roles.js";
5
6
  import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
6
7
  import { assertSafeSupportingPaths, isReviewerPolicyPath, resolveReviewScope, reviewScopeSignature } from "../../../../runtime/src/review/scope.js";
@@ -20,6 +21,11 @@ function harness(value) {
20
21
  ? undefined
21
22
  : selected;
22
23
  }
24
+ const GENERIC_REQUEST = "Review the current Git change surface.";
25
+ const GENERIC_CRITERIA = [{
26
+ id: "change-quality",
27
+ description: "The change is correct, safe, backward compatible, focused, and verified in proportion to its risk; report every material defect as a finding."
28
+ }];
23
29
  /**
24
30
  * Criterion descriptions come from the task store, never from the id. A
25
31
  * reviewer handed `criterion: tests` cannot review anything, so a review with
@@ -42,8 +48,13 @@ async function taskContext(options) {
42
48
  try {
43
49
  record = await tasks.status(query);
44
50
  }
45
- catch {
46
- return undefined;
51
+ catch (error) {
52
+ if (options.taskId === undefined &&
53
+ error instanceof AgentOpsError &&
54
+ error.code === "TASK_SESSION_UNATTACHED") {
55
+ return undefined;
56
+ }
57
+ throw error;
47
58
  }
48
59
  const requested = options.args.criteria ?? [];
49
60
  const criteria = record.task.criteria
@@ -55,7 +66,7 @@ async function taskContext(options) {
55
66
  }));
56
67
  if (criteria.length === 0 ||
57
68
  (requested.length > 0 && criteria.length !== requested.length)) {
58
- return undefined;
69
+ throw new AgentOpsError("REVIEW_CRITERIA_NOT_FOUND", "Every requested review criterion must exist on the selected task.");
59
70
  }
60
71
  return {
61
72
  taskId: record.task.id,
@@ -111,7 +122,7 @@ async function currentEvidence(options, context, criterionId, commandId, configH
111
122
  }
112
123
  return { current, hasReference, unreadable, stale };
113
124
  }
114
- async function preflightReview(options, context, scope) {
125
+ async function preflightReview(options, context, sourceFingerprint) {
115
126
  if (options.config === undefined ||
116
127
  options.evidenceStore === undefined ||
117
128
  options.root === undefined ||
@@ -120,7 +131,6 @@ async function preflightReview(options, context, scope) {
120
131
  return { ok: false, reason: "stale-verification" };
121
132
  }
122
133
  const configHash = calculateConfigHash(options.config);
123
- const sourceFingerprint = await calculateSourceFingerprint(options.root, scope, options.gitRunner);
124
134
  const commands = [];
125
135
  for (const criterion of context.criteria) {
126
136
  for (const commandId of criterion.verifierIds ?? []) {
@@ -224,17 +234,8 @@ export async function runReviewCommand(options) {
224
234
  requirement: separator < 0 ? value : value.slice(separator + 1)
225
235
  };
226
236
  });
227
- if (context === undefined) {
228
- return notRunEnvelope({
229
- status: "NOT_RUN",
230
- reason: "no-task-context",
231
- harness: target,
232
- model: role?.model ?? options.model ?? "configured",
233
- effort: role?.effort ?? options.effort ?? "configured",
234
- prompt: ""
235
- });
236
- }
237
237
  let scope;
238
+ let sourceFingerprint;
238
239
  let verification;
239
240
  if (options.root !== undefined && options.gitRunner !== undefined) {
240
241
  try {
@@ -269,7 +270,8 @@ export async function runReviewCommand(options) {
269
270
  scope
270
271
  });
271
272
  }
272
- if (options.policyConfigHash !== undefined) {
273
+ sourceFingerprint = await calculateSourceFingerprint(options.root, scope, options.gitRunner);
274
+ if (context !== undefined && options.policyConfigHash !== undefined) {
273
275
  if (context.policyConfigHash === null) {
274
276
  return notRunEnvelope({
275
277
  status: "NOT_RUN", reason: "reviewer-policy-baseline-missing",
@@ -285,21 +287,25 @@ export async function runReviewCommand(options) {
285
287
  });
286
288
  }
287
289
  }
288
- const preflight = await preflightReview(options, context, scope);
289
- if (!preflight.ok) {
290
- return notRunEnvelope({
291
- status: "NOT_RUN", reason: preflight.reason,
292
- harness: target, model: role?.model ?? options.model ?? "configured",
293
- effort: role?.effort ?? options.effort ?? "configured", prompt: "", scope
294
- });
290
+ if (context !== undefined) {
291
+ const preflight = await preflightReview(options, context, sourceFingerprint);
292
+ if (!preflight.ok) {
293
+ return notRunEnvelope({
294
+ status: "NOT_RUN", reason: preflight.reason,
295
+ harness: target, model: role?.model ?? options.model ?? "configured",
296
+ effort: role?.effort ?? options.effort ?? "configured", prompt: "", scope
297
+ });
298
+ }
299
+ verification = preflight.summary;
295
300
  }
296
- verification = preflight.summary;
297
301
  }
298
- const criteria = [...context.criteria];
302
+ const criteria = [
303
+ ...(context?.criteria ?? GENERIC_CRITERIA)
304
+ ];
299
305
  let packet;
300
306
  try {
301
307
  packet = buildReviewPacket({
302
- request: context.title,
308
+ request: context?.title ?? GENERIC_REQUEST,
303
309
  criteria,
304
310
  artifactRefs: scope?.changedFiles ?? [],
305
311
  evidenceRequirements
@@ -371,13 +377,10 @@ export async function runReviewCommand(options) {
371
377
  const currentHash = options.currentPolicyConfigHash === undefined
372
378
  ? options.policyConfigHash
373
379
  : await options.currentPolicyConfigHash();
374
- const postflightFingerprint = verification === undefined
375
- ? undefined
376
- : await calculateSourceFingerprint(options.root, postflight, options.gitRunner);
380
+ const postflightFingerprint = await calculateSourceFingerprint(options.root, postflight, options.gitRunner);
377
381
  if (reviewScopeSignature(scope) !== reviewScopeSignature(postflight) ||
378
382
  (options.policyConfigHash !== undefined && currentHash !== options.policyConfigHash) ||
379
- (verification !== undefined &&
380
- postflightFingerprint !== verification.sourceFingerprint)) {
383
+ postflightFingerprint !== sourceFingerprint) {
381
384
  return notRunEnvelope(sourceChangedResult(result));
382
385
  }
383
386
  }
@@ -387,7 +390,8 @@ export async function runReviewCommand(options) {
387
390
  }
388
391
  // Evidence is only appended while the task is active: a completed record
389
392
  // must stay exactly as it was verified.
390
- if (options.tasks !== undefined &&
393
+ if (context !== undefined &&
394
+ options.tasks !== undefined &&
391
395
  result.status === "PASS" &&
392
396
  context.active &&
393
397
  result.results !== undefined) {
@@ -396,6 +400,21 @@ export async function runReviewCommand(options) {
396
400
  item.evidence.map((reference) => `review:${result.harness}:${reference}`)
397
401
  ])));
398
402
  }
403
+ // The attestation is what a Stop gate reads. It is keyed by the verified
404
+ // source fingerprint, so it stops satisfying the gate the moment the tree
405
+ // changes again.
406
+ if (options.root !== undefined &&
407
+ result.status === "PASS" &&
408
+ sourceFingerprint !== undefined) {
409
+ await saveReviewAttestation(options.root, {
410
+ schemaVersion: 1,
411
+ ...(context === undefined ? {} : { taskId: context.taskId }),
412
+ harness: result.harness,
413
+ status: "PASS",
414
+ sourceFingerprint,
415
+ createdAt: new Date().toISOString()
416
+ });
417
+ }
399
418
  const message = result.status === "PASS"
400
419
  ? "Independent review passed."
401
420
  : result.status === "FAIL"
@@ -1,4 +1,32 @@
1
1
  import { okEnvelope } from "../output.js";
2
+ export async function planTrustGrant(binding, store) {
3
+ const trust = await store.status(binding);
4
+ return {
5
+ action: trust.status === "TRUSTED" ? "unchanged" : "grant",
6
+ binding,
7
+ status: trust.status
8
+ };
9
+ }
10
+ export async function planTrustRevoke(binding, store) {
11
+ const trust = await store.status(binding);
12
+ return {
13
+ action: trust.status === "TRUSTED" ? "revoke" : "unchanged",
14
+ binding,
15
+ status: trust.status
16
+ };
17
+ }
18
+ export function formatTrustChange(trust) {
19
+ if (trust.action === "skipped") {
20
+ return [`Trust: skipped (${trust.reason})`];
21
+ }
22
+ return [
23
+ `Trust: ${trust.action} (current: ${trust.status})`,
24
+ ` - canonical path: ${trust.binding.canonicalPath}`,
25
+ ` - remote identity: ${trust.binding.remoteIdentity}`,
26
+ ` - config hash: ${trust.binding.configHash}`,
27
+ ` - runtime hash: ${trust.binding.runtimeHash}`
28
+ ];
29
+ }
2
30
  function trustError(code, message, binding) {
3
31
  return {
4
32
  code,
@@ -2,11 +2,13 @@ import { applyUninstallPlan, createUninstallPlan } from "../../../../runtime/src
2
2
  import { okEnvelope } from "../output.js";
3
3
  import { formatOperationPlan } from "../plan-output.js";
4
4
  import { toPublicUninstallPlan } from "../public-plan.js";
5
- function formatUninstallPlan(plan) {
5
+ import { formatTrustChange, planTrustRevoke } from "./trust.js";
6
+ function formatUninstallPlan(plan, trust) {
6
7
  return formatOperationPlan({
7
8
  title: "Uninstall plan",
8
9
  metadata: [
9
10
  `Installed: ${plan.installed ? "yes" : "no"}`,
11
+ ...(trust === undefined ? [] : formatTrustChange(trust)),
10
12
  ...(plan.manifest === null
11
13
  ? []
12
14
  : [
@@ -17,14 +19,27 @@ function formatUninstallPlan(plan) {
17
19
  operations: toPublicUninstallPlan(plan).operations
18
20
  });
19
21
  }
20
- function uninstallError(code, message, plan) {
22
+ function uninstallError(code, message, plan, trust, applied = false) {
21
23
  return {
22
24
  code,
23
25
  status: "error",
24
- data: { applied: false, plan: toPublicUninstallPlan(plan), message },
26
+ data: { applied, plan: toPublicUninstallPlan(plan, trust), message },
25
27
  errors: [{ code, message }]
26
28
  };
27
29
  }
30
+ async function trustChange(options, plan) {
31
+ if (plan.manifest?.scope === "user") {
32
+ return { action: "skipped", reason: "user-scope" };
33
+ }
34
+ if (options.calculateTrustBinding === undefined ||
35
+ options.trustStore === undefined) {
36
+ return { action: "skipped", reason: "not-configured" };
37
+ }
38
+ const binding = await options.calculateTrustBinding();
39
+ return binding === null
40
+ ? { action: "skipped", reason: "no-verification-commands" }
41
+ : await planTrustRevoke(binding, options.trustStore);
42
+ }
28
43
  export async function runUninstallCommand(options) {
29
44
  const plan = await createUninstallPlan(options.root);
30
45
  if (!plan.installed) {
@@ -34,25 +49,37 @@ export async function runUninstallCommand(options) {
34
49
  message: "No managed installation exists."
35
50
  });
36
51
  }
52
+ const trust = await trustChange(options, plan);
37
53
  if (options.args.dryRun) {
38
54
  return okEnvelope("UNINSTALL_PLAN_READY", {
39
55
  applied: false,
40
- plan: toPublicUninstallPlan(plan),
56
+ plan: toPublicUninstallPlan(plan, trust),
41
57
  message: "Uninstall plan calculated; no files were changed.",
42
- text: formatUninstallPlan(plan)
58
+ text: formatUninstallPlan(plan, trust)
43
59
  });
44
60
  }
45
61
  if (!options.args.yes && !options.isTTY) {
46
- return uninstallError("UNINSTALL_CONFIRMATION_REQUIRED", "Non-interactive uninstall requires --yes.", plan);
62
+ return uninstallError("UNINSTALL_CONFIRMATION_REQUIRED", "Non-interactive uninstall requires --yes.", plan, trust);
47
63
  }
48
64
  if (!options.args.yes &&
49
- !(await options.confirm(plan))) {
50
- return uninstallError("UNINSTALL_CANCELLED", "Uninstall was cancelled; no files were changed.", plan);
65
+ !(await options.confirm(plan, trust))) {
66
+ return uninstallError("UNINSTALL_CANCELLED", "Uninstall was cancelled; no files were changed.", plan, trust);
51
67
  }
52
68
  await applyUninstallPlan(options.root, plan);
69
+ if (trust.action === "revoke") {
70
+ try {
71
+ if (options.trustStore === undefined ||
72
+ !(await options.trustStore.revoke(trust.binding))) {
73
+ throw new Error("Trust record changed during uninstall.");
74
+ }
75
+ }
76
+ catch {
77
+ return uninstallError("UNINSTALL_TRUST_FAILED", "Uninstall was applied, but repository trust was not revoked. Run `agent-ops trust revoke`.", plan, trust, true);
78
+ }
79
+ }
53
80
  return okEnvelope("UNINSTALL_APPLIED", {
54
81
  applied: true,
55
- plan: toPublicUninstallPlan(plan),
82
+ plan: toPublicUninstallPlan(plan, trust),
56
83
  message: "Managed installation content was removed."
57
84
  });
58
85
  }