@nimiplatform/nimi-coding 0.1.0 → 0.2.1

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 (126) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/CODE_OF_CONDUCT.md +28 -0
  3. package/CONTRIBUTING.md +45 -0
  4. package/README.md +371 -344
  5. package/README.zh-CN.md +307 -0
  6. package/SECURITY.md +26 -0
  7. package/adapters/oh-my-codex/README.md +8 -9
  8. package/cli/commands/audit-sweep.mjs +10 -10
  9. package/cli/commands/classify-spec-tree.mjs +5 -0
  10. package/cli/commands/closeout.mjs +3 -0
  11. package/cli/commands/generate-spec-derived-docs.mjs +20 -0
  12. package/cli/commands/generate-spec-migration-plan.mjs +30 -0
  13. package/cli/commands/start.mjs +5 -1
  14. package/cli/commands/surface-validator-command.mjs +49 -0
  15. package/cli/commands/sweep-design.mjs +295 -0
  16. package/cli/commands/sweep.mjs +22 -0
  17. package/cli/commands/sync.mjs +132 -0
  18. package/cli/commands/topic-formatters.mjs +8 -8
  19. package/cli/commands/validate-ai-governance.mjs +167 -46
  20. package/cli/commands/validate-domain-admission.mjs +5 -0
  21. package/cli/commands/validate-guidance-bodies.mjs +5 -0
  22. package/cli/commands/validate-placement.mjs +5 -0
  23. package/cli/commands/validate-projection-edges.mjs +5 -0
  24. package/cli/commands/validate-spec-audit.mjs +5 -1
  25. package/cli/commands/validate-table-family.mjs +5 -0
  26. package/cli/commands/validate-tracked-output-admission.mjs +5 -0
  27. package/cli/constants.mjs +5 -49
  28. package/cli/help.mjs +33 -11
  29. package/cli/index.mjs +20 -2
  30. package/cli/lib/audit-sweep-runtime/admissions.mjs +38 -29
  31. package/cli/lib/audit-sweep-runtime/audit-validity.mjs +8 -0
  32. package/cli/lib/audit-sweep-runtime/chunks.mjs +11 -11
  33. package/cli/lib/audit-sweep-runtime/closeout.mjs +8 -8
  34. package/cli/lib/audit-sweep-runtime/codex-auditor-evidence.mjs +3 -3
  35. package/cli/lib/audit-sweep-runtime/codex-auditor.mjs +10 -10
  36. package/cli/lib/audit-sweep-runtime/common.mjs +7 -7
  37. package/cli/lib/audit-sweep-runtime/format.mjs +3 -3
  38. package/cli/lib/audit-sweep-runtime/ingest.mjs +8 -8
  39. package/cli/lib/audit-sweep-runtime/inventory-spec-chunks.mjs +24 -27
  40. package/cli/lib/audit-sweep-runtime/inventory.mjs +58 -18
  41. package/cli/lib/audit-sweep-runtime/ledger.mjs +1 -1
  42. package/cli/lib/audit-sweep-runtime/p0p1-profile.mjs +2 -2
  43. package/cli/lib/audit-sweep-runtime/remediation.mjs +6 -6
  44. package/cli/lib/audit-sweep-runtime/rerun.mjs +6 -6
  45. package/cli/lib/audit-sweep-runtime/status.mjs +1 -1
  46. package/cli/lib/audit-sweep-runtime/validators.mjs +2 -2
  47. package/cli/lib/authority-convergence.mjs +397 -2
  48. package/cli/lib/blueprint-audit.mjs +5 -5
  49. package/cli/lib/closeout.mjs +126 -3
  50. package/cli/lib/contracts.mjs +21 -17
  51. package/cli/lib/handoff.mjs +29 -11
  52. package/cli/lib/high-risk-admission.mjs +60 -11
  53. package/cli/lib/high-risk-decision.mjs +31 -2
  54. package/cli/lib/high-risk-ingest.mjs +5 -1
  55. package/cli/lib/high-risk-review.mjs +5 -1
  56. package/cli/lib/internal/contracts-parse.mjs +195 -24
  57. package/cli/lib/internal/contracts-validators.mjs +3 -2
  58. package/cli/lib/internal/doctor-bootstrap-surface.mjs +82 -35
  59. package/cli/lib/internal/doctor-delegated-surface.mjs +1 -1
  60. package/cli/lib/internal/doctor-finalize.mjs +12 -8
  61. package/cli/lib/internal/doctor-inspectors.mjs +34 -1
  62. package/cli/lib/internal/governance/ai/ai-context-budget-core.mjs +74 -12
  63. package/cli/lib/internal/governance/ai/ai-structure-budget-core.mjs +24 -6
  64. package/cli/lib/internal/governance/ai/check-agents-freshness.mjs +18 -23
  65. package/cli/lib/internal/surface-taxonomy-validators.mjs +931 -0
  66. package/cli/lib/internal/validators-spec.mjs +229 -20
  67. package/cli/lib/sweep-design-runtime/common.mjs +246 -0
  68. package/cli/lib/sweep-design-runtime/engine.mjs +733 -0
  69. package/cli/lib/sweep-design-runtime/fix-topic.mjs +414 -0
  70. package/cli/lib/sweep-design-runtime/lifecycle.mjs +54 -0
  71. package/cli/lib/sweep-design-runtime/results.mjs +324 -0
  72. package/cli/lib/sweep-design.mjs +8 -0
  73. package/cli/lib/sync.mjs +143 -0
  74. package/cli/lib/topic-artifacts.mjs +186 -0
  75. package/cli/lib/topic-authority-coverage.mjs +73 -0
  76. package/cli/lib/topic-closeout.mjs +560 -0
  77. package/cli/lib/topic-common.mjs +404 -0
  78. package/cli/lib/topic-decisions.mjs +332 -0
  79. package/cli/lib/topic-draft-packets.mjs +126 -7
  80. package/cli/lib/topic-execution.mjs +515 -0
  81. package/cli/lib/topic-goal.mjs +112 -33
  82. package/cli/lib/topic-ledger.mjs +281 -0
  83. package/cli/lib/topic-lifecycle-artifacts.mjs +173 -0
  84. package/cli/lib/topic-root-validation.mjs +288 -0
  85. package/cli/lib/topic-runner-commands.mjs +174 -0
  86. package/cli/lib/topic-runner-deferral.mjs +532 -0
  87. package/cli/lib/topic-runner-stale-gates.mjs +114 -0
  88. package/cli/lib/topic-runner-validation.mjs +138 -0
  89. package/cli/lib/topic-runner.mjs +109 -154
  90. package/cli/lib/topic-scaffold.mjs +252 -0
  91. package/cli/lib/topic-waves.mjs +403 -0
  92. package/cli/lib/topic.mjs +81 -93
  93. package/cli/lib/value-helpers.mjs +6 -1
  94. package/cli/seeds/bootstrap.mjs +96 -20
  95. package/cli/seeds/seed-policy.yaml +67 -0
  96. package/config/bootstrap.yaml +1 -1
  97. package/config/skill-manifest.yaml +4 -2
  98. package/config/spec-generation-inputs.yaml +41 -19
  99. package/contracts/audit-remediation-map.schema.yaml +1 -0
  100. package/contracts/audit-sweep-result.yaml +4 -0
  101. package/contracts/domain-admission.schema.yaml +56 -0
  102. package/contracts/migration-inventory.schema.yaml +80 -0
  103. package/contracts/negative-fixtures.yaml +91 -0
  104. package/contracts/placement-contract.schema.yaml +163 -0
  105. package/contracts/projection-edge.schema.yaml +130 -0
  106. package/contracts/shared-enums.yaml +68 -0
  107. package/contracts/spec-generation-audit.schema.yaml +19 -4
  108. package/contracts/spec-generation-inputs.schema.yaml +130 -29
  109. package/contracts/spec-reconstruction-result.yaml +9 -5
  110. package/contracts/surface-taxonomy.schema.yaml +201 -0
  111. package/contracts/sweep-design-result.yaml +349 -0
  112. package/contracts/table-family.schema.yaml +121 -0
  113. package/contracts/topic-goal.schema.yaml +10 -1
  114. package/contracts/tracked-output-admission.schema.yaml +70 -0
  115. package/contracts/workflow-consumer.schema.yaml +112 -0
  116. package/methodology/audit-sweep-p0p1-recall.yaml +1 -1
  117. package/methodology/spec-reconstruction.yaml +53 -30
  118. package/package.json +19 -4
  119. package/spec/_meta/command-gating-matrix.yaml +33 -0
  120. package/spec/_meta/generate-drift-migration-checklist.yaml +44 -62
  121. package/spec/_meta/governance-routing-cutover-checklist.yaml +3 -3
  122. package/spec/_meta/phase2-impacted-surface-matrix.yaml +14 -14
  123. package/spec/_meta/spec-authority-cutover-readiness.yaml +3 -5
  124. package/spec/_meta/spec-tree-model.yaml +104 -36
  125. package/spec/bootstrap-state.yaml +36 -36
  126. package/spec/product-scope.yaml +13 -10
@@ -9,9 +9,10 @@ import { parseYamlText } from "./yaml-helpers.mjs";
9
9
  import { loadGovernanceConfig } from "./internal/governance/config.mjs";
10
10
 
11
11
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
12
- const TOPIC_GOAL_CONTRACT_REF = "nimi-coding/contracts/topic-goal.schema.yaml";
12
+ const PACKAGE_REF_PREFIX = "package://@nimiplatform/nimi-coding/";
13
+ const TOPIC_GOAL_CONTRACT_REF = `${PACKAGE_REF_PREFIX}contracts/topic-goal.schema.yaml`;
13
14
  const HOST_TOPIC_GOAL_CONTRACT_REF = ".nimi/contracts/topic-goal.schema.yaml";
14
- const FORBIDDEN_SHORTCUTS_REF = "nimi-coding/contracts/forbidden-shortcuts.catalog.yaml";
15
+ const FORBIDDEN_SHORTCUTS_REF = `${PACKAGE_REF_PREFIX}contracts/forbidden-shortcuts.catalog.yaml`;
15
16
  const HOST_FORBIDDEN_SHORTCUTS_REF = ".nimi/contracts/forbidden-shortcuts.catalog.yaml";
16
17
  const GOAL_COMMAND_MAX_CHARS = 1500;
17
18
 
@@ -41,7 +42,8 @@ const REQUIRED_STOP_KEYS = [
41
42
  "silent_owner_cut_reopen",
42
43
  ];
43
44
 
44
- const EXECUTABLE_WAVE_STATES = new Set(["implementation_admitted", "implementation_active"]);
45
+ const EXECUTION_STAGE_WAVE_STATES = new Set(["preflight_admitted", "implementation_admitted", "implementation_active"]);
46
+ const ADMISSION_READY_WAVE_STATES = new Set(["candidate", "preflight_draft", "needs_revision"]);
45
47
  const TERMINAL_WAVE_STATES = new Set(["closed", "retired", "superseded"]);
46
48
  const WAVE_ID_PATTERN = /^wave-[a-z0-9]+(?:-[a-z0-9]+)*$/;
47
49
 
@@ -83,7 +85,7 @@ function projectRef(projectRoot, absolutePath) {
83
85
  }
84
86
 
85
87
  function packagePath(relativePath) {
86
- return path.join(PACKAGE_ROOT, relativePath.replace(/^nimi-coding\//, ""));
88
+ return path.join(PACKAGE_ROOT, relativePath.replace(PACKAGE_REF_PREFIX, ""));
87
89
  }
88
90
 
89
91
  function check(id, ok, message, extra = {}) {
@@ -112,12 +114,58 @@ function hasUnresolvedPlaceholder(text) {
112
114
  || /\?\?\?/.test(text);
113
115
  }
114
116
 
117
+ function normalizeCommandText(command) {
118
+ return String(command ?? "").replace(/\s+/g, " ").trim();
119
+ }
120
+
121
+ function validationCommandScope(command) {
122
+ const scopeMatch = command.match(/(?:^|\s)--scope\s+([^\s]+)/u);
123
+ if (scopeMatch) {
124
+ return scopeMatch[1];
125
+ }
126
+ if (command.includes("topic validate graph")) {
127
+ return "graph";
128
+ }
129
+ if (command.includes("topic validate")) {
130
+ return "topic";
131
+ }
132
+ if (command.includes("test")) {
133
+ return "test";
134
+ }
135
+ return "selected wave";
136
+ }
137
+
138
+ function validationCommandEntry(command) {
139
+ const normalized = normalizeCommandText(command);
140
+ return {
141
+ command: normalized,
142
+ cwd: ".",
143
+ profile: null,
144
+ scope: validationCommandScope(normalized),
145
+ required: true,
146
+ expected_exit_code: 0,
147
+ };
148
+ }
149
+
150
+ function mergeValidationCommandEntries(entries) {
151
+ const seen = new Set();
152
+ const output = [];
153
+ for (const entry of entries) {
154
+ if (!entry.command || seen.has(entry.command)) {
155
+ continue;
156
+ }
157
+ seen.add(entry.command);
158
+ output.push(entry);
159
+ }
160
+ return output;
161
+ }
162
+
115
163
  function parseValidationCommands(artifactTexts) {
116
164
  const commands = [];
117
165
  const commandPattern = /\b(?:pnpm|npm|npx|node|go|cargo)\s+[^\n`]+/g;
118
166
  for (const { text } of artifactTexts) {
119
167
  for (const match of text.matchAll(commandPattern)) {
120
- const command = match[0].replace(/[.)\]]+$/u, "").trim();
168
+ const command = normalizeCommandText(match[0].replace(/[.)\]]+$/u, ""));
121
169
  if (command.includes("<") || command.includes("topic goal")) {
122
170
  continue;
123
171
  }
@@ -126,20 +174,18 @@ function parseValidationCommands(artifactTexts) {
126
174
  }
127
175
  }
128
176
  }
129
- return commands.map((command) => ({
130
- command,
131
- cwd: ".",
132
- profile: null,
133
- scope: command.includes("topic validate graph")
134
- ? "graph"
135
- : command.includes("topic validate")
136
- ? "topic"
137
- : command.includes("test")
138
- ? "test"
139
- : "selected wave",
140
- required: true,
141
- expected_exit_code: 0,
142
- }));
177
+ return commands.map(validationCommandEntry);
178
+ }
179
+
180
+ function selectedWaveValidationCommands(selectedWave) {
181
+ const commands = [
182
+ ...(Array.isArray(selectedWave?.validation_commands) ? selectedWave.validation_commands : []),
183
+ ...(Array.isArray(selectedWave?.source_sweep_design?.validation_commands) ? selectedWave.source_sweep_design.validation_commands : []),
184
+ ];
185
+ return commands
186
+ .map(normalizeCommandText)
187
+ .filter((command) => command.length > 0)
188
+ .map(validationCommandEntry);
143
189
  }
144
190
 
145
191
  function parseHumanGates(preflightText) {
@@ -212,10 +258,23 @@ function selectedWaveResolution(topic) {
212
258
  const selectedTarget = typeof topic.selected_next_target === "string" ? topic.selected_next_target : null;
213
259
  const matchingWaves = selectedTarget === null ? [] : waves.filter((wave) => wave.wave_id === selectedTarget);
214
260
  const selectedWaves = waves.filter((wave) => wave.selected === true);
261
+ const terminalIds = new Set(waves.filter((wave) => TERMINAL_WAVE_STATES.has(wave.state)).map((wave) => wave.wave_id));
262
+ const allWavesTerminal = waves.length > 0 && waves.every((wave) => TERMINAL_WAVE_STATES.has(wave.state));
263
+ const deterministicNextWave = allWavesTerminal ? null : waves.find((wave) => {
264
+ if (TERMINAL_WAVE_STATES.has(wave.state)) return false;
265
+ if (!ADMISSION_READY_WAVE_STATES.has(wave.state)) return false;
266
+ const deps = Array.isArray(wave.deps) ? wave.deps : [];
267
+ return deps.every((dep) => terminalIds.has(dep));
268
+ }) ?? null;
269
+ const selectedWave = matchingWaves.length === 1 ? matchingWaves[0] : null;
270
+ const executionStartWave = selectedWave ?? deterministicNextWave;
215
271
  return {
216
272
  waves,
217
273
  selectedTarget,
218
- selectedWave: matchingWaves.length === 1 ? matchingWaves[0] : null,
274
+ selectedWave,
275
+ executionStartWave,
276
+ deterministicNextWave,
277
+ allWavesTerminal,
219
278
  matchingWaveCount: matchingWaves.length,
220
279
  selectedWaves,
221
280
  };
@@ -225,9 +284,12 @@ function dependencyEvidence(lineageRefs, depId) {
225
284
  return lineageRefs.some((ref) => fileReferencesWave(ref, depId) && /^(result|closeout)-/.test(ref));
226
285
  }
227
286
 
228
- function buildGoalCommand(topicId, waveId, sourceArtifacts) {
287
+ function buildGoalCommand(topicId, executionStartWave, sourceArtifacts) {
229
288
  const artifactList = sourceArtifacts.map((ref) => path.basename(ref)).join(", ");
230
- return `/goal Execute topic ${topicId} from selected admitted wave ${waveId}. Treat ${artifactList} as the execution contract. Implement only the admitted scope. Do not reinterpret scope, change authority ownership, lower readiness gates, mutate topic state during goal generation, invoke Codex automatically, delete evidence, skip admitted coverage, or emit fallback goals. After meaningful implementation steps, run topic validate, topic validate graph, and focused tests. Stop only for declared human gates, authority/scope changes, lowered gates, destructive evidence deletion, or blockers requiring contract changes. Complete by writing required result/closeout artifacts and reporting validation evidence, blockers, and residual risk.`;
289
+ const cursorClause = executionStartWave
290
+ ? `, starting at execution cursor ${executionStartWave.wave_id}`
291
+ : "";
292
+ return `/goal Execute topic ${topicId} to completion${cursorClause}. This is a topic-level goal: do not mark complete after a single wave closeout. Use nimicoding topic-runner run ${topicId} --run-id <run-id> --adapter codex to advance deterministic wave admission, preflight, implementation, validation, result recording, wave closeout, and next-wave selection. Treat ${artifactList} as the execution contract. Wave closeout is a validation boundary only. Complete only after all waves are terminal and topic true-close/closeout evidence is recorded. Stop only for declared human gates, authority/scope changes, lowered gates, destructive evidence deletion, unresolved blockers, or required contract changes.`;
231
293
  }
232
294
 
233
295
  async function checkHostProjection(projectRoot) {
@@ -276,6 +338,7 @@ export async function buildTopicGoal(projectRoot, options) {
276
338
  const lineageWaveIds = [
277
339
  ...new Set([
278
340
  resolution.selectedTarget,
341
+ resolution.executionStartWave?.wave_id,
279
342
  ...resolution.waves.flatMap((wave) => Array.isArray(wave.deps) ? wave.deps : []),
280
343
  ].filter(Boolean)),
281
344
  ];
@@ -287,16 +350,31 @@ export async function buildTopicGoal(projectRoot, options) {
287
350
  }
288
351
  const sourceArtifacts = [...REQUIRED_ARTIFACTS, ...lineageRefs];
289
352
  const sourceArtifactTexts = [...artifactTexts, ...lineageTexts];
290
- const validationCommands = parseValidationCommands(sourceArtifactTexts);
353
+ const validationCommands = mergeValidationCommandEntries([
354
+ ...selectedWaveValidationCommands(resolution.executionStartWave),
355
+ ...parseValidationCommands(sourceArtifactTexts.filter((artifact) => artifact.ref !== "topic.yaml")),
356
+ ]);
291
357
  const humanGates = parseHumanGates(textByRef.get("preflight.md") ?? "");
292
358
  const forbiddenCatalog = await loadForbiddenShortcutsCatalog(projectRoot);
293
359
  const hostProjection = await checkHostProjection(projectRoot);
294
- const selectedWave = resolution.selectedWave;
295
- const deps = Array.isArray(selectedWave?.deps) ? selectedWave.deps : [];
360
+ const executionStartWave = resolution.executionStartWave;
361
+ const deps = Array.isArray(executionStartWave?.deps) ? executionStartWave.deps : [];
296
362
  const depFailures = deps.filter((depId) => {
297
363
  const depWave = resolution.waves.find((wave) => wave.wave_id === depId);
298
364
  return !depWave || !TERMINAL_WAVE_STATES.has(depWave.state) || !dependencyEvidence(lineageRefs, depId);
299
365
  });
366
+ const selectedTargetReady = resolution.selectedTarget === null || resolution.selectedTarget === "topic_design_baseline"
367
+ ? resolution.deterministicNextWave !== null || resolution.allWavesTerminal
368
+ : resolution.matchingWaveCount === 1 && WAVE_ID_PATTERN.test(resolution.selectedTarget);
369
+ const selectedWaveSourceReady = resolution.selectedTarget === null || resolution.selectedTarget === "topic_design_baseline"
370
+ ? resolution.selectedWaves.length === 0
371
+ : resolution.selectedWaves.length === 1 && resolution.selectedWaves[0]?.wave_id === resolution.selectedTarget;
372
+ const executionStartWaveReady = executionStartWave
373
+ ? EXECUTION_STAGE_WAVE_STATES.has(executionStartWave.state) || ADMISSION_READY_WAVE_STATES.has(executionStartWave.state)
374
+ : resolution.allWavesTerminal;
375
+ const executionStartGoalPresent = executionStartWave
376
+ ? typeof executionStartWave.primary_closure_goal === "string" && executionStartWave.primary_closure_goal.trim().length > 0
377
+ : resolution.allWavesTerminal;
300
378
  const commands = validationCommands.map((entry) => entry.command);
301
379
 
302
380
  const checks = [
@@ -307,12 +385,12 @@ export async function buildTopicGoal(projectRoot, options) {
307
385
  check("strict_policy_active", topicReport.ignoredByPolicy !== true, topicReport.ignoredByPolicy ? "topic root is ignored by strict validation policy" : "topic root is under strict validation policy"),
308
386
  check("parallel_truth_forbidden", loaded.topic.parallel_truth === "forbidden", `parallel_truth is ${loaded.topic.parallel_truth ?? "missing"}`),
309
387
  check("profile_resolves", profile.ok, profile.ok ? `profile resolves as ${profile.profile}` : `unknown or mismatched profile ${profile.profile ?? "missing"}`),
310
- check("selected_target_wave_resolves", resolution.matchingWaveCount === 1 && WAVE_ID_PATTERN.test(resolution.selectedTarget ?? ""), `selected_next_target is ${resolution.selectedTarget ?? "missing"}`),
311
- check("selected_wave_single_source", resolution.selectedWaves.length === 1 && resolution.selectedWaves[0]?.wave_id === resolution.selectedTarget, `selected waves: ${resolution.selectedWaves.map((wave) => wave.wave_id).join(", ") || "none"}`),
312
- check("wave_option_matches_selected", options.wave === null || options.wave === resolution.selectedTarget, options.wave === null ? "no wave assertion provided" : `--wave is ${options.wave}`),
313
- check("selected_wave_executable", selectedWave ? EXECUTABLE_WAVE_STATES.has(selectedWave.state) : false, selectedWave ? `selected wave state is ${selectedWave.state}` : "selected wave does not resolve"),
388
+ check("selected_target_wave_resolves", selectedTargetReady, resolution.selectedTarget ? `selected_next_target is ${resolution.selectedTarget}` : `execution cursor is ${executionStartWave?.wave_id ?? (resolution.allWavesTerminal ? "topic closeout" : "missing")}`),
389
+ check("selected_wave_single_source", selectedWaveSourceReady, `selected waves: ${resolution.selectedWaves.map((wave) => wave.wave_id).join(", ") || "none"}`),
390
+ check("wave_option_matches_selected", options.wave === null || options.wave === executionStartWave?.wave_id, options.wave === null ? "no wave assertion provided" : `--wave is ${options.wave}`),
391
+ check("selected_wave_executable", executionStartWaveReady, executionStartWave ? `execution cursor wave state is ${executionStartWave.state}` : resolution.allWavesTerminal ? "topic is ready for topic closeout" : "execution cursor does not resolve"),
314
392
  check("selected_wave_dependencies_terminal", depFailures.length === 0, depFailures.length === 0 ? "selected wave dependencies are terminal by lifecycle evidence" : `dependencies are not terminal by evidence: ${depFailures.join(", ")}`),
315
- check("selected_wave_goal_present", typeof selectedWave?.primary_closure_goal === "string" && selectedWave.primary_closure_goal.trim().length > 0, selectedWave?.primary_closure_goal ? "selected wave declares primary_closure_goal" : "selected wave is missing primary_closure_goal"),
393
+ check("selected_wave_goal_present", executionStartGoalPresent, executionStartWave?.primary_closure_goal ? "execution cursor declares primary_closure_goal" : resolution.allWavesTerminal ? "all waves terminal; topic closeout is the next goal" : "execution cursor is missing primary_closure_goal"),
316
394
  check("forbidden_shortcuts_present", REQUIRED_STOP_KEYS.every((key) => (loaded.topic.forbidden_shortcuts ?? []).includes(key)) && REQUIRED_STOP_KEYS.every((key) => forbiddenCatalog.keys.includes(key)), "topic forbidden_shortcuts include required package catalog keys"),
317
395
  check("forbidden_shortcuts_catalog_aligned", forbiddenCatalog.aligned, forbiddenCatalog.aligned ? "host forbidden-shortcuts projection is aligned or absent" : "host forbidden-shortcuts projection differs from package catalog"),
318
396
  check("required_artifacts_present", missing.length === 0, missing.length === 0 ? "all required artifacts are present" : `missing artifacts: ${missing.join(", ")}`),
@@ -328,11 +406,11 @@ export async function buildTopicGoal(projectRoot, options) {
328
406
  ];
329
407
 
330
408
  const preliminaryOk = checks.every((entry) => entry.status === "pass");
331
- const preliminaryGoal = preliminaryOk && selectedWave ? buildGoalCommand(loaded.topicId, selectedWave.wave_id, sourceArtifacts) : null;
409
+ const preliminaryGoal = preliminaryOk ? buildGoalCommand(loaded.topicId, executionStartWave, sourceArtifacts) : null;
332
410
  checks.push(check("goal_size_within_limit", preliminaryGoal === null || preliminaryGoal.length <= GOAL_COMMAND_MAX_CHARS, preliminaryGoal === null ? "goal size check skipped until readiness passes" : `goal command length is ${preliminaryGoal.length}`));
333
411
 
334
412
  const readinessOk = checks.every((entry) => entry.status === "pass");
335
- const goalCommand = readinessOk && selectedWave ? preliminaryGoal : null;
413
+ const goalCommand = readinessOk ? preliminaryGoal : null;
336
414
  return {
337
415
  ok: readinessOk,
338
416
  topic_id: loaded.topicId,
@@ -341,7 +419,8 @@ export async function buildTopicGoal(projectRoot, options) {
341
419
  true_close_status: loaded.topic.current_true_close_status ?? null,
342
420
  profile: profile.profile ?? null,
343
421
  selected_next_target: loaded.topic.selected_next_target ?? null,
344
- selected_wave_id: selectedWave?.wave_id ?? null,
422
+ selected_wave_id: executionStartWave?.wave_id ?? null,
423
+ execution_start_wave_id: executionStartWave?.wave_id ?? null,
345
424
  topic_state_hash: buildStateHash(sourceArtifactTexts, [
346
425
  { ref: TOPIC_GOAL_CONTRACT_REF, text: await readTextIfFile(packagePath(TOPIC_GOAL_CONTRACT_REF)) ?? "" },
347
426
  ]),
@@ -0,0 +1,281 @@
1
+ import { readdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import YAML from "yaml";
4
+
5
+ import { pathExists, readTextIfFile } from "./fs-helpers.mjs";
6
+ import { parseYamlText } from "./yaml-helpers.mjs";
7
+ import { loadTopicRuntimeAuthority, toPortableRelativePath } from "./topic-common.mjs";
8
+ import { isIsoUtcTimestamp, loadTopicReport } from "./topic-scaffold.mjs";
9
+ import { readFrontmatterObject } from "./topic-artifacts.mjs";
10
+
11
+ const RUN_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
12
+
13
+ export function validateRunId(value) {
14
+ return typeof value == "string" && RUN_ID_PATTERN.test(value);
15
+ }
16
+ export function runLedgerFilename(runId) {
17
+ return `run-ledger-${runId}.yaml`;
18
+ }
19
+ export function runEventFilename(runId, eventIndex, eventKind) {
20
+ return `run-event-${runId}-${String(eventIndex).padStart(4, "0")}-${eventKind}.yaml`;
21
+ }
22
+ export function stopClassToRunStatus(stopClass) {
23
+ return stopClass === "continue"
24
+ ? "running"
25
+ : stopClass === "require_human_confirmation"
26
+ ? "awaiting_human_confirmation"
27
+ : stopClass === "await_external_evidence"
28
+ ? "awaiting_external_evidence"
29
+ : stopClass === "blocked"
30
+ ? "blocked"
31
+ : stopClass === "completed"
32
+ ? "completed"
33
+ : "blocked";
34
+ }
35
+ export function retryPostureForEvent(event) {
36
+ return event.stop_class === "require_human_confirmation"
37
+ ? "retry_forbidden_until_human_gate"
38
+ : event.stop_class === "blocked"
39
+ ? "retry_requires_new_packet"
40
+ : event.stop_class === "await_external_evidence"
41
+ ? "retry_allowed_same_command"
42
+ : "not_applicable";
43
+ }
44
+ export function normalizeArtifactRefs(input) {
45
+ const refs = {};
46
+ for (const [key, value] of Object.entries(input ?? {}))
47
+ typeof key == "string" &&
48
+ key.length > 0 &&
49
+ typeof value == "string" &&
50
+ value.length > 0 &&
51
+ (refs[key] = value);
52
+ return refs;
53
+ }
54
+ export async function validatePortableRefExists(projectRoot, ref, label) {
55
+ return path.isAbsolute(ref)
56
+ ? `${label} must be project-relative: ${ref}`
57
+ : (await pathExists(path.join(projectRoot, ref)))?.isFile()
58
+ ? null
59
+ : `${label} does not resolve to a file: ${ref}`;
60
+ }
61
+ export async function loadTopicRunEvents(topicDir, runId) {
62
+ const entries = await readdir(topicDir, { withFileTypes: true }),
63
+ events = [];
64
+ for (const entry of entries) {
65
+ if (
66
+ !entry.isFile() ||
67
+ !entry.name.startsWith(`run-event-${runId}-`) ||
68
+ !entry.name.endsWith(".yaml")
69
+ )
70
+ continue;
71
+ const eventPath = path.join(topicDir, entry.name),
72
+ eventText = await readTextIfFile(eventPath),
73
+ event = parseYamlText(eventText ?? "");
74
+ !event ||
75
+ typeof event != "object" ||
76
+ event.run_id !== runId ||
77
+ events.push({ event, eventRef: entry.name, eventPath });
78
+ }
79
+ return events.sort((left, right) => {
80
+ const leftIndex = Number(left.event.event_index ?? 0),
81
+ rightIndex = Number(right.event.event_index ?? 0);
82
+ return leftIndex - rightIndex || left.eventRef.localeCompare(right.eventRef);
83
+ });
84
+ }
85
+ export function latestArtifactRef(events, key) {
86
+ for (const entry of [...events].reverse()) {
87
+ const value = entry.event.artifact_refs?.[key];
88
+ if (typeof value == "string" && value.length > 0) return value;
89
+ }
90
+ return null;
91
+ }
92
+ export function buildCurrentHumanGate(events) {
93
+ const gateClosingEvents = new Set(["human_gate_resolved", "wave_closed", "topic_closed"]);
94
+ for (const entry of [...events].reverse()) {
95
+ const event = entry.event;
96
+ if (gateClosingEvents.has(event.event_kind)) return null;
97
+ if (
98
+ event.stop_class === "require_human_confirmation" ||
99
+ event.event_kind === "human_gate_opened"
100
+ )
101
+ return {
102
+ event_ref: entry.eventRef,
103
+ wave_id: event.wave_id ?? null,
104
+ recommended_action: event.recommended_action,
105
+ summary: event.summary,
106
+ source_ref: event.source_ref,
107
+ };
108
+ }
109
+ return null;
110
+ }
111
+ export function buildTopicRunLedgerProjection(topic, runId, events, updatedAt) {
112
+ const latest = events.at(-1) ?? null,
113
+ latestEvent = latest?.event ?? null;
114
+ return {
115
+ ledger_id: `${topic.topic_id}:${runId}`,
116
+ topic_id: topic.topic_id,
117
+ run_id: runId,
118
+ kind: "topic-run-ledger",
119
+ run_status: latestEvent ? stopClassToRunStatus(latestEvent.stop_class) : "running",
120
+ event_count: events.length,
121
+ event_refs: events.map((entry) => entry.eventRef),
122
+ latest_event_ref: latest?.eventRef ?? null,
123
+ current_wave_id: latestEvent?.wave_id ?? topic.selected_next_target ?? null,
124
+ latest_decision_ref: latestArtifactRef(events, "decision_ref"),
125
+ latest_packet_ref: latestArtifactRef(events, "packet_ref"),
126
+ latest_prompt_ref: latestArtifactRef(events, "prompt_ref"),
127
+ latest_result_ref: latestArtifactRef(events, "result_ref"),
128
+ latest_closeout_ref: latestArtifactRef(events, "closeout_ref"),
129
+ current_human_gate: buildCurrentHumanGate(events),
130
+ retry_posture: latestEvent ? retryPostureForEvent(latestEvent) : "not_applicable",
131
+ updated_at: updatedAt,
132
+ };
133
+ }
134
+ export async function writeTopicRunLedger(projectRoot, loaded, runId, events, updatedAt) {
135
+ const ledger = buildTopicRunLedgerProjection(loaded.topic, runId, events, updatedAt),
136
+ ledgerPath = path.join(loaded.topicDir, runLedgerFilename(runId));
137
+ return (
138
+ await writeFile(ledgerPath, YAML.stringify(ledger), "utf8"),
139
+ {
140
+ ok: true,
141
+ topicId: loaded.topicId,
142
+ topicRef: toPortableRelativePath(path.relative(projectRoot, loaded.topicDir)),
143
+ runId,
144
+ ledgerRef: toPortableRelativePath(path.relative(projectRoot, ledgerPath)),
145
+ ledger,
146
+ }
147
+ );
148
+ }
149
+ export async function initTopicRunLedger(projectRoot, input, runId, startedAt = new Date().toISOString()) {
150
+ if (!validateRunId(runId))
151
+ return { ok: false, error: `Topic run ledger refused: invalid run id ${runId}` };
152
+ const loaded = await loadTopicReport(projectRoot, input);
153
+ if (!loaded.ok) return loaded;
154
+ const ledgerPath = path.join(loaded.topicDir, runLedgerFilename(runId));
155
+ if ((await readTextIfFile(ledgerPath)) !== null)
156
+ return { ok: false, error: `Topic run ledger already exists: ${runId}` };
157
+ const report = await writeTopicRunLedger(projectRoot, loaded, runId, [], startedAt);
158
+ return { ...report, runStatus: report.ledger.run_status, eventCount: report.ledger.event_count };
159
+ }
160
+ export async function recordTopicRunEvent(projectRoot, input, options) {
161
+ if (!validateRunId(options.runId))
162
+ return { ok: false, error: `Topic run event refused: invalid run id ${options.runId}` };
163
+ const loaded = await loadTopicReport(projectRoot, input);
164
+ if (!loaded.ok) return loaded;
165
+ const authority = await loadTopicRuntimeAuthority(projectRoot);
166
+ if (!authority.topicRunLedger.eventKinds.includes(options.eventKind))
167
+ return {
168
+ ok: false,
169
+ error: `Topic run event refused: unsupported event kind ${options.eventKind}`,
170
+ };
171
+ if (!authority.topicStepDecision.stopClasses.includes(options.stopClass))
172
+ return {
173
+ ok: false,
174
+ error: `Topic run event refused: unsupported stop class ${options.stopClass}`,
175
+ };
176
+ if (!authority.topicStepDecision.recommendedActions.includes(options.recommendedAction))
177
+ return {
178
+ ok: false,
179
+ error: `Topic run event refused: unsupported recommended action ${options.recommendedAction}`,
180
+ };
181
+ if (!isIsoUtcTimestamp(options.recordedAt))
182
+ return {
183
+ ok: false,
184
+ error: `Topic run event refused: --verified-at must be an ISO-8601 UTC timestamp: ${options.recordedAt}`,
185
+ };
186
+ if (!options.sourceRef || !options.summary)
187
+ return { ok: false, error: "Topic run event refused: --source and --summary are required" };
188
+ const sourceError = await validatePortableRefExists(projectRoot, options.sourceRef, "source_ref");
189
+ if (sourceError) return { ok: false, error: `Topic run event refused: ${sourceError}` };
190
+ const ledgerPath = path.join(loaded.topicDir, runLedgerFilename(options.runId));
191
+ if ((await readTextIfFile(ledgerPath)) === null)
192
+ return {
193
+ ok: false,
194
+ error: `Topic run event refused: run ledger does not exist: ${options.runId}`,
195
+ };
196
+ const artifactRefs = normalizeArtifactRefs(options.artifactRefs),
197
+ invalidArtifactKeys = Object.keys(artifactRefs).filter(
198
+ (key) => !authority.topicRunLedger.artifactRefKeys.includes(key),
199
+ );
200
+ if (invalidArtifactKeys.length > 0)
201
+ return {
202
+ ok: false,
203
+ error: `Topic run event refused: unsupported artifact ref keys: ${invalidArtifactKeys.join(", ")}`,
204
+ };
205
+ for (const [key, ref] of Object.entries(artifactRefs)) {
206
+ const artifactError = await validatePortableRefExists(projectRoot, ref, key);
207
+ if (artifactError) return { ok: false, error: `Topic run event refused: ${artifactError}` };
208
+ }
209
+ const eventIndex = (await loadTopicRunEvents(loaded.topicDir, options.runId)).length + 1,
210
+ event = {
211
+ event_id: `${options.runId}:${String(eventIndex).padStart(4, "0")}:${options.eventKind}`,
212
+ topic_id: loaded.topicId,
213
+ run_id: options.runId,
214
+ event_index: eventIndex,
215
+ event_kind: options.eventKind,
216
+ stop_class: options.stopClass,
217
+ recommended_action: options.recommendedAction,
218
+ wave_id: options.waveId ?? loaded.topic.selected_next_target ?? null,
219
+ source_ref: options.sourceRef,
220
+ summary: options.summary,
221
+ recorded_at: options.recordedAt,
222
+ artifact_refs: artifactRefs,
223
+ },
224
+ eventPath = path.join(
225
+ loaded.topicDir,
226
+ runEventFilename(options.runId, eventIndex, options.eventKind),
227
+ );
228
+ await writeFile(eventPath, YAML.stringify(event), "utf8");
229
+ const updatedEvents = await loadTopicRunEvents(loaded.topicDir, options.runId),
230
+ report = await writeTopicRunLedger(
231
+ projectRoot,
232
+ loaded,
233
+ options.runId,
234
+ updatedEvents,
235
+ options.recordedAt,
236
+ );
237
+ return {
238
+ ...report,
239
+ eventId: event.event_id,
240
+ eventRef: toPortableRelativePath(path.relative(projectRoot, eventPath)),
241
+ runStatus: report.ledger.run_status,
242
+ eventCount: report.ledger.event_count,
243
+ };
244
+ }
245
+ export async function buildTopicRunLedger(
246
+ projectRoot,
247
+ input,
248
+ runId,
249
+ updatedAt = new Date().toISOString(),
250
+ ) {
251
+ if (!validateRunId(runId))
252
+ return { ok: false, error: `Topic run ledger refused: invalid run id ${runId}` };
253
+ const loaded = await loadTopicReport(projectRoot, input);
254
+ if (!loaded.ok) return loaded;
255
+ const ledgerPath = path.join(loaded.topicDir, runLedgerFilename(runId));
256
+ if ((await readTextIfFile(ledgerPath)) === null)
257
+ return { ok: false, error: `Topic run ledger not found: ${runId}` };
258
+ const events = await loadTopicRunEvents(loaded.topicDir, runId),
259
+ report = await writeTopicRunLedger(projectRoot, loaded, runId, events, updatedAt);
260
+ return { ...report, runStatus: report.ledger.run_status, eventCount: report.ledger.event_count };
261
+ }
262
+ export async function readTopicRunLedger(projectRoot, input, runId) {
263
+ if (!validateRunId(runId))
264
+ return { ok: false, error: `Topic run ledger refused: invalid run id ${runId}` };
265
+ const loaded = await loadTopicReport(projectRoot, input);
266
+ if (!loaded.ok) return loaded;
267
+ const ledgerPath = path.join(loaded.topicDir, runLedgerFilename(runId)),
268
+ ledgerText = await readTextIfFile(ledgerPath);
269
+ if (ledgerText === null) return { ok: false, error: `Topic run ledger not found: ${runId}` };
270
+ const ledger = parseYamlText(ledgerText);
271
+ return {
272
+ ok: true,
273
+ topicId: loaded.topicId,
274
+ topicRef: toPortableRelativePath(path.relative(projectRoot, loaded.topicDir)),
275
+ runId,
276
+ ledgerRef: toPortableRelativePath(path.relative(projectRoot, ledgerPath)),
277
+ ledger,
278
+ runStatus: ledger.run_status,
279
+ eventCount: ledger.event_count,
280
+ };
281
+ }