@kylecheng3146/agent-ops 0.1.14 → 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.
@@ -2,13 +2,15 @@ import { applyUpdatePlan, createUpdatePlan } from "../../../../runtime/src/insta
2
2
  import { okEnvelope } from "../output.js";
3
3
  import { formatOperationPlan } from "../plan-output.js";
4
4
  import { toPublicUpdatePlan } from "../public-plan.js";
5
- export function formatUpdatePlan(plan) {
5
+ import { formatTrustChange, planTrustGrant } from "./trust.js";
6
+ export function formatUpdatePlan(plan, trust) {
6
7
  const detectedVerification = plan.installation.detectedVerification;
7
8
  return formatOperationPlan({
8
9
  title: "Update plan",
9
10
  metadata: [
10
11
  `Target version: ${plan.targetVersion}`,
11
12
  `Harness: ${plan.installation.harness.join(", ")}`,
13
+ ...(trust === undefined ? [] : formatTrustChange(trust)),
12
14
  `Schema migrations: ${plan.migrationSteps.length === 0
13
15
  ? "none"
14
16
  : plan.migrationSteps
@@ -24,14 +26,27 @@ export function formatUpdatePlan(plan) {
24
26
  operations: toPublicUpdatePlan(plan).installation.operations
25
27
  });
26
28
  }
27
- function updateError(code, message, plan) {
29
+ function updateError(code, message, plan, trust, applied = false) {
28
30
  return {
29
31
  code,
30
32
  status: "error",
31
- data: { applied: false, plan: toPublicUpdatePlan(plan), message },
33
+ data: { applied, plan: toPublicUpdatePlan(plan, trust), message },
32
34
  errors: [{ code, message }]
33
35
  };
34
36
  }
37
+ async function trustChange(options, plan) {
38
+ if (plan.installation.scope === "user") {
39
+ return { action: "skipped", reason: "user-scope" };
40
+ }
41
+ if (options.calculateTrustBinding === undefined ||
42
+ options.trustStore === undefined) {
43
+ return { action: "skipped", reason: "not-configured" };
44
+ }
45
+ const binding = await options.calculateTrustBinding(plan.installation.config);
46
+ return binding === null
47
+ ? { action: "skipped", reason: "no-verification-commands" }
48
+ : await planTrustGrant(binding, options.trustStore);
49
+ }
35
50
  export async function runUpdateCommand(options) {
36
51
  const plan = await createUpdatePlan({
37
52
  root: options.root,
@@ -55,25 +70,37 @@ export async function runUpdateCommand(options) {
55
70
  ? {}
56
71
  : { hookTargets: options.hookTargets ?? options.args.hookTargets })
57
72
  });
73
+ const trust = await trustChange(options, plan);
58
74
  if (options.args.dryRun) {
59
75
  return okEnvelope("UPDATE_PLAN_READY", {
60
76
  applied: false,
61
- plan: toPublicUpdatePlan(plan),
77
+ plan: toPublicUpdatePlan(plan, trust),
62
78
  message: "Update plan calculated; no files were changed.",
63
- text: formatUpdatePlan(plan)
79
+ text: formatUpdatePlan(plan, trust)
64
80
  });
65
81
  }
66
82
  if (!options.args.yes && !options.isTTY) {
67
- return updateError("UPDATE_CONFIRMATION_REQUIRED", "Non-interactive update requires --yes.", plan);
83
+ return updateError("UPDATE_CONFIRMATION_REQUIRED", "Non-interactive update requires --yes.", plan, trust);
68
84
  }
69
85
  if (!options.args.yes &&
70
- !(await options.confirm(plan))) {
71
- return updateError("UPDATE_CANCELLED", "Update was cancelled; no files were changed.", plan);
86
+ !(await options.confirm(plan, trust))) {
87
+ return updateError("UPDATE_CANCELLED", "Update was cancelled; no files were changed.", plan, trust);
72
88
  }
73
89
  await applyUpdatePlan(options.root, plan);
90
+ if (trust.action === "grant") {
91
+ try {
92
+ if (options.trustStore === undefined) {
93
+ throw new Error("Trust store is unavailable.");
94
+ }
95
+ await options.trustStore.grant(trust.binding);
96
+ }
97
+ catch {
98
+ return updateError("UPDATE_TRUST_FAILED", "Update was applied, but repository trust was not granted. Run `agent-ops trust grant --yes`.", plan, trust, true);
99
+ }
100
+ }
74
101
  return okEnvelope("UPDATE_APPLIED", {
75
102
  applied: true,
76
- plan: toPublicUpdatePlan(plan),
103
+ plan: toPublicUpdatePlan(plan, trust),
77
104
  message: `Managed installation updated to ${plan.targetVersion}.`
78
105
  });
79
106
  }
@@ -43,7 +43,7 @@ async function loadOptionalConfig(path) {
43
43
  throw error;
44
44
  }
45
45
  }
46
- export async function loadEffectiveConfig(root, scope) {
46
+ export async function loadEffectiveConfig(root, scope, projectOverride) {
47
47
  const home = process.env.AGENT_OPS_HOME ?? homedir();
48
48
  const userPath = join(home, ".agent-ops", "config.json");
49
49
  const projectPath = join(root, ".agent-ops", "config.json");
@@ -69,7 +69,9 @@ export async function loadEffectiveConfig(root, scope) {
69
69
  });
70
70
  }
71
71
  }
72
- const project = await loadOptionalConfig(projectPath);
72
+ const project = projectOverride === undefined
73
+ ? await loadOptionalConfig(projectPath)
74
+ : { sourcePath: projectPath, config: projectOverride };
73
75
  if (project !== null) {
74
76
  layers.push({
75
77
  source: "project",
@@ -154,15 +156,18 @@ export async function repositoryTrust(root, config, cliVersion) {
154
156
  const home = process.env.AGENT_OPS_HOME ?? homedir();
155
157
  const state = localStatePaths(home);
156
158
  try {
157
- const binding = await calculateTrustBinding({
158
- repositoryPath: root,
159
- remoteUrl: repositoryRemoteUrl(root),
160
- configHash: calculateConfigHash(config),
161
- runtimeHash: sha256(cliVersion)
162
- });
159
+ const binding = await repositoryTrustBinding(root, config, cliVersion);
163
160
  return (await new FileTrustStore(state.trustStore, state.anchorDirectory).status(binding)).status;
164
161
  }
165
162
  catch {
166
163
  return "UNTRUSTED";
167
164
  }
168
165
  }
166
+ export async function repositoryTrustBinding(root, config, cliVersion) {
167
+ return await calculateTrustBinding({
168
+ repositoryPath: root,
169
+ remoteUrl: repositoryRemoteUrl(root),
170
+ configHash: calculateConfigHash(config),
171
+ runtimeHash: sha256(cliVersion)
172
+ });
173
+ }
@@ -35,7 +35,7 @@ export function toPublicOperation(operation) {
35
35
  export function toPublicOperations(operations) {
36
36
  return operations.map(toPublicOperation);
37
37
  }
38
- export function toPublicInstallPlan(plan) {
38
+ export function toPublicInstallPlan(plan, trust) {
39
39
  return {
40
40
  scope: plan.scope,
41
41
  harness: plan.harness,
@@ -43,21 +43,23 @@ export function toPublicInstallPlan(plan) {
43
43
  capabilities: plan.capabilities,
44
44
  manifest: plan.manifest,
45
45
  operations: toPublicOperations(plan.operations),
46
- detectedVerification: plan.detectedVerification
46
+ detectedVerification: plan.detectedVerification,
47
+ ...(trust === undefined ? {} : { trust })
47
48
  };
48
49
  }
49
- export function toPublicUpdatePlan(plan) {
50
+ export function toPublicUpdatePlan(plan, trust) {
50
51
  return {
51
52
  targetVersion: plan.targetVersion,
52
53
  migrationSteps: plan.migrationSteps,
53
- installation: toPublicInstallPlan(plan.installation)
54
+ installation: toPublicInstallPlan(plan.installation, trust)
54
55
  };
55
56
  }
56
- export function toPublicUninstallPlan(plan) {
57
+ export function toPublicUninstallPlan(plan, trust) {
57
58
  return {
58
59
  installed: plan.installed,
59
60
  manifest: plan.manifest,
60
61
  manifestHash: plan.manifestHash,
61
- operations: toPublicOperations(plan.operations)
62
+ operations: toPublicOperations(plan.operations),
63
+ ...(trust === undefined ? {} : { trust })
62
64
  };
63
65
  }
@@ -12,6 +12,7 @@ const CLAUDE_LOOP_EVENTS = [
12
12
  ];
13
13
  const CLAUDE_HOOK_MARKER = "--managed-by=agent-ops";
14
14
  const CLAUDE_LOOP_LAUNCHER = "${CLAUDE_PROJECT_DIR}/.claude/hooks/agent-ops-loop.sh";
15
+ const CLAUDE_WINDOWS_LOOP_LAUNCHER = "${CLAUDE_PROJECT_DIR}/.claude/hooks/agent-ops-loop.ps1";
15
16
  export function claudeSettingsTarget(scope) {
16
17
  return scope === "project"
17
18
  ? {
@@ -42,26 +43,35 @@ function matcherGroup(event, runtimePath) {
42
43
  hooks: [commandHook(event, runtimePath)]
43
44
  };
44
45
  }
45
- function loopMatcherGroup(event) {
46
+ function powershellLoopCommand(event) {
47
+ return `& "${CLAUDE_WINDOWS_LOOP_LAUNCHER}" "${event}" "${CLAUDE_HOOK_MARKER}"`;
48
+ }
49
+ function loopMatcherGroup(event, platform) {
50
+ const hook = platform === "win32"
51
+ ? {
52
+ type: "command",
53
+ shell: "powershell",
54
+ command: powershellLoopCommand(event),
55
+ timeout: 30
56
+ }
57
+ : {
58
+ type: "command",
59
+ command: "bash",
60
+ args: [
61
+ CLAUDE_LOOP_LAUNCHER,
62
+ event,
63
+ CLAUDE_HOOK_MARKER
64
+ ],
65
+ timeout: 30
66
+ };
46
67
  return {
47
68
  ...(event === "PreToolUse" || event === "PermissionRequest"
48
69
  ? { matcher: "Bash" }
49
70
  : {}),
50
- hooks: [
51
- {
52
- type: "command",
53
- command: "bash",
54
- args: [
55
- CLAUDE_LOOP_LAUNCHER,
56
- event,
57
- CLAUDE_HOOK_MARKER
58
- ],
59
- timeout: 30
60
- }
61
- ]
71
+ hooks: [hook]
62
72
  };
63
73
  }
64
- export function buildClaudeHookSettings(capabilities, runtimePath) {
74
+ export function buildClaudeHookSettings(capabilities, runtimePath, platform = process.platform) {
65
75
  if (runtimePath.length === 0 ||
66
76
  runtimePath.length > 4096 ||
67
77
  runtimePath.includes("\0")) {
@@ -70,7 +80,7 @@ export function buildClaudeHookSettings(capabilities, runtimePath) {
70
80
  const hooks = {};
71
81
  if (capabilities.includes("project-loop")) {
72
82
  for (const event of CLAUDE_LOOP_EVENTS) {
73
- hooks[event] = [loopMatcherGroup(event)];
83
+ hooks[event] = [loopMatcherGroup(event, platform)];
74
84
  }
75
85
  }
76
86
  else {
@@ -95,14 +105,20 @@ function isRecord(value) {
95
105
  * ours merely by carrying the marker string.
96
106
  */
97
107
  export function isClaudeManagedHandler(handler) {
98
- if (!isRecord(handler) || !Array.isArray(handler.args)) {
108
+ if (!isRecord(handler) || handler.type !== "command") {
99
109
  return false;
100
110
  }
101
111
  return ((handler.command === "node" &&
112
+ Array.isArray(handler.args) &&
102
113
  handler.args[3] === CLAUDE_HOOK_MARKER) ||
103
114
  (handler.command === "bash" &&
115
+ Array.isArray(handler.args) &&
104
116
  handler.args[0] === CLAUDE_LOOP_LAUNCHER &&
105
- handler.args[2] === CLAUDE_HOOK_MARKER));
117
+ handler.args[2] === CLAUDE_HOOK_MARKER) ||
118
+ (handler.shell === "powershell" &&
119
+ handler.args === undefined &&
120
+ typeof handler.command === "string" &&
121
+ CLAUDE_LOOP_EVENTS.some((event) => handler.command === powershellLoopCommand(event))));
106
122
  }
107
123
  function withoutOwnedHandlers(value) {
108
124
  if (!isRecord(value) || !Array.isArray(value.hooks)) {
@@ -8,6 +8,19 @@ function json(value) {
8
8
  export function claudeHookOutput(event, result) {
9
9
  const denialReason = result.remedy === undefined ? result.code : `${result.code}: ${result.remedy}`;
10
10
  if (event === "Stop" && result.evidence !== undefined) {
11
+ if (result.status === "FAIL") {
12
+ const failed = result.evidence.commandResults
13
+ .filter(({ exitCode }) => exitCode !== 0)
14
+ .map(({ commandId }) => commandId);
15
+ return json({
16
+ decision: "block",
17
+ reason: `agent-ops: ${result.code} reported FAIL for ${failed.join(", ")}. ` +
18
+ "Resolve every failing item — an unsatisfied independent-review " +
19
+ "gate is cleared by running `agent-ops review` to a PASS — then " +
20
+ "stop again.",
21
+ evidence: result.evidence
22
+ });
23
+ }
11
24
  return json({
12
25
  systemMessage: `agent-ops: ${result.code}`,
13
26
  evidence: result.evidence
@@ -1,5 +1,8 @@
1
1
  import { calculateConfigHash } from "../config/hash.js";
2
2
  import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
3
+ import { findReviewAttestation } from "../review/attestation.js";
4
+ import { resolveReviewScope } from "../review/scope.js";
5
+ import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
3
6
  import { aggregateVerificationStatus, executeConfiguredCommand } from "../verify/command-executor.js";
4
7
  import { collectChangeSurface } from "../verify/change-surface.js";
5
8
  import { selectVerificationScope } from "../verify/scope.js";
@@ -15,6 +18,7 @@ function commandById(config, commandId) {
15
18
  }
16
19
  return command;
17
20
  }
21
+ export const REVIEW_GATE_COMMAND_ID = "independent-review";
18
22
  export class StopVerificationService {
19
23
  #options;
20
24
  constructor(options) {
@@ -39,6 +43,35 @@ export class StopVerificationService {
39
43
  throw stopError("STOP_VERIFICATION_UNCONFIRMED", "Stop verification configuration is stale or unconfirmed.");
40
44
  }
41
45
  }
46
+ /**
47
+ * Reports whether the current source state carries a passing independent
48
+ * review. Returns null when no gate applies: an unchanged tree, or a
49
+ * repository this service cannot inspect. Infrastructure that cannot answer
50
+ * stays fail-open — only a resolvable change surface with no attestation
51
+ * fails closed.
52
+ */
53
+ async #reviewGate() {
54
+ if ((this.#options.config.reviewRoles ?? []).length === 0) {
55
+ return null;
56
+ }
57
+ let fingerprint;
58
+ try {
59
+ const scope = await resolveReviewScope({
60
+ root: this.#options.root,
61
+ runner: this.#options.gitRunner
62
+ });
63
+ fingerprint = await calculateSourceFingerprint(this.#options.root, scope, this.#options.gitRunner);
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ const attestation = await findReviewAttestation(this.#options.root, fingerprint);
69
+ return {
70
+ commandId: REVIEW_GATE_COMMAND_ID,
71
+ exitCode: attestation === null ? 1 : 0,
72
+ testCount: null
73
+ };
74
+ }
42
75
  async verify() {
43
76
  this.#assertReady();
44
77
  const surface = await collectChangeSurface(this.#options.gitRunner);
@@ -57,13 +90,19 @@ export class StopVerificationService {
57
90
  });
58
91
  executions.push(result);
59
92
  }
60
- const results = executions.map(({ commandId, exitCode, testCount }) => ({
61
- commandId,
62
- exitCode,
63
- testCount
64
- }));
93
+ const reviewGate = await this.#reviewGate();
94
+ const results = [
95
+ ...executions.map(({ commandId, exitCode, testCount }) => ({
96
+ commandId,
97
+ exitCode,
98
+ testCount
99
+ })),
100
+ ...(reviewGate === null ? [] : [reviewGate])
101
+ ];
65
102
  return {
66
- status: aggregateVerificationStatus(executions),
103
+ status: reviewGate !== null && reviewGate.exitCode !== 0
104
+ ? "FAIL"
105
+ : aggregateVerificationStatus(executions),
67
106
  results
68
107
  };
69
108
  }
@@ -36,6 +36,12 @@ export function loopLauncherPath(harness) {
36
36
  export function loopLauncherArtifactId(harness) {
37
37
  return `${harness}-loop-launcher`;
38
38
  }
39
+ export function loopWindowsLauncherPath(harness) {
40
+ return `${loopRoot(harness)}/hooks/agent-ops-loop.ps1`;
41
+ }
42
+ export function loopWindowsLauncherArtifactId(harness) {
43
+ return `${harness}-loop-launcher-windows`;
44
+ }
39
45
  function assertRuntimePath(runtimePath) {
40
46
  if (runtimePath.length === 0 ||
41
47
  runtimePath.length > 4096 ||
@@ -47,6 +53,9 @@ function assertRuntimePath(runtimePath) {
47
53
  function shellQuote(value) {
48
54
  return `'${value.replaceAll("'", "'\"'\"'")}'`;
49
55
  }
56
+ function powershellQuote(value) {
57
+ return `'${value.replaceAll("'", "''")}'`;
58
+ }
50
59
  function loopEntryPath(hookRuntimePath) {
51
60
  assertRuntimePath(hookRuntimePath);
52
61
  return `${hookRuntimePath.slice(0, -"hook-entry.js".length)}loop-entry.js`;
@@ -61,6 +70,17 @@ export function buildLoopLauncher(harness, hookRuntimePath) {
61
70
  ""
62
71
  ].join("\n");
63
72
  }
73
+ export function buildPowerShellLoopLauncher(harness, hookRuntimePath) {
74
+ const runtimePath = loopEntryPath(hookRuntimePath);
75
+ return [
76
+ `# agent-ops: generated ${harness} Windows loop v1`,
77
+ "$ErrorActionPreference = \"Stop\"",
78
+ `$runtimePath = ${powershellQuote(runtimePath)}`,
79
+ `& node $runtimePath ${harness} @args`,
80
+ "exit $LASTEXITCODE",
81
+ ""
82
+ ].join("\n");
83
+ }
64
84
  function statePaths(harness) {
65
85
  const root = loopRoot(harness);
66
86
  return [
@@ -120,12 +140,22 @@ export function planLoopContribution(options) {
120
140
  if (options.hookRuntimePath === undefined) {
121
141
  throw new AgentOpsError("LOOP_RUNTIME_REQUIRED", "The loop profile requires the installed hook runtime path.");
122
142
  }
123
- return {
124
- artifacts: harnesses.map((harness) => ({
143
+ const artifacts = harnesses.flatMap((harness) => [
144
+ {
125
145
  id: loopLauncherArtifactId(harness),
126
146
  path: loopLauncherPath(harness),
127
147
  content: buildLoopLauncher(harness, options.hookRuntimePath ?? "")
128
- })),
148
+ },
149
+ ...(harness === "claude"
150
+ ? [{
151
+ id: loopWindowsLauncherArtifactId(harness),
152
+ path: loopWindowsLauncherPath(harness),
153
+ content: buildPowerShellLoopLauncher(harness, options.hookRuntimePath ?? "")
154
+ }]
155
+ : [])
156
+ ]);
157
+ return {
158
+ artifacts,
129
159
  blocks: [
130
160
  {
131
161
  id: LOOP_MARKER_ID,
@@ -136,7 +136,7 @@ const DESCRIPTORS = {
136
136
  hookPath: ".claude/settings.json",
137
137
  surfaces: claudeSurfaces,
138
138
  ownSettingsKeys: ["hooks"],
139
- buildHooks: (capabilities, runtimePath) => buildClaudeHookSettings(capabilities, runtimePath),
139
+ buildHooks: (capabilities, runtimePath, platform) => buildClaudeHookSettings(capabilities, runtimePath, platform),
140
140
  mergeHooks: (existing, managed) => mergeClaudeSettings(existing, managed),
141
141
  stripHooks: (existing) => stripClaudeManagedHooks(existing),
142
142
  isManagedHandler: isClaudeManagedHandler,
@@ -244,7 +244,7 @@ export function managedRules(descriptor, context) {
244
244
  ""
245
245
  ];
246
246
  if (context.capabilities.includes("rules")) {
247
- lines.push("For every change:", "", "1. Define two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope and preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain independent review before claiming completion, via", " `agent-ops review` (or the CLI's equivalent invocation). Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "explicit matching trust record; installation approval never grants trust.", "");
247
+ lines.push("For every change:", "", "1. Define two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope and preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain independent review before claiming completion, via", " `agent-ops review` (or the CLI's equivalent invocation). Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
248
248
  }
249
249
  if (context.capabilities.includes("lifecycle-summary")) {
250
250
  lines.push("Advisory lifecycle summaries and local logs are informational. Advisory", "failures must remain fail-open and cannot become verification evidence.", "");
@@ -34,7 +34,7 @@ export function planHookRegistration(options) {
34
34
  return null;
35
35
  }
36
36
  const path = options.path ?? hookRegistrationPath(options.harness, options.scope);
37
- const managed = descriptor.control.buildHooks(options.capabilities, options.runtimePath);
37
+ const managed = descriptor.control.buildHooks(options.capabilities, options.runtimePath, options.platform);
38
38
  const events = Object.keys(managed.hooks);
39
39
  if (events.length === 0) {
40
40
  return null;
@@ -2,7 +2,7 @@ import { applyManagedBlock, managedBlockMarkers } from "../fs/managed-block.js";
2
2
  import { AgentOpsError } from "../fs/paths.js";
3
3
  import { harnessDescriptor, harnessHookPath, routingBlockId, selectHarnessHookSurface, rulesArtifactId } from "./harness.js";
4
4
  import { isOpencodePluginPath } from "../adapters/opencode/config.js";
5
- import { LOOP_MARKER_ID, LOOP_MARKER_VERSION, loopIgnoreContent, loopLauncherArtifactId, loopLauncherPath, selectedLoopHarnesses } from "./codex-loop.js";
5
+ import { LOOP_MARKER_ID, LOOP_MARKER_VERSION, loopIgnoreContent, loopLauncherArtifactId, loopWindowsLauncherArtifactId, loopWindowsLauncherPath, loopLauncherPath, selectedLoopHarnesses } from "./codex-loop.js";
6
6
  function expectedMarker(manifest, id, markerId) {
7
7
  const descriptor = harnessDescriptor(id);
8
8
  const markers = managedBlockMarkers(markerId, 1, "html");
@@ -76,10 +76,13 @@ export function assertSupportedManifestOwnership(manifest, root) {
76
76
  const requiredArtifactPaths = new Set([
77
77
  pathKey(".agent-ops/config.json")
78
78
  ]);
79
+ const optionalArtifactPaths = new Set();
79
80
  const expectedMarkers = new Map();
80
81
  const expectedMarkerPaths = new Set();
81
82
  const loopHarnesses = selectedLoopHarnesses(harnesses);
82
- const hasLoopArtifacts = manifest.artifacts.some(({ id }) => loopHarnesses.some((harness) => id === loopLauncherArtifactId(harness)));
83
+ const hasLoopArtifacts = manifest.artifacts.some(({ id }) => loopHarnesses.some((harness) => id === loopLauncherArtifactId(harness) ||
84
+ (harness === "claude" &&
85
+ id === loopWindowsLauncherArtifactId(harness))));
83
86
  const hasLoopMarker = manifest.markers.some(({ id }) => id === LOOP_MARKER_ID);
84
87
  const hasLoop = hasLoopArtifacts || hasLoopMarker;
85
88
  if (hasLoop &&
@@ -140,6 +143,16 @@ export function assertSupportedManifestOwnership(manifest, root) {
140
143
  ids: new Set([loopLauncherArtifactId(harness)])
141
144
  });
142
145
  requiredArtifactPaths.add(pathKey(path));
146
+ if (harness === "claude") {
147
+ const windowsPath = loopWindowsLauncherPath(harness);
148
+ expectedArtifactPaths.set(pathKey(windowsPath), {
149
+ path: windowsPath,
150
+ ids: new Set([loopWindowsLauncherArtifactId(harness)])
151
+ });
152
+ // Older loop manifests predate the Windows launcher. Accept them so
153
+ // update and uninstall remain backward-compatible.
154
+ optionalArtifactPaths.add(pathKey(windowsPath));
155
+ }
143
156
  }
144
157
  expectedMarkerPaths.add(pathKey(".gitignore"));
145
158
  expectedMarkers.set(LOOP_MARKER_ID, expectedLoopMarker(manifest));
@@ -148,13 +161,16 @@ export function assertSupportedManifestOwnership(manifest, root) {
148
161
  ? recordedOpencodePluginPath ??
149
162
  harnessHookPath("opencode", manifest.scope, root)
150
163
  : null;
151
- const optionalArtifactCount = opencodePluginPath !== null &&
152
- expectedArtifactPaths.has(pathKey(opencodePluginPath))
153
- ? 1
154
- : 0;
155
- if ((manifest.artifacts.length !== requiredArtifactPaths.size &&
156
- manifest.artifacts.length !== requiredArtifactPaths.size +
157
- optionalArtifactCount) ||
164
+ if (opencodePluginPath !== null &&
165
+ expectedArtifactPaths.has(pathKey(opencodePluginPath))) {
166
+ optionalArtifactPaths.add(pathKey(opencodePluginPath));
167
+ }
168
+ const optionalArtifactCount = [...optionalArtifactPaths].filter((path) => expectedArtifactPaths.has(path)).length;
169
+ const manifestArtifactPaths = new Set(manifest.artifacts.map(({ path }) => pathKey(path)));
170
+ if ([...requiredArtifactPaths].some((path) => !manifestArtifactPaths.has(path)) ||
171
+ manifest.artifacts.length < requiredArtifactPaths.size ||
172
+ manifest.artifacts.length >
173
+ requiredArtifactPaths.size + optionalArtifactCount ||
158
174
  manifest.markers.length !== expectedMarkerPaths.size) {
159
175
  throw manifestOwnershipError();
160
176
  }
@@ -71,7 +71,7 @@ async function detectVerificationCommands(root) {
71
71
  .filter((proposal) => proposal.confidence === "high")
72
72
  .map(verificationCommandFromProposal);
73
73
  }
74
- function formatConfig(profiles, existing, reviewTargets = [], detectedCommands = []) {
74
+ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands = []) {
75
75
  // Absent reviewRoles means external review is disabled; an empty selection
76
76
  // must therefore omit the field rather than write an empty array.
77
77
  const reviewRoles = reviewTargets.length > 0
@@ -80,10 +80,10 @@ function formatConfig(profiles, existing, reviewTargets = [], detectedCommands =
80
80
  const verification = existing?.verification !== undefined &&
81
81
  existing.verification.commands.length > 0
82
82
  ? existing.verification
83
- : { commands: detectedCommands };
84
- return `${JSON.stringify({
83
+ : { commands: [...detectedCommands] };
84
+ return {
85
85
  schemaVersion: CONFIG_SCHEMA_VERSION,
86
- profiles,
86
+ profiles: [...profiles],
87
87
  verification,
88
88
  features: existing?.features ?? {
89
89
  stopVerification: {
@@ -92,8 +92,8 @@ function formatConfig(profiles, existing, reviewTargets = [], detectedCommands =
92
92
  },
93
93
  pathMappings: existing?.pathMappings ?? [],
94
94
  securityExceptions: existing?.securityExceptions ?? [],
95
- ...(reviewRoles === undefined ? {} : { reviewRoles })
96
- }, null, 2)}\n`;
95
+ ...(reviewRoles === undefined ? {} : { reviewRoles: [...reviewRoles] })
96
+ };
97
97
  }
98
98
  async function planConfig(root, profiles, existingManifest, suppliedConfig, reviewTargets = []) {
99
99
  const current = await readCurrentFile(root, CONFIG_PATH);
@@ -131,7 +131,8 @@ async function planConfig(root, profiles, existingManifest, suppliedConfig, revi
131
131
  existingConfig.verification.commands.length === 0
132
132
  ? await detectVerificationCommands(root)
133
133
  : [];
134
- const content = formatConfig(profiles, existingConfig, reviewTargets, detectedCommands);
134
+ const config = buildConfig(profiles, existingConfig, reviewTargets, detectedCommands);
135
+ const content = `${JSON.stringify(config, null, 2)}\n`;
135
136
  return {
136
137
  operation: {
137
138
  kind: "write",
@@ -145,6 +146,7 @@ async function planConfig(root, profiles, existingManifest, suppliedConfig, revi
145
146
  hash: sha256(content),
146
147
  owner: "agent-ops"
147
148
  },
149
+ config,
148
150
  detectedVerification: detectedCommands
149
151
  };
150
152
  }
@@ -502,6 +504,7 @@ export async function createInstallPlan(options) {
502
504
  path: hookPath,
503
505
  capabilities: resolved.capabilities,
504
506
  runtimePath: options.hookRuntimePath,
507
+ platform: options.platform,
505
508
  currentSource: current?.content ?? null
506
509
  });
507
510
  if (planned === null) {
@@ -567,6 +570,7 @@ export async function createInstallPlan(options) {
567
570
  harness: options.harness,
568
571
  profiles: resolved.profiles,
569
572
  capabilities: resolved.capabilities,
573
+ config: config.config,
570
574
  manifest,
571
575
  operations,
572
576
  detectedVerification: config.detectedVerification
@@ -30,8 +30,8 @@ export function smokeAvailabilityStatus(config) {
30
30
  return config.verification.commands.length > 0 ? "PASS" : "UNKNOWN";
31
31
  }
32
32
  /**
33
- * Installation approval never grants trust, so an ungranted repository is
34
- * unconfigured rather than broken. A stale binding is a real failure.
33
+ * User-scope installs and projects without verification commands can remain
34
+ * untrusted without being broken. A stale binding is a real failure.
35
35
  */
36
36
  export function repositoryTrustStatus(trust) {
37
37
  return trust === "TRUSTED"