@nimiplatform/nimi-coding 0.1.0 → 0.2.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 (121) hide show
  1. package/README.md +19 -20
  2. package/adapters/oh-my-codex/README.md +8 -9
  3. package/cli/commands/audit-sweep.mjs +10 -10
  4. package/cli/commands/classify-spec-tree.mjs +5 -0
  5. package/cli/commands/closeout.mjs +3 -0
  6. package/cli/commands/generate-spec-derived-docs.mjs +20 -0
  7. package/cli/commands/generate-spec-migration-plan.mjs +30 -0
  8. package/cli/commands/start.mjs +5 -1
  9. package/cli/commands/surface-validator-command.mjs +49 -0
  10. package/cli/commands/sweep-design.mjs +295 -0
  11. package/cli/commands/sweep.mjs +22 -0
  12. package/cli/commands/sync.mjs +132 -0
  13. package/cli/commands/topic-formatters.mjs +8 -8
  14. package/cli/commands/validate-ai-governance.mjs +167 -46
  15. package/cli/commands/validate-domain-admission.mjs +5 -0
  16. package/cli/commands/validate-guidance-bodies.mjs +5 -0
  17. package/cli/commands/validate-placement.mjs +5 -0
  18. package/cli/commands/validate-projection-edges.mjs +5 -0
  19. package/cli/commands/validate-spec-audit.mjs +5 -1
  20. package/cli/commands/validate-table-family.mjs +5 -0
  21. package/cli/commands/validate-tracked-output-admission.mjs +5 -0
  22. package/cli/constants.mjs +5 -49
  23. package/cli/help.mjs +33 -11
  24. package/cli/index.mjs +20 -2
  25. package/cli/lib/audit-sweep-runtime/admissions.mjs +38 -29
  26. package/cli/lib/audit-sweep-runtime/audit-validity.mjs +8 -0
  27. package/cli/lib/audit-sweep-runtime/chunks.mjs +11 -11
  28. package/cli/lib/audit-sweep-runtime/closeout.mjs +8 -8
  29. package/cli/lib/audit-sweep-runtime/codex-auditor-evidence.mjs +3 -3
  30. package/cli/lib/audit-sweep-runtime/codex-auditor.mjs +10 -10
  31. package/cli/lib/audit-sweep-runtime/common.mjs +7 -7
  32. package/cli/lib/audit-sweep-runtime/format.mjs +3 -3
  33. package/cli/lib/audit-sweep-runtime/ingest.mjs +8 -8
  34. package/cli/lib/audit-sweep-runtime/inventory-spec-chunks.mjs +24 -27
  35. package/cli/lib/audit-sweep-runtime/inventory.mjs +58 -18
  36. package/cli/lib/audit-sweep-runtime/ledger.mjs +1 -1
  37. package/cli/lib/audit-sweep-runtime/p0p1-profile.mjs +2 -2
  38. package/cli/lib/audit-sweep-runtime/remediation.mjs +6 -6
  39. package/cli/lib/audit-sweep-runtime/rerun.mjs +6 -6
  40. package/cli/lib/audit-sweep-runtime/status.mjs +1 -1
  41. package/cli/lib/audit-sweep-runtime/validators.mjs +2 -2
  42. package/cli/lib/authority-convergence.mjs +397 -2
  43. package/cli/lib/blueprint-audit.mjs +5 -5
  44. package/cli/lib/closeout.mjs +126 -3
  45. package/cli/lib/contracts.mjs +21 -17
  46. package/cli/lib/handoff.mjs +29 -11
  47. package/cli/lib/high-risk-admission.mjs +60 -11
  48. package/cli/lib/high-risk-decision.mjs +31 -2
  49. package/cli/lib/high-risk-ingest.mjs +5 -1
  50. package/cli/lib/high-risk-review.mjs +5 -1
  51. package/cli/lib/internal/contracts-parse.mjs +195 -24
  52. package/cli/lib/internal/contracts-validators.mjs +3 -2
  53. package/cli/lib/internal/doctor-bootstrap-surface.mjs +82 -35
  54. package/cli/lib/internal/doctor-delegated-surface.mjs +1 -1
  55. package/cli/lib/internal/doctor-finalize.mjs +12 -8
  56. package/cli/lib/internal/doctor-inspectors.mjs +34 -1
  57. package/cli/lib/internal/governance/ai/ai-context-budget-core.mjs +74 -12
  58. package/cli/lib/internal/governance/ai/ai-structure-budget-core.mjs +24 -6
  59. package/cli/lib/internal/governance/ai/check-agents-freshness.mjs +18 -23
  60. package/cli/lib/internal/surface-taxonomy-validators.mjs +931 -0
  61. package/cli/lib/internal/validators-spec.mjs +229 -20
  62. package/cli/lib/sweep-design-runtime/common.mjs +246 -0
  63. package/cli/lib/sweep-design-runtime/engine.mjs +733 -0
  64. package/cli/lib/sweep-design-runtime/fix-topic.mjs +414 -0
  65. package/cli/lib/sweep-design-runtime/lifecycle.mjs +54 -0
  66. package/cli/lib/sweep-design-runtime/results.mjs +324 -0
  67. package/cli/lib/sweep-design.mjs +8 -0
  68. package/cli/lib/sync.mjs +143 -0
  69. package/cli/lib/topic-artifacts.mjs +186 -0
  70. package/cli/lib/topic-authority-coverage.mjs +73 -0
  71. package/cli/lib/topic-closeout.mjs +560 -0
  72. package/cli/lib/topic-common.mjs +404 -0
  73. package/cli/lib/topic-decisions.mjs +332 -0
  74. package/cli/lib/topic-draft-packets.mjs +126 -7
  75. package/cli/lib/topic-execution.mjs +515 -0
  76. package/cli/lib/topic-goal.mjs +112 -33
  77. package/cli/lib/topic-ledger.mjs +281 -0
  78. package/cli/lib/topic-lifecycle-artifacts.mjs +173 -0
  79. package/cli/lib/topic-root-validation.mjs +288 -0
  80. package/cli/lib/topic-runner-commands.mjs +174 -0
  81. package/cli/lib/topic-runner-deferral.mjs +532 -0
  82. package/cli/lib/topic-runner-stale-gates.mjs +114 -0
  83. package/cli/lib/topic-runner-validation.mjs +138 -0
  84. package/cli/lib/topic-runner.mjs +109 -154
  85. package/cli/lib/topic-scaffold.mjs +252 -0
  86. package/cli/lib/topic-waves.mjs +403 -0
  87. package/cli/lib/topic.mjs +81 -93
  88. package/cli/lib/value-helpers.mjs +6 -1
  89. package/cli/seeds/bootstrap.mjs +96 -20
  90. package/cli/seeds/seed-policy.yaml +67 -0
  91. package/config/bootstrap.yaml +1 -1
  92. package/config/skill-manifest.yaml +4 -2
  93. package/config/spec-generation-inputs.yaml +41 -19
  94. package/contracts/audit-remediation-map.schema.yaml +1 -0
  95. package/contracts/audit-sweep-result.yaml +4 -0
  96. package/contracts/domain-admission.schema.yaml +56 -0
  97. package/contracts/migration-inventory.schema.yaml +80 -0
  98. package/contracts/negative-fixtures.yaml +91 -0
  99. package/contracts/placement-contract.schema.yaml +163 -0
  100. package/contracts/projection-edge.schema.yaml +130 -0
  101. package/contracts/shared-enums.yaml +68 -0
  102. package/contracts/spec-generation-audit.schema.yaml +19 -4
  103. package/contracts/spec-generation-inputs.schema.yaml +130 -29
  104. package/contracts/spec-reconstruction-result.yaml +9 -5
  105. package/contracts/surface-taxonomy.schema.yaml +201 -0
  106. package/contracts/sweep-design-result.yaml +349 -0
  107. package/contracts/table-family.schema.yaml +114 -0
  108. package/contracts/topic-goal.schema.yaml +10 -1
  109. package/contracts/tracked-output-admission.schema.yaml +70 -0
  110. package/contracts/workflow-consumer.schema.yaml +112 -0
  111. package/methodology/audit-sweep-p0p1-recall.yaml +1 -1
  112. package/methodology/spec-reconstruction.yaml +53 -30
  113. package/package.json +5 -4
  114. package/spec/_meta/command-gating-matrix.yaml +33 -0
  115. package/spec/_meta/generate-drift-migration-checklist.yaml +44 -62
  116. package/spec/_meta/governance-routing-cutover-checklist.yaml +3 -3
  117. package/spec/_meta/phase2-impacted-surface-matrix.yaml +14 -14
  118. package/spec/_meta/spec-authority-cutover-readiness.yaml +3 -5
  119. package/spec/_meta/spec-tree-model.yaml +104 -36
  120. package/spec/bootstrap-state.yaml +36 -36
  121. package/spec/product-scope.yaml +13 -10
@@ -0,0 +1,138 @@
1
+ import { spawn } from "node:child_process";
2
+ import { writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { loadTopicReport } from "./topic.mjs";
6
+
7
+ function utcNowNoMillis() {
8
+ return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
9
+ }
10
+
11
+ function toPortablePath(value) {
12
+ return value.split(path.sep).join("/");
13
+ }
14
+
15
+ function projectRef(projectRoot, absolutePath) {
16
+ return toPortablePath(path.relative(projectRoot, absolutePath));
17
+ }
18
+
19
+ function safeSegment(value) {
20
+ return String(value).replace(/[^a-zA-Z0-9._-]+/g, "-");
21
+ }
22
+
23
+ function hasPlaceholder(value) {
24
+ return /<[^>]+>/.test(value);
25
+ }
26
+
27
+ function isFilteredPnpmCommand(command) {
28
+ return /(?:^|\s)pnpm\s+--filter\s+\S+\s+\S+/u.test(command);
29
+ }
30
+
31
+ export function classifyValidationCommandResult(command, exitCode, stdout = "", stderr = "") {
32
+ const combinedOutput = `${stdout}\n${stderr}`;
33
+ if (
34
+ exitCode === 0
35
+ && isFilteredPnpmCommand(command)
36
+ && /No projects matched the filters/u.test(combinedOutput)
37
+ ) {
38
+ return {
39
+ status: "validation_drift",
40
+ passed: false,
41
+ summary: "Filtered package command matched no projects; replace with a concrete validation command or admit an explicit no-package evidence rule.",
42
+ };
43
+ }
44
+ if (exitCode === 0) {
45
+ return { status: "pass", passed: true, summary: "validation command passed" };
46
+ }
47
+ const failureLine = combinedOutput
48
+ .split(/\r?\n/u)
49
+ .map((line) => line.trim())
50
+ .find((line) => line.length > 0) ?? "validation command failed";
51
+ return {
52
+ status: "fail",
53
+ passed: false,
54
+ summary: failureLine.slice(0, 300),
55
+ };
56
+ }
57
+
58
+ function runShellCommand(command, cwd) {
59
+ return new Promise((resolve) => {
60
+ const child = spawn(command, {
61
+ cwd,
62
+ shell: true,
63
+ stdio: ["ignore", "pipe", "pipe"],
64
+ });
65
+ const stdout = [];
66
+ const stderr = [];
67
+ child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
68
+ child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
69
+ child.on("error", (error) => {
70
+ resolve({
71
+ exitCode: 1,
72
+ stdout: Buffer.concat(stdout).toString("utf8"),
73
+ stderr: `${Buffer.concat(stderr).toString("utf8")}${error.message}\n`,
74
+ });
75
+ });
76
+ child.on("close", (code) => {
77
+ resolve({
78
+ exitCode: typeof code === "number" ? code : 1,
79
+ stdout: Buffer.concat(stdout).toString("utf8"),
80
+ stderr: Buffer.concat(stderr).toString("utf8"),
81
+ });
82
+ });
83
+ });
84
+ }
85
+
86
+ export async function runValidationCommandEvidence(projectRoot, options) {
87
+ const loaded = await loadTopicReport(projectRoot, options.topicInput);
88
+ if (!loaded.ok) {
89
+ return loaded;
90
+ }
91
+ const command = String(options.command ?? "").trim();
92
+ if (!command || hasPlaceholder(command)) {
93
+ return {
94
+ ok: false,
95
+ error: "topic-runner validation refused: command must be concrete.",
96
+ };
97
+ }
98
+ const cwd = path.resolve(projectRoot, options.cwd ?? ".");
99
+ const startedAt = options.startedAt ?? utcNowNoMillis();
100
+ const runResult = await runShellCommand(command, cwd);
101
+ const completedAt = options.completedAt ?? utcNowNoMillis();
102
+ const classification = classifyValidationCommandResult(
103
+ command,
104
+ runResult.exitCode,
105
+ runResult.stdout,
106
+ runResult.stderr,
107
+ );
108
+ const validationId = safeSegment(options.validationId ?? command).slice(0, 96) || "validation";
109
+ const evidencePath = path.join(loaded.topicDir, `evidence-validation-${validationId}.json`);
110
+ const evidence = {
111
+ contract: "nimicoding.topic-runner.validation-evidence.v1",
112
+ topic_id: loaded.topicId,
113
+ run_id: options.runId ?? null,
114
+ command,
115
+ cwd: projectRef(projectRoot, cwd),
116
+ started_at: startedAt,
117
+ completed_at: completedAt,
118
+ exit_code: runResult.exitCode,
119
+ status: classification.status,
120
+ stdout: runResult.stdout,
121
+ stderr: runResult.stderr,
122
+ remediation: classification.status === "validation_drift"
123
+ ? "replace with concrete validation command or admit an explicit no-package evidence rule"
124
+ : null,
125
+ };
126
+ await writeFile(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`, "utf8");
127
+ return {
128
+ ok: classification.passed,
129
+ topicId: loaded.topicId,
130
+ topicRef: projectRef(projectRoot, loaded.topicDir),
131
+ command,
132
+ exitCode: runResult.exitCode,
133
+ status: classification.status,
134
+ passed: classification.passed,
135
+ evidenceRef: projectRef(projectRoot, evidencePath),
136
+ summary: classification.summary,
137
+ };
138
+ }
@@ -2,6 +2,14 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
4
  import { runNativeCodexSdkPrompt } from "./codex-sdk-runner.mjs";
5
+ import { parseMechanicalCommandRef } from "./topic-runner-commands.mjs";
6
+ export { parseMechanicalCommandRef } from "./topic-runner-commands.mjs";
7
+ import { deferLocalWaveBlocker } from "./topic-runner-deferral.mjs";
8
+ import { maybeResolveStaleHumanGate } from "./topic-runner-stale-gates.mjs";
9
+ export {
10
+ classifyValidationCommandResult,
11
+ runValidationCommandEvidence,
12
+ } from "./topic-runner-validation.mjs";
5
13
  import {
6
14
  admitWaveInTopic,
7
15
  buildTopicRunLedger,
@@ -12,6 +20,7 @@ import {
12
20
  initTopicRunLedger,
13
21
  loadTopicReport,
14
22
  readTopicRunLedger,
23
+ recordTopicResult,
15
24
  recordTopicRunEvent,
16
25
  } from "./topic.mjs";
17
26
 
@@ -65,8 +74,12 @@ function isTerminalWave(wave) {
65
74
  return ["closed", "retired", "superseded"].includes(wave?.state);
66
75
  }
67
76
 
77
+ function getTopicWaves(topic) {
78
+ return Array.isArray(topic?.waves) ? topic.waves : [];
79
+ }
80
+
68
81
  function findDeterministicNextWave(topic) {
69
- const waves = Array.isArray(topic?.waves) ? topic.waves : [];
82
+ const waves = getTopicWaves(topic);
70
83
  const terminalIds = new Set(waves.filter(isTerminalWave).map((wave) => wave.wave_id));
71
84
  const ready = waves.filter((wave) => {
72
85
  if (isTerminalWave(wave)) return false;
@@ -74,7 +87,7 @@ function findDeterministicNextWave(topic) {
74
87
  const deps = Array.isArray(wave.deps) ? wave.deps : [];
75
88
  return deps.every((dep) => terminalIds.has(dep));
76
89
  });
77
- return ready.length === 1 ? ready[0] : null;
90
+ return ready.length > 0 ? ready[0] : null;
78
91
  }
79
92
 
80
93
  function normalizePhaseTransitionDecision(decisionReport, topic) {
@@ -115,7 +128,7 @@ function normalizePhaseTransitionDecision(decisionReport, topic) {
115
128
  reason_code: "deterministic_next_wave_ready",
116
129
  requires_human_confirmation: false,
117
130
  recommended_decision: "admit_wave",
118
- recommendation_rationale: "Exactly one dependency-ready non-terminal wave exists, so selecting the next phase is mechanical.",
131
+ recommendation_rationale: "The first dependency-ready non-terminal wave in topic.yaml waves[] order is selected mechanically.",
119
132
  expected_artifacts: [],
120
133
  next_command_ref: `nimicoding topic wave admit ${topic.topic_id} ${nextWave.wave_id}`,
121
134
  blocking_checks: [],
@@ -186,153 +199,6 @@ async function rewriteMovedRunEventRef(projectRoot, eventRef, fromTopicRef, toTo
186
199
  await writeFile(eventPath, eventText.split(fromRef).join(toRef), "utf8");
187
200
  }
188
201
 
189
- function parseMechanicalCommandRef(commandRef, topicId) {
190
- if (typeof commandRef !== "string" || commandRef.trim().length === 0) {
191
- return {
192
- ok: false,
193
- error: "topic-runner refused: decision.next_command_ref is empty",
194
- };
195
- }
196
- if (hasPlaceholder(commandRef)) {
197
- return {
198
- ok: false,
199
- error: `topic-runner refused: decision.next_command_ref contains a placeholder: ${commandRef}`,
200
- };
201
- }
202
-
203
- const parts = commandRef.trim().split(/\s+/);
204
- const expectedPrefix = ["nimicoding", "topic"];
205
- if (parts[0] !== expectedPrefix[0] || parts[1] !== expectedPrefix[1]) {
206
- return {
207
- ok: false,
208
- error: `topic-runner refused: next command is not a package-owned topic command: ${commandRef}`,
209
- };
210
- }
211
-
212
- const [domain, action, commandTopicId] = parts.slice(2, 5);
213
- if (domain === "wave" && action === "admit") {
214
- const waveId = parts[5] ?? null;
215
- if (commandTopicId !== topicId) {
216
- return {
217
- ok: false,
218
- error: `topic-runner refused: next command topic ${commandTopicId} does not match ${topicId}`,
219
- };
220
- }
221
- if (!waveId || waveId.startsWith("--")) {
222
- return {
223
- ok: false,
224
- error: `topic-runner refused: wave admit command is missing wave id: ${commandRef}`,
225
- };
226
- }
227
- return {
228
- ok: true,
229
- action: "admit_wave",
230
- waveId,
231
- };
232
- }
233
-
234
- if (domain === "packet" && action === "freeze") {
235
- if (commandTopicId !== topicId) {
236
- return {
237
- ok: false,
238
- error: `topic-runner refused: next command topic ${commandTopicId} does not match ${topicId}`,
239
- };
240
- }
241
- const fromFlagIndex = parts.indexOf("--from");
242
- const draftPath = fromFlagIndex >= 0 ? parts[fromFlagIndex + 1] : null;
243
- if (!draftPath || draftPath.startsWith("--")) {
244
- return {
245
- ok: false,
246
- error: `topic-runner refused: packet freeze command is missing --from: ${commandRef}`,
247
- };
248
- }
249
- return {
250
- ok: true,
251
- action: "freeze_packet",
252
- draftPath,
253
- };
254
- }
255
-
256
- if (["worker", "audit"].includes(domain) && action === "dispatch") {
257
- if (commandTopicId !== topicId) {
258
- return {
259
- ok: false,
260
- error: `topic-runner refused: next command topic ${commandTopicId} does not match ${topicId}`,
261
- };
262
- }
263
-
264
- const packetFlagIndex = parts.indexOf("--packet");
265
- const packetId = packetFlagIndex >= 0 ? parts[packetFlagIndex + 1] : null;
266
- if (!packetId || packetId.startsWith("--")) {
267
- return {
268
- ok: false,
269
- error: `topic-runner refused: dispatch command is missing --packet: ${commandRef}`,
270
- };
271
- }
272
-
273
- return {
274
- ok: true,
275
- action: domain === "audit" ? "dispatch_audit" : "dispatch_worker",
276
- role: domain,
277
- packetId,
278
- };
279
- }
280
-
281
- if (domain === "closeout" && action === "wave") {
282
- if (commandTopicId !== topicId) {
283
- return {
284
- ok: false,
285
- error: `topic-runner refused: next command topic ${commandTopicId} does not match ${topicId}`,
286
- };
287
- }
288
- const waveId = parts[5] ?? null;
289
- if (!waveId || waveId.startsWith("--")) {
290
- return {
291
- ok: false,
292
- error: `topic-runner refused: closeout wave command is missing wave id: ${commandRef}`,
293
- };
294
- }
295
-
296
- const optionValue = (flag) => {
297
- const index = parts.indexOf(flag);
298
- return index >= 0 ? parts[index + 1] : null;
299
- };
300
- const authorityClosure = optionValue("--authority");
301
- const semanticClosure = optionValue("--semantic");
302
- const consumerClosure = optionValue("--consumer");
303
- const driftResistanceClosure = optionValue("--drift-resistance");
304
- const disposition = optionValue("--disposition");
305
- if (
306
- !authorityClosure || authorityClosure.startsWith("--") ||
307
- !semanticClosure || semanticClosure.startsWith("--") ||
308
- !consumerClosure || consumerClosure.startsWith("--") ||
309
- !driftResistanceClosure || driftResistanceClosure.startsWith("--") ||
310
- !disposition || disposition.startsWith("--")
311
- ) {
312
- return {
313
- ok: false,
314
- error: `topic-runner refused: closeout wave command is missing required closure flags: ${commandRef}`,
315
- };
316
- }
317
-
318
- return {
319
- ok: true,
320
- action: "closeout_wave",
321
- waveId,
322
- authorityClosure,
323
- semanticClosure,
324
- consumerClosure,
325
- driftResistanceClosure,
326
- disposition,
327
- };
328
- }
329
-
330
- return {
331
- ok: false,
332
- error: `topic-runner refused: unsupported mechanical next command: ${commandRef}`,
333
- };
334
- }
335
-
336
202
  async function executeMechanicalCommand(projectRoot, options, parsedCommand) {
337
203
  if (parsedCommand.action === "admit_wave") {
338
204
  const report = await admitWaveInTopic(projectRoot, options.topicInput, parsedCommand.waveId);
@@ -385,6 +251,29 @@ async function executeMechanicalCommand(projectRoot, options, parsedCommand) {
385
251
  };
386
252
  }
387
253
 
254
+ if (parsedCommand.action === "record_result") {
255
+ const report = await recordTopicResult(
256
+ projectRoot,
257
+ options.topicInput,
258
+ parsedCommand.resultKind,
259
+ parsedCommand.verdict,
260
+ parsedCommand.fromPath,
261
+ parsedCommand.verifiedAt,
262
+ );
263
+ return {
264
+ ok: report.ok,
265
+ action: parsedCommand.action,
266
+ report,
267
+ eventKind: "result_recorded",
268
+ eventSourceRef: report.ok ? report.resultRef : null,
269
+ summary: report.ok ? `${parsedCommand.resultKind}_result_recorded` : "runner_result_record_failed",
270
+ artifactRefs: report.ok ? { result_ref: report.resultRef } : {},
271
+ waveId: report.ok ? report.waveId : null,
272
+ recordedAt: parsedCommand.verifiedAt,
273
+ error: report.ok ? null : report.error,
274
+ };
275
+ }
276
+
388
277
  return {
389
278
  ok: false,
390
279
  action: parsedCommand.action,
@@ -466,7 +355,7 @@ export async function runTopicRunnerStep(projectRoot, options, deps = {}) {
466
355
  }
467
356
 
468
357
  const recordedAt = options.verifiedAt ?? utcNowNoMillis();
469
- const ledger = await ensureLedger(projectRoot, options.topicInput, options.runId, recordedAt);
358
+ let ledger = await ensureLedger(projectRoot, options.topicInput, options.runId, recordedAt);
470
359
  if (!ledger.ok) {
471
360
  return ledger;
472
361
  }
@@ -482,6 +371,19 @@ export async function runTopicRunnerStep(projectRoot, options, deps = {}) {
482
371
  }
483
372
  const decisionReport = normalizePhaseTransitionDecision(rawDecisionReport, loaded.topic);
484
373
 
374
+ const staleGateSync = await maybeResolveStaleHumanGate(
375
+ projectRoot,
376
+ options,
377
+ loaded,
378
+ ledger,
379
+ decisionReport.decision,
380
+ recordedAt,
381
+ );
382
+ if (!staleGateSync.ok) {
383
+ return staleGateSync;
384
+ }
385
+ ledger = staleGateSync.ledger;
386
+
485
387
  const decisionRef = await writeDecisionArtifact(
486
388
  projectRoot,
487
389
  loaded,
@@ -507,6 +409,56 @@ export async function runTopicRunnerStep(projectRoot, options, deps = {}) {
507
409
  }
508
410
 
509
411
  if (decisionReport.decision.stop_class !== "continue") {
412
+ if (options.allowDeferredLocalBlockers === true) {
413
+ const deferred = await deferLocalWaveBlocker(
414
+ projectRoot,
415
+ loaded,
416
+ decisionReport.decision,
417
+ decisionRef,
418
+ recordedAt,
419
+ );
420
+ if (deferred.ok) {
421
+ const deferredEvent = await recordTopicRunEvent(projectRoot, options.topicInput, {
422
+ runId: options.runId,
423
+ eventKind: "runner_blocked",
424
+ stopClass: "continue",
425
+ recommendedAction: "admit_wave",
426
+ sourceRef: deferred.blockerRef,
427
+ summary: "deferred_local_wave_blocker",
428
+ recordedAt,
429
+ waveId: deferred.wave.wave_id,
430
+ artifactRefs: {
431
+ decision_ref: decisionRef,
432
+ evidence_ref: deferred.blockerRef,
433
+ },
434
+ });
435
+ if (!deferredEvent.ok) {
436
+ return deferredEvent;
437
+ }
438
+ return {
439
+ ok: true,
440
+ topicId: decisionReport.topicId,
441
+ topicRef: decisionReport.topicRef,
442
+ runId: options.runId,
443
+ adapter: options.adapter,
444
+ runnerStatus: "continued",
445
+ executed: true,
446
+ stopClass: "continue",
447
+ recommendedAction: "defer_local_wave_blocker",
448
+ decision: decisionReport.decision,
449
+ gate: buildGate(decisionReport.decision),
450
+ decisionRef,
451
+ deferredBlocker: {
452
+ waveId: deferred.wave.wave_id,
453
+ reasonCode: decisionReport.decision.reason_code,
454
+ blockerRef: deferred.blockerRef,
455
+ nextWaveId: deferred.nextWave.wave_id,
456
+ },
457
+ ledgerRef: deferredEvent.ledgerRef,
458
+ eventCount: deferredEvent.eventCount,
459
+ };
460
+ }
461
+ }
510
462
  return {
511
463
  ok: true,
512
464
  topicId: decisionReport.topicId,
@@ -549,7 +501,7 @@ export async function runTopicRunnerStep(projectRoot, options, deps = {}) {
549
501
  }, parsedCommand.error);
550
502
  }
551
503
 
552
- if (["admit_wave", "freeze_packet", "closeout_wave"].includes(parsedCommand.action)) {
504
+ if (["admit_wave", "freeze_packet", "closeout_wave", "record_result"].includes(parsedCommand.action)) {
553
505
  const commandExecution = await executeMechanicalCommand(projectRoot, { ...options, recordedAt }, parsedCommand);
554
506
  if (!commandExecution.ok) {
555
507
  const blockedEvent = await recordRunnerBlocked(projectRoot, { ...options, verifiedAt: recordedAt }, {
@@ -595,7 +547,7 @@ export async function runTopicRunnerStep(projectRoot, options, deps = {}) {
595
547
  recommendedAction: parsedCommand.action,
596
548
  sourceRef: commandExecution.eventSourceRef,
597
549
  summary: commandExecution.summary,
598
- recordedAt,
550
+ recordedAt: commandExecution.recordedAt ?? recordedAt,
599
551
  waveId: commandExecution.waveId,
600
552
  artifactRefs,
601
553
  });
@@ -728,7 +680,10 @@ export async function runTopicRunner(projectRoot, options, deps = {}) {
728
680
  : 20;
729
681
  const steps = [];
730
682
  for (let index = 0; index < maxSteps; index += 1) {
731
- const step = await runTopicRunnerStep(projectRoot, options, deps);
683
+ const step = await runTopicRunnerStep(projectRoot, {
684
+ ...options,
685
+ allowDeferredLocalBlockers: true,
686
+ }, deps);
732
687
  steps.push(step);
733
688
  if (!step.ok || step.runnerStatus !== "continued") {
734
689
  return {