@kylecheng3146/agent-ops 0.5.3 → 0.7.0

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.
Files changed (124) hide show
  1. package/README.md +103 -1
  2. package/dist/packages/cli/src/args.js +183 -3
  3. package/dist/packages/cli/src/bin.js +76 -19
  4. package/dist/packages/cli/src/cli.js +53 -8
  5. package/dist/packages/cli/src/codex-loop-process.js +19 -2
  6. package/dist/packages/cli/src/commands/advance.js +42 -5
  7. package/dist/packages/cli/src/commands/init.js +2 -0
  8. package/dist/packages/cli/src/commands/office.js +20 -0
  9. package/dist/packages/cli/src/commands/review-show.js +1 -1
  10. package/dist/packages/cli/src/commands/review.js +100 -9
  11. package/dist/packages/cli/src/commands/run.js +115 -0
  12. package/dist/packages/cli/src/commands/task.js +61 -17
  13. package/dist/packages/cli/src/commands/update.js +8 -0
  14. package/dist/packages/cli/src/commands/verify.js +1 -0
  15. package/dist/packages/cli/src/context.js +64 -1
  16. package/dist/packages/cli/src/hook-entry.js +2 -1
  17. package/dist/packages/cli/src/hook-process.js +54 -3
  18. package/dist/packages/cli/src/loop-entry.js +2 -1
  19. package/dist/packages/cli/src/office-entry.js +230 -0
  20. package/dist/packages/cli/src/owned-run-step.js +65 -0
  21. package/dist/packages/cli/src/parallel-deps.js +18 -5
  22. package/dist/packages/cli/src/run-deps.js +321 -0
  23. package/dist/packages/cli/src/run-entry.js +952 -0
  24. package/dist/packages/cli/src/run-setup-entry.js +32 -0
  25. package/dist/packages/cli/src/run-step-entry.js +58 -0
  26. package/dist/packages/cli/src/wizard.js +14 -2
  27. package/dist/runtime/src/adapters/agy/config.js +4 -3
  28. package/dist/runtime/src/adapters/agy/events.js +9 -0
  29. package/dist/runtime/src/adapters/claude/config.js +3 -3
  30. package/dist/runtime/src/adapters/claude/events.js +8 -0
  31. package/dist/runtime/src/adapters/codex/config.js +3 -3
  32. package/dist/runtime/src/adapters/codex/events.js +8 -0
  33. package/dist/runtime/src/adapters/opencode/config.js +5 -4
  34. package/dist/runtime/src/adapters/opencode/events.js +8 -0
  35. package/dist/runtime/src/config/explain.js +11 -0
  36. package/dist/runtime/src/config/hash.js +1 -1
  37. package/dist/runtime/src/config/merge.js +9 -1
  38. package/dist/runtime/src/contracts.js +2 -2
  39. package/dist/runtime/src/hooks/codex-loop.js +4 -0
  40. package/dist/runtime/src/hooks/completion-gate.js +8 -2
  41. package/dist/runtime/src/install/doctor.js +2 -0
  42. package/dist/runtime/src/install/harness.js +44 -1
  43. package/dist/runtime/src/install/plan.js +16 -11
  44. package/dist/runtime/src/install/preauth.js +44 -18
  45. package/dist/runtime/src/install/profiles.js +8 -2
  46. package/dist/runtime/src/install/update.js +1 -0
  47. package/dist/runtime/src/office/collect.js +233 -0
  48. package/dist/runtime/src/office/page.js +873 -0
  49. package/dist/runtime/src/office/scene.js +168 -0
  50. package/dist/runtime/src/office/server.js +139 -0
  51. package/dist/runtime/src/office/sessions.js +115 -0
  52. package/dist/runtime/src/office/snapshot.js +107 -0
  53. package/dist/runtime/src/parallel/finish.js +85 -22
  54. package/dist/runtime/src/parallel/guard.js +12 -2
  55. package/dist/runtime/src/parallel/integrate.js +288 -10
  56. package/dist/runtime/src/parallel/manage.js +4 -1
  57. package/dist/runtime/src/parallel/receipt.js +71 -5
  58. package/dist/runtime/src/parallel/service.js +41 -1
  59. package/dist/runtime/src/review/attestation.js +36 -11
  60. package/dist/runtime/src/review/execute.js +24 -1
  61. package/dist/runtime/src/review/packet.js +35 -1
  62. package/dist/runtime/src/review/runner.js +12 -1
  63. package/dist/runtime/src/review/scope.js +18 -1
  64. package/dist/runtime/src/review/slots.js +35 -10
  65. package/dist/runtime/src/run/authorization.js +60 -0
  66. package/dist/runtime/src/run/budget.js +30 -0
  67. package/dist/runtime/src/run/controls.js +139 -0
  68. package/dist/runtime/src/run/convergence.js +177 -0
  69. package/dist/runtime/src/run/hosts/claude.js +180 -0
  70. package/dist/runtime/src/run/hosts/codex.js +279 -0
  71. package/dist/runtime/src/run/hosts/index.js +4 -0
  72. package/dist/runtime/src/run/hosts/protocol.js +254 -0
  73. package/dist/runtime/src/run/hosts/types.js +1 -0
  74. package/dist/runtime/src/run/hosts/util.js +136 -0
  75. package/dist/runtime/src/run/integration.js +204 -0
  76. package/dist/runtime/src/run/macOS.js +189 -0
  77. package/dist/runtime/src/run/ownership.js +22 -0
  78. package/dist/runtime/src/run/phase.js +37 -0
  79. package/dist/runtime/src/run/planning.js +25 -0
  80. package/dist/runtime/src/run/policy-transition.js +1076 -0
  81. package/dist/runtime/src/run/policy.js +176 -0
  82. package/dist/runtime/src/run/ratchet.js +71 -0
  83. package/dist/runtime/src/run/scheduler.js +260 -0
  84. package/dist/runtime/src/run/service.js +433 -0
  85. package/dist/runtime/src/run/supervisor.js +635 -0
  86. package/dist/runtime/src/run/transport.js +159 -0
  87. package/dist/runtime/src/run/types.js +1 -0
  88. package/dist/runtime/src/run/usage.js +81 -0
  89. package/dist/runtime/src/run/verification.js +64 -0
  90. package/dist/runtime/src/run/worker-gate.js +48 -0
  91. package/dist/runtime/src/schema/acceptance.js +91 -0
  92. package/dist/runtime/src/schema/validate.js +80 -9
  93. package/dist/runtime/src/task/completion.js +14 -2
  94. package/dist/runtime/src/task/contract.js +17 -0
  95. package/dist/runtime/src/task/pin-finding.js +81 -0
  96. package/dist/runtime/src/task/service.js +316 -13
  97. package/dist/runtime/src/task/store.js +57 -7
  98. package/dist/runtime/src/verify/acceptance-coverage.js +85 -0
  99. package/dist/runtime/src/verify/acceptance-protocol.js +252 -0
  100. package/dist/runtime/src/verify/acceptance-replay.js +239 -0
  101. package/dist/runtime/src/verify/adapters/index.js +5 -0
  102. package/dist/runtime/src/verify/adapters/jest.js +142 -0
  103. package/dist/runtime/src/verify/adapters/node-reporter.js +20 -0
  104. package/dist/runtime/src/verify/adapters/node.js +254 -0
  105. package/dist/runtime/src/verify/adapters/pytest.js +188 -0
  106. package/dist/runtime/src/verify/adapters/rust.js +84 -0
  107. package/dist/runtime/src/verify/adapters/shared.js +82 -0
  108. package/dist/runtime/src/verify/adapters/vitest.js +218 -0
  109. package/dist/runtime/src/verify/evidence.js +5 -2
  110. package/dist/runtime/src/verify/service.js +24 -8
  111. package/dist/runtime/src/verify/source-fingerprint.js +1 -1
  112. package/dist/runtime/src/verify/spawn.js +13 -2
  113. package/docs/en/spec/acceptance-and-evidence.md +48 -0
  114. package/docs/en/spec/harness-adapters.md +113 -1
  115. package/docs/en/spec/loop-engineering.md +49 -0
  116. package/docs/zh-TW/spec/acceptance-and-evidence.md +21 -1
  117. package/docs/zh-TW/spec/harness-adapters.md +93 -1
  118. package/docs/zh-TW/spec/loop-engineering.md +22 -0
  119. package/package.json +1 -1
  120. package/schemas/config.schema.json +230 -19
  121. package/schemas/evidence.schema.json +129 -6
  122. package/schemas/task.schema.json +265 -6
  123. package/templates/acceptance/agent_ops_acceptance.rs +36 -0
  124. package/templates/acceptance/agent_ops_pytest.py +46 -0
@@ -0,0 +1,218 @@
1
+ import { makeAcceptanceCheck, makeAcceptanceRun, parseJsonDocument, parseJsonLines, stableCheckId } from "../acceptance-protocol.js";
2
+ import { collectJestAcceptance } from "./jest.js";
3
+ import { array, hasRetry, lower, number, object, result, run, string, terminalEvidence } from "./shared.js";
4
+ function mapFramework(runValue, options) {
5
+ const results = runValue.results.map((item) => makeAcceptanceCheck({
6
+ executionId: options.executionId,
7
+ framework: "vitest",
8
+ frameworkVersion: options.frameworkVersion,
9
+ checkId: item.checkId,
10
+ phase: options.phase,
11
+ status: item.status,
12
+ failureClass: item.failureClass,
13
+ attempts: item.attempts,
14
+ evidence: item.evidence.map((reference) => reference.replace(/^jest:/u, "vitest:"))
15
+ }));
16
+ return makeAcceptanceRun({
17
+ executionId: options.executionId,
18
+ framework: "vitest",
19
+ frameworkVersion: options.frameworkVersion,
20
+ phase: options.phase,
21
+ results,
22
+ completed: runValue.completed,
23
+ completionEvidence: runValue.completionEvidence.map((reference) => reference.replace(/^jest:/u, "vitest:")),
24
+ diagnostics: runValue.diagnostics
25
+ });
26
+ }
27
+ function eventInput(input) {
28
+ if (Array.isArray(input))
29
+ return { events: input, diagnostics: [] };
30
+ if (typeof input !== "string")
31
+ return undefined;
32
+ const document = parseJsonDocument(input);
33
+ if (Array.isArray(document))
34
+ return { events: document, diagnostics: [] };
35
+ const lines = parseJsonLines(input);
36
+ const events = lines.values.length === 1 && Array.isArray(lines.values[0]) ? lines.values[0] : lines.values;
37
+ return events.length > 0 ? { events, diagnostics: lines.invalidLines.map(line => `invalid-json-line:${line}`) } : undefined;
38
+ }
39
+ function flattenTasks(tasks, file, ancestors, assertions, lifecycleErrors) {
40
+ for (const raw of tasks) {
41
+ const task = object(raw);
42
+ if (task === undefined) {
43
+ assertions.push(null);
44
+ continue;
45
+ }
46
+ const name = string(task.name ?? task.title);
47
+ const nextAncestors = name === undefined ? ancestors : [...ancestors, name];
48
+ const children = array(task.tasks ?? task.children);
49
+ const state = object(task.result) ?? object(task.state);
50
+ if (children !== undefined && children.length > 0) {
51
+ const errors = array(state?.errors);
52
+ const hooks = object(state?.hooks ?? task.hooks);
53
+ if (errors !== undefined && errors.length > 0)
54
+ lifecycleErrors.push(...errors);
55
+ if (task.hookErrors != null || state?.hookErrors != null ||
56
+ (hooks !== undefined && Object.values(hooks).some(value => value !== "pass" && value !== "passed"))) {
57
+ lifecycleErrors.push("suite-hook-error");
58
+ }
59
+ flattenTasks(children, file, nextAncestors, assertions, lifecycleErrors);
60
+ continue;
61
+ }
62
+ if (task.type === "suite")
63
+ continue;
64
+ const status = state === undefined ? task.status ?? task.state : state.state ?? state.status;
65
+ const retryValue = state === undefined ? task.retry ?? task.retries : state.retry ?? state.retries;
66
+ const retryCount = number(state?.retryCount);
67
+ const repeatCount = number(state?.repeatCount);
68
+ // Native errors and hooks omit fixture cleanup/onTestFinished provenance.
69
+ // Even AssertionError with passing hooks is ambiguous without classified
70
+ // collector metadata, just as in Jest-compatible JSON.
71
+ const assertion = task.assertion === true || state?.assertion === true || state?.failureClass === "assertion-failed";
72
+ assertions.push({
73
+ name,
74
+ ancestorTitles: ancestors,
75
+ status: task.fails === true ? "xfail" : status,
76
+ attempts: number(task.attempts) ?? (retryCount !== undefined && repeatCount !== undefined ? retryCount + repeatCount + 1 : null),
77
+ retry: hasRetry(task) || (state !== undefined && hasRetry(state)) || (typeof retryValue === "number" && retryValue > 0),
78
+ assertion,
79
+ hooks: state?.hooks ?? task.hooks,
80
+ hookErrors: state?.hookErrors ?? task.hookErrors,
81
+ location: task.location
82
+ });
83
+ }
84
+ }
85
+ function structuredReport(input) {
86
+ const value = typeof input === "string" ? parseJsonDocument(input) : input;
87
+ const report = object(value);
88
+ if (report === undefined)
89
+ return undefined;
90
+ if (array(report.testResults) !== undefined) {
91
+ return report;
92
+ }
93
+ const testFiles = array(report.testFiles);
94
+ if (testFiles !== undefined && testFiles.some((file) => {
95
+ const value = object(file);
96
+ return value !== undefined && array(value.assertionResults) !== undefined;
97
+ })) {
98
+ return { ...report, testResults: testFiles };
99
+ }
100
+ const files = array(report.files ?? report.testFiles);
101
+ if (files === undefined)
102
+ return undefined;
103
+ const suites = [];
104
+ for (const rawFile of files) {
105
+ const file = object(rawFile);
106
+ if (file === undefined) {
107
+ suites.push(null);
108
+ continue;
109
+ }
110
+ const assertions = [];
111
+ const lifecycleErrors = [];
112
+ const tasks = array(file.tasks ?? file.children);
113
+ if (tasks !== undefined) {
114
+ flattenTasks(tasks, string(file.filepath ?? file.file ?? file.name) ?? "<vitest-file>", [], assertions, lifecycleErrors);
115
+ }
116
+ suites.push({
117
+ testFilePath: string(file.filepath ?? file.file ?? file.name) ?? "<vitest-file>",
118
+ assertionResults: assertions,
119
+ testExecError: file.error ?? file.hookErrors ?? object(file.result)?.errors,
120
+ runtimeError: lifecycleErrors
121
+ });
122
+ }
123
+ return { ...report, testResults: suites };
124
+ }
125
+ function collectEvents(events, options, initialDiagnostics) {
126
+ const results = [];
127
+ const diagnostics = [...initialDiagnostics];
128
+ let completed = false;
129
+ let completionEvidence = [];
130
+ for (const raw of events) {
131
+ const value = object(raw);
132
+ if (value === undefined) {
133
+ diagnostics.push("malformed-event");
134
+ continue;
135
+ }
136
+ const type = lower(value.type ?? value.event ?? value.kind);
137
+ if ((type === "complete" || type === "completed" || type === "finished") &&
138
+ value.completed !== false && value.interrupted !== true) {
139
+ completed = true;
140
+ completionEvidence = [terminalEvidence(value, options.executionId)];
141
+ continue;
142
+ }
143
+ if (type !== "test-end" && type !== "test:pass" && type !== "test:fail" &&
144
+ type !== "test:skip" && type !== "test:todo" && type !== "test-result") {
145
+ diagnostics.push(`unsupported-event:${type ?? "unknown"}`);
146
+ continue;
147
+ }
148
+ const ancestors = array(value.ancestorTitles)?.filter((item) => typeof item === "string") ?? [];
149
+ const name = string(value.title ?? value.name) ??
150
+ (ancestors.length === 0 ? string(value.fullName) : undefined);
151
+ if (name === undefined) {
152
+ diagnostics.push("missing-check-id");
153
+ continue;
154
+ }
155
+ const file = string(value.file ?? value.filepath) ?? "<vitest>";
156
+ const location = object(value.location);
157
+ const line = number(location?.line);
158
+ const column = number(location?.column);
159
+ let id;
160
+ try {
161
+ id = stableCheckId([
162
+ file,
163
+ ...ancestors,
164
+ name,
165
+ ...(line === undefined || column === undefined ? [] : [`@${line}:${column}`])
166
+ ]);
167
+ }
168
+ catch {
169
+ diagnostics.push("missing-check-id");
170
+ continue;
171
+ }
172
+ const status = lower(value.status ?? value.state ?? (type === "test:pass" ? "passed" : type === "test:fail" ? "failed" : type === "test:skip" ? "skipped" : type === "test:todo" ? "todo" : undefined));
173
+ const refs = [`vitest:assertion:${id}`];
174
+ const observedAttempts = number(value.attempts);
175
+ const attemptCount = observedAttempts !== undefined && observedAttempts > 0 ? observedAttempts : null;
176
+ const hooks = object(value.hooks);
177
+ const hookFailure = value.hookErrors != null || (hooks !== undefined && Object.values(hooks).some(state => state !== "pass" && state !== "passed"));
178
+ if (hasRetry(value)) {
179
+ results.push(result(options, "vitest", id, "UNKNOWN", refs, "retry", attemptCount));
180
+ }
181
+ else if (hookFailure) {
182
+ results.push(result(options, "vitest", id, "UNKNOWN", refs, "hook-error", attemptCount));
183
+ }
184
+ else if (status === "passed" || status === "pass") {
185
+ results.push(result(options, "vitest", id, "PASS", refs, "none", attemptCount));
186
+ }
187
+ else if (status === "failed" || status === "fail") {
188
+ results.push(value.assertion === true || value.failureClass === "assertion-failed"
189
+ ? result(options, "vitest", id, "FAIL", refs, "assertion-failed", attemptCount)
190
+ : result(options, "vitest", id, "UNKNOWN", refs, "infrastructure-error", attemptCount));
191
+ }
192
+ else if (status === "skipped" || status === "pending") {
193
+ results.push(result(options, "vitest", id, "UNKNOWN", refs, "skipped", attemptCount));
194
+ }
195
+ else if (status === "todo") {
196
+ results.push(result(options, "vitest", id, "UNKNOWN", refs, "todo", attemptCount));
197
+ }
198
+ else {
199
+ results.push(result(options, "vitest", id, "UNKNOWN", refs, "malformed-output", attemptCount));
200
+ }
201
+ }
202
+ return run(options, "vitest", results, completed, completionEvidence, diagnostics);
203
+ }
204
+ /** Collect Vitest reporter events or its Jest-compatible JSON output. */
205
+ export function collectVitestAcceptance(input, options) {
206
+ const events = eventInput(input);
207
+ if (events !== undefined && events.events.some((event) => {
208
+ const value = object(event);
209
+ const type = value === undefined ? undefined : lower(value.type ?? value.event ?? value.kind);
210
+ return type !== undefined && type.startsWith("test");
211
+ })) {
212
+ return collectEvents(events.events, options, events.diagnostics);
213
+ }
214
+ const report = structuredReport(input);
215
+ if (report === undefined)
216
+ return run(options, "vitest", [], false, [], ["malformed-output"]);
217
+ return mapFramework(collectJestAcceptance(report, options), options);
218
+ }
@@ -24,7 +24,9 @@ function validateBuiltEvidence(value) {
24
24
  }
25
25
  export function buildVerificationEvidence(input) {
26
26
  return validateBuiltEvidence({
27
- schemaVersion: EVIDENCE_SCHEMA_VERSION,
27
+ schemaVersion: input.taskContractHash === undefined && input.acceptance === undefined ? 3 : EVIDENCE_SCHEMA_VERSION,
28
+ ...(input.taskContractHash === undefined ? {} : { taskContractHash: input.taskContractHash }),
29
+ ...(input.acceptance === undefined ? {} : { acceptance: input.acceptance }),
28
30
  taskId: input.taskId,
29
31
  criterionId: input.criterionId,
30
32
  commandId: input.command.id,
@@ -54,7 +56,7 @@ export function isPassingVerificationEvidence(command, evidence) {
54
56
  return false;
55
57
  }
56
58
  return command.evidence.kind !== "test-count" ||
57
- (evidence.schemaVersion === EVIDENCE_SCHEMA_VERSION &&
59
+ (evidence.schemaVersion >= 3 &&
58
60
  evaluateTestCount(evidence.testCount, command.evidence.minimum).status === "PASS");
59
61
  }
60
62
  export class FileEvidenceStore {
@@ -64,6 +66,7 @@ export class FileEvidenceStore {
64
66
  this.#root = root;
65
67
  this.#anchorDirectory = anchorDirectory;
66
68
  }
69
+ get root() { return this.#root; }
67
70
  async save(value) {
68
71
  const evidence = validateBuiltEvidence(value);
69
72
  const content = `${JSON.stringify(evidence, null, 2)}\n`;
@@ -1,3 +1,5 @@
1
+ import { taskContractHash } from "../task/contract.js";
2
+ import { replayAcceptance } from "./acceptance-replay.js";
1
3
  import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
2
4
  import { validateTaskAgainstConfig } from "../schema/validate.js";
3
5
  import { collectChangeSurface } from "./change-surface.js";
@@ -23,7 +25,7 @@ function exitCategory(result) {
23
25
  return result.exitCode === 0 ? "exit-zero" : "nonzero-exit";
24
26
  }
25
27
  function relevantCriteria(task, commandId) {
26
- return task.criteria.filter((criterion) => criterion.verifierIds.includes(commandId));
28
+ return task.criteria.filter((criterion) => criterion.verifierIds.includes(commandId) || criterion.acceptance !== undefined);
27
29
  }
28
30
  function commandById(config, commandId) {
29
31
  const command = config.verification.commands.find((candidate) => candidate.id === commandId);
@@ -59,6 +61,7 @@ export class VerificationService {
59
61
  failureClass: result.failureClass,
60
62
  sourceFingerprint,
61
63
  toolVersions: this.#options.toolVersions ?? {},
64
+ ...(task.goal === undefined && (task.contractRevision ?? 0) === 0 && task.criteria.every(c => c.acceptance === undefined) ? {} : { taskContractHash: taskContractHash(task) }),
62
65
  config: this.#options.config
63
66
  });
64
67
  references.push(await this.#options.evidenceStore.save(evidence));
@@ -113,13 +116,14 @@ export class VerificationService {
113
116
  throw verificationError("VERIFICATION_INPUT_INVALID", validation.errors[0]?.message ??
114
117
  "Task and verification configuration are incompatible.");
115
118
  }
116
- const missingCriteria = validation.value.criteria.filter((criterion) => !this.#options.config.verification.commands.some(({ id, required }) => required && criterion.verifierIds.includes(id)));
119
+ const missingCriteria = validation.value.criteria.filter((criterion) => criterion.acceptance === undefined && !this.#options.config.verification.commands.some(({ id, required }) => required && criterion.verifierIds.includes(id)));
117
120
  if (missingCriteria.length > 0) {
118
121
  throw verificationError("VERIFICATION_UNKNOWN", `Criteria without required coverage: ${missingCriteria.map(({ id }) => id).join(", ")}. Configure at least one required verifier for each criterion and recreate the task; no verifier commands were run.`);
119
122
  }
120
123
  const reviewScope = await resolveReviewScope({
121
124
  root: this.#options.root,
122
125
  runner: this.#options.gitRunner,
126
+ ...(this.#options.noChangePaths === undefined && stored.noChangePaths === undefined ? {} : { noChangePaths: this.#options.noChangePaths ?? stored.noChangePaths }),
123
127
  ...(this.#options.base === undefined ? {} : { base: this.#options.base })
124
128
  });
125
129
  const sourceFingerprint = await calculateSourceFingerprint(this.#options.root, reviewScope, this.#options.gitRunner);
@@ -133,29 +137,38 @@ export class VerificationService {
133
137
  ...mappedSelection,
134
138
  verifierIds: [...new Set([...mappedSelection.verifierIds,
135
139
  ...this.#options.config.verification.commands
136
- .filter(({ id, required }) => required && taskVerifierIds.has(id))
140
+ .filter(({ id, required }) => required && (validation.value.criteria.some(c => c.acceptance !== undefined) || taskVerifierIds.has(id)))
137
141
  .map(({ id }) => id)])]
138
142
  };
139
143
  const results = [];
140
144
  for (const commandId of selection.verifierIds) {
141
145
  results.push(await this.#runCommand(validation.value, commandById(this.#options.config, commandId)));
142
146
  }
143
- let status = aggregateVerificationStatus(results);
147
+ const acceptance = validation.value.criteria.some(c => c.acceptance !== undefined && c.acceptance.mode !== "review-only")
148
+ ? await replayAcceptance({ ...this.#options, task: validation.value, sourceFingerprint }) : [];
149
+ let status = aggregateVerificationStatus([...results, ...acceptance.map(a => ({ required: true, status: a.status }))]);
144
150
  let sourceChanged = false;
145
151
  const postflightScope = await resolveReviewScope({
146
152
  root: this.#options.root,
147
153
  runner: this.#options.gitRunner,
154
+ ...(this.#options.noChangePaths === undefined && stored.noChangePaths === undefined ? {} : { noChangePaths: this.#options.noChangePaths ?? stored.noChangePaths }),
148
155
  ...(this.#options.base === undefined ? {} : { base: this.#options.base })
149
156
  });
150
157
  const postflightFingerprint = await calculateSourceFingerprint(this.#options.root, postflightScope, this.#options.gitRunner);
151
158
  if (reviewScopeSignature(reviewScope) !== reviewScopeSignature(postflightScope) ||
152
- sourceFingerprint !== postflightFingerprint) {
159
+ sourceFingerprint !== postflightFingerprint ||
160
+ taskContractHash((await this.#options.taskService.status({ taskId })).task) !== taskContractHash(stored.task)) {
153
161
  status = "UNKNOWN";
154
162
  sourceChanged = true;
155
163
  }
164
+ const acceptanceEvidence = {};
165
+ for (const proof of acceptance)
166
+ acceptanceEvidence[proof.criterionId] = [...(acceptanceEvidence[proof.criterionId] ?? []), proof.reference];
167
+ if (status !== "PASS" && stored.status === "active" && acceptance.length > 0)
168
+ await this.#options.taskService.recordEvidence(taskId, acceptanceEvidence);
156
169
  let signal = null;
157
170
  if (status === "PASS") {
158
- const taskEvidence = {};
171
+ const taskEvidence = { ...acceptanceEvidence };
159
172
  for (const [index, result] of results.entries()) {
160
173
  const command = commandById(this.#options.config, result.commandId);
161
174
  const references = await this.#persistEvidence(validation.value, command, result.startedAt, result.finishedAt, result, sourceFingerprint);
@@ -175,7 +188,9 @@ export class VerificationService {
175
188
  }
176
189
  }
177
190
  else {
178
- const required = results.filter((result) => result.required);
191
+ const required = [...results.filter((result) => result.required), ...acceptance.map(a => ({ commandId: a.runnerId, required: true,
192
+ status: a.status, failureClass: a.failureClass, exitCode: null, timedOut: false, testCount: null,
193
+ diagnostic: a.criterionId + ":" + a.phase + ":" + a.failureClass, evidenceReferences: [a.reference], startedAt: "", finishedAt: "" }))];
179
194
  const gating = required;
180
195
  const failed = gating.find((result) => result.status !== "PASS");
181
196
  if (failed === undefined) {
@@ -189,7 +204,7 @@ export class VerificationService {
189
204
  diagnostics: "source changed during verification"
190
205
  }));
191
206
  signal = advanced.signal;
192
- return { taskId, status, surface, selection, results, signal, reviewScope, sourceFingerprint };
207
+ return { taskId, status, surface, selection, results, signal, reviewScope, sourceFingerprint, acceptance };
193
208
  }
194
209
  const advanced = await this.#options.taskService.recordFailure(taskId, createFailureFingerprint({
195
210
  commandId: failed.commandId,
@@ -200,6 +215,7 @@ export class VerificationService {
200
215
  signal = advanced.signal;
201
216
  }
202
217
  return {
218
+ acceptance,
203
219
  taskId,
204
220
  status,
205
221
  surface,
@@ -8,7 +8,7 @@ export async function calculateSourceFingerprint(root, scope, runner) {
8
8
  if (scope.mode === "base") {
9
9
  return sha256(JSON.stringify({
10
10
  domain: "agent-ops-source-v1",
11
- mode: "base",
11
+ mode: scope.noChange === true ? "goal-no-change" : "base",
12
12
  head,
13
13
  base: scope.resolvedBase,
14
14
  paths: [...scope.changedFiles]
@@ -146,16 +146,20 @@ async function defaultTerminateWindowsTree(processId, graceMs) {
146
146
  }
147
147
  export class NodeVerificationProcessRunner {
148
148
  #platform;
149
+ #processGroupId;
149
150
  #terminateWindowsTree;
150
151
  constructor(options = {}) {
151
152
  this.#platform = options.platform ?? process.platform;
153
+ this.#processGroupId = options.processGroupId;
154
+ if (this.#processGroupId !== undefined && (!Number.isSafeInteger(this.#processGroupId) || this.#processGroupId < 1 || this.#platform === "win32"))
155
+ throw new Error("Registered proof group must be a positive POSIX process group ID.");
152
156
  this.#terminateWindowsTree =
153
157
  options.terminateWindowsTree ?? defaultTerminateWindowsTree;
154
158
  }
155
159
  start(request) {
156
160
  const child = spawn(request.command, [...request.args], {
157
161
  cwd: request.cwd,
158
- detached: this.#platform !== "win32",
162
+ detached: this.#platform !== "win32" && this.#processGroupId === undefined,
159
163
  env: {
160
164
  ...(request.replaceEnv === true ? {} : process.env),
161
165
  ...(request.env ?? {})
@@ -204,7 +208,7 @@ export class NodeVerificationProcessRunner {
204
208
  await this.#terminateWindowsTree(processId, graceMs);
205
209
  return;
206
210
  }
207
- await terminatePosixTree(processId, completion, graceMs);
211
+ await terminatePosixTree(this.#processGroupId ?? processId, completion, graceMs);
208
212
  }
209
213
  };
210
214
  }
@@ -375,3 +379,10 @@ export async function runVerificationCommand(command, options) {
375
379
  stderrTruncated: capturedStderr.truncated
376
380
  };
377
381
  }
382
+ /** CLI-only opt-in. The dormant entry sets this after the supervisor records its PID. */
383
+ export function registeredRunProofRunner() {
384
+ const value = process.env.AGENT_OPS_RUN_PROOF_PID;
385
+ if (value !== undefined && !/^[1-9][0-9]{0,9}$/u.test(value))
386
+ throw new Error("Invalid registered run proof PID.");
387
+ return new NodeVerificationProcessRunner(value === undefined ? {} : { processGroupId: Number(value) });
388
+ }
@@ -1,5 +1,53 @@
1
1
  # Acceptance and Evidence
2
2
 
3
+ ## EVIDENCE-CONTRACT-001
4
+
5
+ Executable acceptance MUST bind the current contract to immutable replay evidence.
6
+
7
+ - Trigger: Verifying or completing a typed acceptance contract.
8
+ - Action: Replay trusted checks against the immutable baseline and current candidate, then obtain fresh review.
9
+ - Evidence: Digest-bound phase results and current contract coverage enter both reviewer packets.
10
+ - Positive: Baseline assertion FAIL and candidate PASS prove a behavioral check.
11
+ - Negative: A fixture error is counted as red, or an old review proves revised criteria.
12
+
13
+ - `behavioral`: every designated red check fails by assertion on the baseline;
14
+ every required candidate check passes. `invariant`: required candidate checks
15
+ pass; baseline PASS is valid. `review-only`: two fresh reviewers discharge the
16
+ requirement against the original goal; no mechanical proof is claimed.
17
+ - Trusted repository `verification.acceptanceRunners` define argv, setup, product
18
+ build and test build. Tasks provide stable check IDs and explicit committed
19
+ test/fixture/helper materials. Each checkout installs and builds its own version
20
+ before candidate materials are overlaid. Tasks cannot override runner commands.
21
+ - Fixture, collection, build, timeout, cancellation, truncated output, missing or
22
+ duplicate IDs, retries and skips MUST NOT establish red or green. Complete raw
23
+ execution artifacts are digest-bound to the contract, materials and commits.
24
+ - Native Jest JSON and Vitest task errors lack complete test-call versus
25
+ hook/fixture-cleanup provenance. Failed rows MUST remain UNKNOWN unless a
26
+ reviewed repository collector supplies explicit assertion classification;
27
+ AssertionError names, matcher details and passing hook states are insufficient.
28
+ Green requires explicit single-attempt evidence: attempts, Jest invocations,
29
+ or both Vitest retryCount and repeatCount. Native Vitest JSON omits these
30
+ counters and cannot supply mechanical proof by itself. Pytest requires one
31
+ ordered setup/call/teardown lifecycle per nodeid; the Rust protocol requires
32
+ explicit attempts. Missing metadata MUST NOT default to a successful attempt.
33
+ - Baselines are immutable creation commits, or failed candidates for finding pins.
34
+ Revise/replan preserve the original goal, every mapped requirement and previous
35
+ contracts/evidence. New contracts require new proof and both fresh reviews.
36
+ - `task pin-finding` validates saved report digest/index provenance. Recurrence
37
+ explicitly names an existing pin. A sixth criterion returns a replan obligation
38
+ without changing state; the mapped replan must retain the pin and all requirements.
39
+ - Reviewer packets include original goal, revision history, coverage and verifier
40
+ materials. Review checks the assertions themselves and explicit fallback reasons.
41
+ Mechanical green never removes mandatory policy commands or either fresh review.
42
+
43
+ Task state writes version 2 and reads version 1. Evidence version 4 reads earlier
44
+ evidence; attestation version 3 binds contracts and coverage. Legacy criteria keep
45
+ the verifier rules below; typed or revised contracts cannot reuse an old review.
46
+ Coverage separates mechanical proof, review-only and legacy; a runner binding alone
47
+ is not proof. Explicit no-change proof names existing committed regular source files
48
+ at the frozen candidate; integration re-resolves them. Ordinary empty-diff review
49
+ and empty-branch direct finish remain rejected.
50
+
3
51
  ## EVIDENCE-CRITERION-001
4
52
 
5
53
  Every acceptance criterion MUST map to observable evidence.
@@ -41,7 +41,7 @@ capabilities and MUST track generated source as one whole-file artifact.
41
41
  - Action: For opencode, manage `.opencode/plugins/agent-ops.js` in a project or `.config/opencode/plugins/agent-ops.js` at user scope (`$XDG_CONFIG_HOME/opencode/plugins/agent-ops.js` when configured inside the managed user root, or `$OPENCODE_CONFIG_DIR/plugins/agent-ops.js` when that native config directory is configured), leave `opencode.json` untouched, and deduplicate a project `AGENTS.md` contribution by path.
42
42
  - Evidence: The manifest contains the plugin hash, the generated source contains only the selected hook registrations, and shared project markers occur once.
43
43
  - Positive: `codex,opencode` produces one project AGENTS route and one hashed opencode plugin.
44
- - Negative: `Add an opencode.json instructions entry or register a plugin for a core-only profile.`
44
+ - Negative: `Add an opencode.json instructions entry or register a plugin for a core-only profile with Office disabled.`
45
45
 
46
46
  ## HARNESS-ADAPTER-004
47
47
 
@@ -106,6 +106,7 @@ The current registration matrix is intentionally asymmetric:
106
106
 
107
107
  | Capability | agy | Codex | Claude Code | OpenCode |
108
108
  | --- | --- | --- | --- | --- |
109
+ | office-presence (Preview) | degraded | degraded | supported | degraded |
109
110
  | lifecycle-summary | degraded | supported | supported | degraded |
110
111
  | command-policy | supported | unknown | supported | supported |
111
112
  | completion-gate | supported | unsupported | unsupported | unsupported |
@@ -137,3 +138,114 @@ stores only bounded local event metadata, returns bounded redacted session
137
138
  context, and preserves local goal, state, telemetry, and Codex TOML files on
138
139
  update or uninstall. A clearly parsed `[features]` / `hooks = false` in an
139
140
  existing Codex configuration MUST reject loop planning before any write.
141
+
142
+ ## HARNESS-ADAPTER-007
143
+
144
+ The `run` profile MUST be opt-in, select `core` and `loop` with it, and add only
145
+ the `auto-run` capability. With it, the Claude Code and Codex managed rules hand
146
+ a change that needs more than five acceptance criteria to `agent-ops run`; five
147
+ or fewer stay in the session. agy rules, and every rule file without `auto-run`,
148
+ MUST stay byte-identical to the rules without the profile.
149
+
150
+ - Trigger: A project selects `run`, and a Claude Code or Codex session meets a
151
+ change that needs more than five acceptance criteria.
152
+ - Action: From the main checkout and before any `task create`, the agent writes
153
+ the user's prompt verbatim and its proposed acceptance criteria, marked as
154
+ proposals, to the gitignored `.agent-ops/state/run-goal.md`, then starts
155
+ `agent-ops run --goal-file .agent-ops/state/run-goal.md --host <claude|codex> --wait`
156
+ as a background shell command. Codex starts it like review: an outer request
157
+ with `sandbox_permissions: "require_escalated"` and
158
+ `env -u CODEX_SANDBOX_NETWORK_DISABLED`. An awaiting-input result is relayed
159
+ to the user and answered with `agent-ops run respond`. A start refused with
160
+ `RUN_TARGET_REQUIRED`, `RUN_BACKGROUND_UNSUPPORTED`, `RUN_TARGET_DIRTY` or
161
+ `WORKTREE_NESTED` is reported in one line naming the code and falls back to
162
+ the subtask flow; `RUN_REPO_UNTRUSTED` stops and asks the user. A run that
163
+ ends without completing (blocked, budget exhausted, stopped, failing review)
164
+ is reported with its `run status` state and code, its last `run logs` events,
165
+ and the exact `agent-ops run resume <id>` and `agent-ops run stop <id>`
166
+ commands, and is never resumed automatically or taken over by the session.
167
+ With worktree auto mode, the Claude `PreToolUse` worktree guard allows a
168
+ write to exactly `.agent-ops/state/run-goal.md` in the main checkout without
169
+ creating a worktree. Confirmed init/update pre-authorizes `agent-ops run`:
170
+ `Bash(agent-ops run *)` for Claude Code, and an escalated prefix rule for
171
+ `env -u CODEX_SANDBOX_NETWORK_DISABLED agent-ops run` for Codex.
172
+ - Evidence: Profile, managed-rule digest, pre-authorization, and worktree-guard
173
+ tests cover the resolved profiles, the unchanged rules, the added entries, and
174
+ the single exempt path.
175
+ - Positive: `A Claude Code session with the run profile writes .agent-ops/state/run-goal.md in the main checkout and starts agent-ops run --host claude --wait in the background.`
176
+ - Negative: `Split an eight-criteria change into subtasks while the run profile is active, add auto-run text to GEMINI.md, or resume a blocked run without the user.`
177
+
178
+ ## HARNESS-ADAPTER-008
179
+
180
+ `agent-ops office` (Preview) MUST show progress read-only from agent-ops state alone. The
181
+ supervisor records an optional phase per run and worker (`planning`,
182
+ `implementing`, `verifying`, `reviewing`, `integrating`) at deterministic
183
+ transitions, and each task's latest verify and review result with passed/total
184
+ criteria. Run state written before phases existed MUST still validate and show
185
+ as `unknown`. Changed files come from each worktree's own `git diff` against its
186
+ base; narration uses paths only (`docs/**` is "writing docs", `tests/**` is
187
+ "writing tests", otherwise "editing <file>"). The office MUST NOT read native
188
+ Claude or Codex transcripts or file contents.
189
+
190
+ - Office Preview is opt-in via `features.office.enabled`. Init MUST offer an
191
+ off-by-default choice; interactive update MUST default to the existing choice.
192
+ Non-interactive update MUST preserve it unless `--office on|off` is supplied.
193
+ Older configs without the optional field remain valid and mean disabled.
194
+ Enabling Office MUST install its presence hooks even with only `core`; this
195
+ MUST NOT opt into command policy, Stop verification or lifecycle summaries.
196
+ Office-only OpenCode runtime failure MUST remain fail-open; adding Office to
197
+ command policy MUST preserve policy denial and unavailable-runtime behavior.
198
+ Agy startup is per invocation, OpenCode startup is app-scoped, and Codex
199
+ Stop delivery is unavailable in some execution modes; stale presence expires
200
+ using the bounded hook-activity timeout rather than assuming native Stop.
201
+ Features retain their existing whole-object layer precedence. Disabled hooks
202
+ MUST NOT record Office presence or start/open Office; the explicit command
203
+ MUST explain how to enable it. A running server MUST close after observing a
204
+ disabled choice on its next 15-second lifecycle check.
205
+ - Trigger: When enabled, a managed ordinary session starts or reports activity, or a user runs
206
+ `agent-ops office`, `agent-ops run start` or `agent-ops run status`.
207
+ - Action: The command reuses the live server recorded in
208
+ `<git common dir>/agent-ops/office.json`, or starts one, and prints its URL.
209
+ Ordinary session hooks also start or reuse it, including before a task or
210
+ worktree exists. Only the successful new-server owner opens the browser;
211
+ other sessions share the same Office. Session metadata contains bounded
212
+ identifiers, known work state and activity times, never native transcripts,
213
+ prompts or tool/file contents. Startup and observation failures are advisory.
214
+ The server binds
215
+ 127.0.0.1 on a random port, requires the URL's unguessable token, refuses any
216
+ Host other than that address and port, answers every non-GET with 405, and
217
+ exits 10 minutes after neither a run nor an ordinary session is active.
218
+ Ordinary-session liveness follows hooks, with stale presence expiring after
219
+ 30 minutes without activity; it does not inspect native host processes.
220
+ Its single inline page uses cozy cream, oak and sage in-code 16-color scene
221
+ sprites with no image assets. Each 32×48 character uses its own fixed skin,
222
+ hair and clothing palette, four-direction walking, and localized role labels.
223
+ The viewer's badge displays `You` in English and `你` in Chinese; its overview size matches the largest room's
224
+ characters. The fixed-viewport overview
225
+ fills the available page below the header and shows all furnished work-unit
226
+ rooms, shrinking rooms as necessary without document scrolling. A shared
227
+ hallway connects the rooms. The user controls a local Supervisor with arrow
228
+ keys or WASD, can walk into every room and return to the overview, without
229
+ model requests or network mutations. Each run team shares a room; each ordinary session has its own,
230
+ and running reviews remain visible. Clicking a room enters a larger detail
231
+ view with a return control. Every room contains planning, development,
232
+ verification, review and integration areas. Characters walk to their known
233
+ phase's zone, use light phase-driven animation, and have short name/status
234
+ labels; unknown state is not inferred from dialogue. Whiteboards show task
235
+ criteria progress, verify/review outcomes and pending questions, with `!` for
236
+ unanswered questions. Clicking a whiteboard or progress board opens the room
237
+ work list; workstations and phase areas show the known work in that phase.
238
+ Pending markers open complete questions with the existing copyable commands.
239
+ The same paged views are available with `L` (work list), `1–5` (phases) and
240
+ `Q` (questions). Clicking an actor opens bounded, paged status details
241
+ with copyable commands. Completed rooms are available through Recently
242
+ completed for at most two hours. Chinese/English switching remembers the
243
+ choice, and keyboard navigation and reduced motion are supported. A failure
244
+ to start the office never fails a hook, `run start` or `run status`.
245
+ - Evidence: Phase, snapshot, server, scene and CLI tests cover legacy state,
246
+ aggregation and narration, ordinary-session registration and startup-once,
247
+ the two-hour completion boundary, token/Host/method guards, reuse and the
248
+ injected-clock idle exit, viewport/room navigation, localization, and the
249
+ printed URL. A browser read-back checks the rendered single-page layout.
250
+ - Positive: `An ordinary session opens the shared Office, where its room is visible before it creates a worktree; entering a team room shows the coordinator at the verification bench and a whiteboard with 2 of 6 criteria passed.`
251
+ - Negative: `Serve the office on 0.0.0.0, accept a POST, read a worker's transcript to narrate it, or bundle a PNG sprite sheet.`
@@ -1,5 +1,54 @@
1
1
  # Loop Engineering
2
2
 
3
+ ## LOOP-RUN-001
4
+
5
+ `run` MUST preserve the original user goal and require current agent-ops proof.
6
+
7
+ - Trigger: Starting, repairing or recovering a native goal run.
8
+ - Action: Bind writers to current leases and require final verification, dual review and receipt proof.
9
+ - Evidence: The run ledger and sealed receipt bind the current candidate and original goal.
10
+ - Positive: Recovering interrupted cleanup preserves the target and saved final proof.
11
+ - Negative: Native goal completion alone changes run state to complete.
12
+
13
+ - The macOS supervisor registers Claude/Codex native identities before activating
14
+ goals. Same-host writers use isolated worktrees and current generation leases;
15
+ the coordinator counts toward the maximum two writers. Dependencies become
16
+ available only through frozen deliveries; child baselines follow those commits.
17
+ - The default 60-minute budget counts the union of active intervals, including
18
+ setup, proof, review and integration. Resume keeps elapsed time and usage epochs.
19
+ Missing counters remain UNKNOWN; partial usage is not a complete cost estimate.
20
+ - Questions pause work and require an explicit answer. Repair preserves the goal
21
+ and records failures; two identical rounds without progress stop that worker
22
+ and its dependents. Independent tasks remain eligible. Review findings require
23
+ regression pins or recorded review-only fallback before another final gate.
24
+ - Native completion is observational. Finalization requires mandatory verification,
25
+ both fresh reviews, completed task state and a receipt bound to the target.
26
+ Integration seals the candidate before moving the target and records task,
27
+ receipt, note and cleanup progress. Recovery validates that seal and does not
28
+ repeat a successful target mutation. Inconsistent targets remain blocked.
29
+ - Stop persists disabled continuation before confirming registered process-group
30
+ death. Crash recovery cannot replace a live or unidentified writer. Dirty
31
+ checkouts, version drift and restart storms preserve the scene for diagnosis.
32
+ Reboot/login changes require explicit resume; no global host policy is changed.
33
+ Startup process identities are saved before the native handshake; a stopped,
34
+ unopened Codex session may initialize afresh only after process death is proved.
35
+
36
+ - Setup and native-issued local proof commands MUST register dormant process
37
+ groups before execution; Stop also reconciles their descendants. Interrupted
38
+ setup requires explicit resume and retains its checkout and failure artifact.
39
+ - A coordinator may assess additional fixed non-dangerous capabilities only under
40
+ a run-scoped policy bound to the original repository trust, executable runtime
41
+ and remaining budget. Explicit native or organization denial cannot be bypassed.
42
+ Policy changes fence all writers, preserve frozen deliveries and journal contract
43
+ synchronization before new generations acquire leases. This never grants global
44
+ repository trust. Native decisions retain redacted command scope and rationale;
45
+ absent provider evidence remains UNKNOWN.
46
+ Explicit resume renews expired authorization from the remaining budget and
47
+ reconciles immutable renewal lineage before policy synchronization. An answer
48
+ uses the same recovery checks; neither action can revive an explicit denial.
49
+ - Convergence compares actual per-check statuses and outstanding finding pins.
50
+ Source fingerprints, timing and diagnostics alone cannot establish progress.
51
+
3
52
  This module defines the bounded loop used to plan, implement, verify, and hand off work.
4
53
 
5
54
  ## LOOP-START-001