@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
@@ -0,0 +1,252 @@
1
+ import { mkdir, rename, 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 {
8
+ TOPIC_ID_PATTERN,
9
+ TOPIC_ROOT,
10
+ TOPIC_SLUG_PATTERN,
11
+ formatDate,
12
+ loadTopicRuntimeAuthority,
13
+ toPortableRelativePath,
14
+ } from "./topic-common.mjs";
15
+
16
+ export function titleFromSlug(slug) {
17
+ return slug
18
+ .split("-")
19
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
20
+ .join(" ");
21
+ }
22
+ export function deriveTopicId(slug, date = new Date()) {
23
+ return TOPIC_ID_PATTERN.test(slug) ? slug : `${formatDate(date)}-${slug}`;
24
+ }
25
+ export function getTopicRoot(projectRoot) {
26
+ return path.join(projectRoot, TOPIC_ROOT);
27
+ }
28
+ export function getTopicStateRoot(projectRoot, state) {
29
+ return path.join(getTopicRoot(projectRoot), state);
30
+ }
31
+ export function isTopicPathInput(value) {
32
+ return typeof value == "string" && (value.includes("/") || value.startsWith("."));
33
+ }
34
+ export function buildCreatePayload(options, authority) {
35
+ return {
36
+ topic_id: options.topicId,
37
+ state: "proposal",
38
+ created_at: options.today,
39
+ last_transition_at: options.today,
40
+ last_transition_reason: "topic_created_via_nimicoding_topic_create",
41
+ title: options.title,
42
+ mode: options.mode,
43
+ posture: options.posture,
44
+ design_policy: options.designPolicy,
45
+ parallel_truth: options.parallelTruth,
46
+ layering: options.layering,
47
+ risk: options.risk,
48
+ applicability: options.applicability,
49
+ entry_justification: options.justification,
50
+ execution_mode: options.executionMode,
51
+ selected_next_target: "topic_design_baseline",
52
+ current_true_close_status: "not_started",
53
+ forbidden_shortcuts: authority.defaultForbiddenShortcuts,
54
+ waves: [],
55
+ };
56
+ }
57
+ export function buildReadme(topic) {
58
+ return `# ${topic.title}
59
+ State: \`${topic.state}\`
60
+ This topic was created by \`nimicoding topic create\`.
61
+ ## Purpose
62
+ TODO: explain why this work needs topic-level governance rather than the ordinary non-topic path.
63
+ ## Entry Posture
64
+ - mode: \`${topic.mode}\`
65
+ - posture: \`${topic.posture}\`
66
+ - design policy: \`${topic.design_policy}\`
67
+ - applicability: \`${topic.applicability}\`
68
+ - execution mode: \`${topic.execution_mode}\`
69
+ ## Current Next Action
70
+ - selected_next_target: \`${topic.selected_next_target}\`
71
+ - TODO: freeze the first bounded wave target before admission
72
+ `;
73
+ }
74
+ export function buildDesign(topicId) {
75
+ return `# Design
76
+ Topic: \`${topicId}\`
77
+ This file is the index for split design companions.
78
+ - TODO: add subtopic design files as the topic grows
79
+ - TODO: keep this file as an index rather than collapsing the whole topic into one document
80
+ `;
81
+ }
82
+ export function buildSimpleCompanion(title, topicId, bullets) {
83
+ return `# ${title}
84
+ Topic: \`${topicId}\`
85
+ ${bullets.map((item) => `- ${item}`).join(`
86
+ `)}
87
+ `;
88
+ }
89
+ export async function writeTopicScaffold(topicDir, topic) {
90
+ const files = new Map([
91
+ ["topic.yaml", YAML.stringify(topic)],
92
+ ["README.md", buildReadme(topic)],
93
+ ["design.md", buildDesign(topic.topic_id)],
94
+ ["preflight.md", buildSimpleCompanion("Preflight", topic.topic_id, ["TODO", "TODO"])],
95
+ ["waves.md", buildSimpleCompanion("Waves", topic.topic_id, ["TODO", "TODO"])],
96
+ [
97
+ "candidate-wave-plan.md",
98
+ buildSimpleCompanion("Candidate Wave Plan", topic.topic_id, ["TODO", "TODO"]),
99
+ ],
100
+ ["closeout.md", buildSimpleCompanion("Closeout", topic.topic_id, ["TODO", "TODO"])],
101
+ [
102
+ "implementation-doctrine.md",
103
+ buildSimpleCompanion("Implementation Doctrine", topic.topic_id, ["TODO", "TODO"]),
104
+ ],
105
+ [
106
+ "admission-checklists.md",
107
+ buildSimpleCompanion("Admission Checklists", topic.topic_id, ["TODO", "TODO"]),
108
+ ],
109
+ [
110
+ "manager-session-protocol.md",
111
+ buildSimpleCompanion("Manager Session Protocol", topic.topic_id, ["TODO", "TODO"]),
112
+ ],
113
+ ["manager-prompts.md", buildSimpleCompanion("Manager Prompts", topic.topic_id, ["TODO"])],
114
+ ]);
115
+ await mkdir(topicDir, { recursive: false });
116
+ for (const [fileName, contents] of files.entries())
117
+ await writeFile(path.join(topicDir, fileName), contents, "utf8");
118
+ }
119
+ export function validateTopicSlug(value) {
120
+ return TOPIC_SLUG_PATTERN.test(value);
121
+ }
122
+ export function validateTopicId(value) {
123
+ return TOPIC_ID_PATTERN.test(value);
124
+ }
125
+ export async function findTopicDirectory(projectRoot, input = null) {
126
+ const authority = await loadTopicRuntimeAuthority(projectRoot),
127
+ topicStatePattern = authority.topicStates.join("|");
128
+ if (!input) {
129
+ const current = process.cwd(),
130
+ match = toPortableRelativePath(path.relative(projectRoot, current)).match(
131
+ new RegExp(
132
+ `^\\.nimi/topics/(${topicStatePattern})/(\\d{4}-\\d{2}-\\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*)`,
133
+ ),
134
+ );
135
+ return match
136
+ ? {
137
+ ok: true,
138
+ topicDir: path.join(projectRoot, ".nimi", "topics", match[1], match[2]),
139
+ topicId: match[2],
140
+ state: match[1],
141
+ }
142
+ : {
143
+ ok: false,
144
+ error:
145
+ "No topic id or topic path was provided, and the current working directory is not inside a topic root.",
146
+ };
147
+ }
148
+ if (isTopicPathInput(input)) {
149
+ const topicDir = path.resolve(projectRoot, input),
150
+ match = toPortableRelativePath(path.relative(projectRoot, topicDir)).match(
151
+ new RegExp(
152
+ `^\\.nimi/topics/(${topicStatePattern})/(\\d{4}-\\d{2}-\\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*)$`,
153
+ ),
154
+ );
155
+ return match
156
+ ? { ok: true, topicDir, topicId: match[2], state: match[1] }
157
+ : { ok: false, error: `Topic path must resolve to .nimi/topics/<state>/<topic-id>: ${input}` };
158
+ }
159
+ const matches = [];
160
+ for (const state of authority.topicStates) {
161
+ const candidate = path.join(getTopicStateRoot(projectRoot, state), input);
162
+ (await pathExists(candidate))?.isDirectory() &&
163
+ matches.push({ state, topicDir: candidate, topicId: input });
164
+ }
165
+ return matches.length === 1
166
+ ? { ok: true, ...matches[0] }
167
+ : matches.length > 1
168
+ ? {
169
+ ok: false,
170
+ error: `Topic id resolves to multiple lifecycle roots and must be disambiguated by path: ${input}`,
171
+ }
172
+ : { ok: false, error: `Topic not found under ${TOPIC_ROOT}: ${input}` };
173
+ }
174
+ export async function resolveTopicProjectRoot(startDir) {
175
+ let currentDir = path.resolve(startDir);
176
+ for (;;) {
177
+ if ((await pathExists(path.join(currentDir, ".nimi")))?.isDirectory()) return currentDir;
178
+ const parentDir = path.dirname(currentDir);
179
+ if (parentDir === currentDir) return path.resolve(startDir);
180
+ currentDir = parentDir;
181
+ }
182
+ }
183
+ export async function loadTopicReport(projectRoot, input = null) {
184
+ const resolved = await findTopicDirectory(projectRoot, input);
185
+ if (!resolved.ok) return resolved;
186
+ const topicYamlPath = path.join(resolved.topicDir, "topic.yaml"),
187
+ topicYamlText = await readTextIfFile(topicYamlPath);
188
+ if (topicYamlText === null)
189
+ return {
190
+ ok: false,
191
+ error: `Missing topic.yaml at ${toPortableRelativePath(path.relative(projectRoot, topicYamlPath))}`,
192
+ };
193
+ const topic = parseYamlText(topicYamlText);
194
+ return !topic || typeof topic != "object"
195
+ ? {
196
+ ok: false,
197
+ error: `topic.yaml is not valid YAML at ${toPortableRelativePath(path.relative(projectRoot, topicYamlPath))}`,
198
+ }
199
+ : { ok: true, ...resolved, topicYamlPath, topicYamlText, topic };
200
+ }
201
+ export function getTopicWaves(topic) {
202
+ return Array.isArray(topic.waves) ? topic.waves.map((entry) => ({ ...entry })) : [];
203
+ }
204
+ export function findDeterministicNextWave(topic) {
205
+ const waves = getTopicWaves(topic),
206
+ terminalIds = new Set(
207
+ waves
208
+ .filter((entry) => ["closed", "retired", "superseded"].includes(entry.state))
209
+ .map((entry) => entry.wave_id),
210
+ ),
211
+ ready = waves.filter(
212
+ (entry) =>
213
+ !["closed", "retired", "superseded"].includes(entry.state) &&
214
+ ["candidate", "preflight_draft"].includes(entry.state) &&
215
+ (Array.isArray(entry.deps) ? entry.deps : []).every((dep) => terminalIds.has(dep)),
216
+ );
217
+ return ready.length > 0 ? ready[0] : null;
218
+ }
219
+ export async function writeTopicYaml(topicYamlPath, topic) {
220
+ await writeFile(topicYamlPath, YAML.stringify(topic), "utf8");
221
+ }
222
+ export async function moveTopicDirectoryForState(projectRoot, currentDir, topicId, targetState) {
223
+ const targetDir = path.join(getTopicStateRoot(projectRoot, targetState), topicId);
224
+ return currentDir === targetDir
225
+ ? { topicDir: currentDir, topicYamlPath: path.join(currentDir, "topic.yaml") }
226
+ : (await mkdir(path.dirname(targetDir), { recursive: true }),
227
+ await rename(currentDir, targetDir),
228
+ { topicDir: targetDir, topicYamlPath: path.join(targetDir, "topic.yaml") });
229
+ }
230
+ export function topicHasEnrichedShape(topic, authority) {
231
+ return authority.enrichedRequiredFields.every((field) => {
232
+ const value = topic[field];
233
+ return field === "selected_next_target"
234
+ ? value === null ||
235
+ value === "topic_design_baseline" ||
236
+ (typeof value == "string" && value.length > 0)
237
+ : value != null && value !== "" && (!Array.isArray(value) || value.length > 0);
238
+ });
239
+ }
240
+ export function buildTopicNow() {
241
+ return formatDate(new Date());
242
+ }
243
+ export function isIsoUtcTimestamp(value) {
244
+ if (
245
+ typeof value !== "string" ||
246
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value)
247
+ )
248
+ return false;
249
+ const parsed = new Date(value),
250
+ canonicalValue = value.includes(".") ? value : value.replace("Z", ".000Z");
251
+ return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === canonicalValue;
252
+ }
@@ -0,0 +1,403 @@
1
+ import { readdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { loadAuthorityConvergencePolicy } from "./authority-convergence.mjs";
5
+ import { loadTopicRuntimeContracts } from "./contracts.mjs";
6
+ import { readTextIfFile } from "./fs-helpers.mjs";
7
+ import { fileReferencesWave } from "./topic-lifecycle-artifacts.mjs";
8
+ import {
9
+ DEFAULT_TOPIC_RUNTIME_AUTHORITY,
10
+ PENDING_ENTRY_BLOCKER_STATES,
11
+ WAVE_ID_PATTERN,
12
+ loadTopicRuntimeAuthority,
13
+ toPortableRelativePath,
14
+ } from "./topic-common.mjs";
15
+ import { pendingNoteFilename, readFrontmatterObject } from "./topic-artifacts.mjs";
16
+ import {
17
+ buildTopicNow,
18
+ findDeterministicNextWave,
19
+ getTopicWaves,
20
+ isIsoUtcTimestamp,
21
+ loadTopicReport,
22
+ moveTopicDirectoryForState,
23
+ topicHasEnrichedShape,
24
+ writeTopicYaml,
25
+ } from "./topic-scaffold.mjs";
26
+
27
+ export async function collectWaveArtifactEvidence(topicDir, waveId) {
28
+ const files = (await readdir(topicDir, { withFileTypes: true }))
29
+ .filter((entry) => entry.isFile())
30
+ .map((entry) => entry.name);
31
+ return {
32
+ packetRefs: files.filter(
33
+ (name) => name.startsWith("packet-") && fileReferencesWave(name, waveId),
34
+ ),
35
+ resultRefs: files.filter(
36
+ (name) => name.startsWith("result-") && fileReferencesWave(name, waveId),
37
+ ),
38
+ closeoutRefs: files.filter(
39
+ (name) => name.startsWith("closeout-") && fileReferencesWave(name, waveId),
40
+ ),
41
+ remediationRefs: files.filter(
42
+ (name) => name.includes("remediation") && fileReferencesWave(name, waveId),
43
+ ),
44
+ overflowRefs: files.filter(
45
+ (name) => name.includes("overflow-continuation") && fileReferencesWave(name, waveId),
46
+ ),
47
+ };
48
+ }
49
+ export async function loadPendingNote(topicDir) {
50
+ const notePath = path.join(topicDir, pendingNoteFilename()),
51
+ noteText = await readTextIfFile(notePath);
52
+ if (noteText === null)
53
+ return { ok: false, notePath, error: `Missing pending note artifact: ${pendingNoteFilename()}` };
54
+ const note = readFrontmatterObject(noteText);
55
+ return note
56
+ ? { ok: true, notePath, note }
57
+ : { ok: false, notePath, error: "Pending note artifact frontmatter is invalid" };
58
+ }
59
+ export function getPendingEntryBlockers(topic) {
60
+ return getTopicWaves(topic)
61
+ .filter((entry) => PENDING_ENTRY_BLOCKER_STATES.has(entry.state))
62
+ .map((entry) => `${entry.wave_id}:${entry.state}`);
63
+ }
64
+ export async function loadTopicValidationPolicy(projectRoot) {
65
+ const parsed = (await loadTopicRuntimeContracts(projectRoot)).validationPolicy.data,
66
+ entries = Array.isArray(parsed?.topic_validation_policy?.ignore_for_default_validate)
67
+ ? parsed.topic_validation_policy.ignore_for_default_validate
68
+ : [],
69
+ ignoredTopicIds = new Map();
70
+ for (const entry of entries)
71
+ entry &&
72
+ typeof entry.topic_id == "string" &&
73
+ entry.topic_id.length > 0 &&
74
+ ignoredTopicIds.set(entry.topic_id, {
75
+ reason: typeof entry.reason == "string" ? entry.reason : null,
76
+ posture: typeof entry.posture == "string" ? entry.posture : null,
77
+ });
78
+ const semantics = parsed?.topic_validation_policy?.ignored_topic_validate_semantics ?? {};
79
+ return {
80
+ ignoredTopicIds,
81
+ ignoredTopicValidateSemantics: {
82
+ status:
83
+ typeof semantics.status == "string"
84
+ ? semantics.status
85
+ : DEFAULT_TOPIC_RUNTIME_AUTHORITY.ignoredTopicValidateSemantics.status,
86
+ canonicalSuccess:
87
+ typeof semantics.canonical_success == "boolean"
88
+ ? semantics.canonical_success
89
+ : DEFAULT_TOPIC_RUNTIME_AUTHORITY.ignoredTopicValidateSemantics.canonicalSuccess,
90
+ },
91
+ };
92
+ }
93
+ export function validateWaveId(value) {
94
+ return WAVE_ID_PATTERN.test(value);
95
+ }
96
+ export function normalizeDeps(value) {
97
+ return Array.isArray(value) ? value.map((entry) => String(entry)) : [];
98
+ }
99
+ export function validateGraphFromTopic(topic) {
100
+ const waves = getTopicWaves(topic),
101
+ checks = [],
102
+ warnings = [],
103
+ waveIds = waves.map((entry) => entry.wave_id),
104
+ uniqueWaveIds = new Set(waveIds);
105
+ checks.push({
106
+ id: "wave_ids_unique",
107
+ ok: uniqueWaveIds.size === waveIds.length,
108
+ reason:
109
+ uniqueWaveIds.size === waveIds.length
110
+ ? "wave ids are unique"
111
+ : "duplicate wave ids exist in topic.yaml waves[]",
112
+ });
113
+ const invalidWaveIds = waveIds.filter((entry) => !validateWaveId(entry));
114
+ checks.push({
115
+ id: "wave_ids_valid",
116
+ ok: invalidWaveIds.length === 0,
117
+ reason:
118
+ invalidWaveIds.length === 0
119
+ ? "wave ids use the canonical wave-<n>-slug shape"
120
+ : `invalid wave ids: ${invalidWaveIds.join(", ")}`,
121
+ });
122
+ const missingDeps = [],
123
+ selectedWaveIds = [],
124
+ retiredSelected = [];
125
+ for (const wave of waves) {
126
+ const deps = normalizeDeps(wave.deps);
127
+ for (const dep of deps) uniqueWaveIds.has(dep) || missingDeps.push(`${wave.wave_id}->${dep}`);
128
+ (wave.selected === true && selectedWaveIds.push(wave.wave_id),
129
+ wave.selected === true &&
130
+ ["retired", "superseded"].includes(wave.state) &&
131
+ retiredSelected.push(wave.wave_id));
132
+ }
133
+ (checks.push({
134
+ id: "wave_dependencies_resolve",
135
+ ok: missingDeps.length === 0,
136
+ reason:
137
+ missingDeps.length === 0
138
+ ? "all wave dependencies resolve inside the topic"
139
+ : `missing dependency refs: ${missingDeps.join(", ")}`,
140
+ }),
141
+ checks.push({
142
+ id: "selected_wave_unique",
143
+ ok: selectedWaveIds.length <= 1,
144
+ reason:
145
+ selectedWaveIds.length <= 1
146
+ ? "selected wave is unique"
147
+ : `multiple selected waves exist: ${selectedWaveIds.join(", ")}`,
148
+ }));
149
+ const selectedMatchesTopicTarget =
150
+ selectedWaveIds.length === 0
151
+ ? topic.selected_next_target === "topic_design_baseline" ||
152
+ topic.selected_next_target === null
153
+ : selectedWaveIds[0] === topic.selected_next_target;
154
+ (checks.push({
155
+ id: "selected_wave_matches_topic_target",
156
+ ok: selectedMatchesTopicTarget,
157
+ reason: selectedMatchesTopicTarget
158
+ ? "selected wave matches topic.selected_next_target"
159
+ : `selected wave and topic.selected_next_target diverge (${selectedWaveIds[0] ?? "none"} vs ${topic.selected_next_target ?? "none"})`,
160
+ }),
161
+ checks.push({
162
+ id: "retired_or_superseded_not_selected",
163
+ ok: retiredSelected.length === 0,
164
+ reason:
165
+ retiredSelected.length === 0
166
+ ? "retired or superseded waves are not selected"
167
+ : `retired/superseded waves remain selected: ${retiredSelected.join(", ")}`,
168
+ }));
169
+ const visiting = new Set(),
170
+ visited = new Set();
171
+ let cycleRef = null;
172
+ const waveMap = new Map(waves.map((wave) => [wave.wave_id, wave]));
173
+ function dfs(waveId, trail = []) {
174
+ if (cycleRef) return;
175
+ if (visiting.has(waveId)) {
176
+ cycleRef = [...trail, waveId].join(" -> ");
177
+ return;
178
+ }
179
+ if (visited.has(waveId)) return;
180
+ visiting.add(waveId);
181
+ const wave = waveMap.get(waveId);
182
+ if (wave) for (const dep of normalizeDeps(wave.deps)) dfs(dep, [...trail, waveId]);
183
+ (visiting.delete(waveId), visited.add(waveId));
184
+ }
185
+ for (const waveId of waveIds) dfs(waveId);
186
+ return (
187
+ checks.push({
188
+ id: "graph_acyclic",
189
+ ok: cycleRef === null,
190
+ reason:
191
+ cycleRef === null ? "wave graph is acyclic" : `wave graph contains a cycle: ${cycleRef}`,
192
+ }),
193
+ waves.length === 0 && warnings.push("topic has no machine wave registry yet"),
194
+ { ok: checks.every((entry) => entry.ok), checks, warnings, waves }
195
+ );
196
+ }
197
+ export async function validateTopicGraph(projectRoot, input = null) {
198
+ const loaded = await loadTopicReport(projectRoot, input);
199
+ if (!loaded.ok) return { ok: false, error: loaded.error, checks: [], warnings: [] };
200
+ const { validateTopicRoot } = await import("./topic-root-validation.mjs");
201
+ const rootValidation = await validateTopicRoot(projectRoot, input);
202
+ if (!rootValidation.ok) return rootValidation;
203
+ const authority = await loadTopicRuntimeAuthority(projectRoot);
204
+ if (!topicHasEnrichedShape(loaded.topic, authority))
205
+ return {
206
+ ...rootValidation,
207
+ ok: false,
208
+ checks: [
209
+ ...rootValidation.checks,
210
+ {
211
+ id: "enriched_topic_required_for_wave_graph",
212
+ ok: false,
213
+ reason: "wave graph commands require an enriched topic root",
214
+ },
215
+ ],
216
+ warnings: rootValidation.warnings,
217
+ };
218
+ const graph = validateGraphFromTopic(loaded.topic);
219
+ return {
220
+ ...rootValidation,
221
+ ok: rootValidation.ok && graph.ok,
222
+ checks: [...rootValidation.checks, ...graph.checks],
223
+ warnings: [...rootValidation.warnings, ...graph.warnings],
224
+ waveCount: graph.waves.length,
225
+ };
226
+ }
227
+ export async function validateWaveAdmission(projectRoot, input, waveId) {
228
+ const loaded = await loadTopicReport(projectRoot, input);
229
+ if (!loaded.ok) return { ok: false, error: loaded.error, checks: [], warnings: [] };
230
+ const graphReport = await validateTopicGraph(projectRoot, input),
231
+ wave = getTopicWaves(loaded.topic).find((entry) => entry.wave_id === waveId) ?? null,
232
+ checks = [...(graphReport.checks ?? [])],
233
+ warnings = [...(graphReport.warnings ?? [])];
234
+ if (
235
+ (checks.push({
236
+ id: "wave_exists",
237
+ ok: wave !== null,
238
+ reason: wave ? "wave exists in topic.yaml waves[]" : `wave does not exist: ${waveId}`,
239
+ }),
240
+ !wave)
241
+ )
242
+ return { ...graphReport, ok: false, checks, warnings };
243
+ const dispatchableState = !["retired", "superseded", "closed", "overflowed"].includes(wave.state);
244
+ (checks.push({
245
+ id: "wave_state_dispatchable",
246
+ ok: dispatchableState,
247
+ reason: dispatchableState
248
+ ? "wave state is eligible for admission"
249
+ : `wave state is not admissible: ${wave.state}`,
250
+ }),
251
+ checks.push({
252
+ id: "wave_selected",
253
+ ok: wave.selected === true,
254
+ reason: wave.selected === true ? "wave is selected" : "wave must be selected before admission",
255
+ }),
256
+ checks.push({
257
+ id: "selected_target_matches_wave",
258
+ ok: loaded.topic.selected_next_target === waveId,
259
+ reason:
260
+ loaded.topic.selected_next_target === waveId
261
+ ? "topic.selected_next_target matches the wave"
262
+ : `topic.selected_next_target does not match wave (${loaded.topic.selected_next_target ?? "none"} vs ${waveId})`,
263
+ }));
264
+ const waveMap = new Map(getTopicWaves(loaded.topic).map((entry) => [entry.wave_id, entry])),
265
+ unmetDeps = normalizeDeps(wave.deps).filter((dep) => waveMap.get(dep)?.state !== "closed");
266
+ checks.push({
267
+ id: "upstream_dependencies_closed",
268
+ ok: unmetDeps.length === 0,
269
+ reason:
270
+ unmetDeps.length === 0
271
+ ? "all upstream dependencies are closed"
272
+ : `upstream dependencies are not closed: ${unmetDeps.join(", ")}`,
273
+ });
274
+ const waveStateAllowedForAdmit = ["candidate", "preflight_draft", "needs_revision"].includes(
275
+ wave.state,
276
+ );
277
+ return (
278
+ checks.push({
279
+ id: "wave_state_allows_preflight_admission",
280
+ ok: waveStateAllowedForAdmit,
281
+ reason: waveStateAllowedForAdmit
282
+ ? "wave state can move to preflight_admitted"
283
+ : `wave state cannot move to preflight_admitted from ${wave.state}`,
284
+ }),
285
+ { ...graphReport, ok: graphReport.ok && checks.every((entry) => entry.ok), checks, warnings }
286
+ );
287
+ }
288
+ export async function addWaveToTopic(projectRoot, input, wave) {
289
+ const loaded = await loadTopicReport(projectRoot, input);
290
+ if (!loaded.ok) return loaded;
291
+ const authority = await loadTopicRuntimeAuthority(projectRoot);
292
+ if (!topicHasEnrichedShape(loaded.topic, authority))
293
+ return { ok: false, error: "Wave commands require an enriched topic root." };
294
+ const waves = getTopicWaves(loaded.topic);
295
+ if (waves.some((entry) => entry.wave_id === wave.wave_id))
296
+ return { ok: false, error: `Wave already exists: ${wave.wave_id}` };
297
+ waves.push(wave);
298
+ const graphPreview = validateGraphFromTopic({ ...loaded.topic, waves }),
299
+ failedCheck = graphPreview.checks.find((entry) => !entry.ok);
300
+ return failedCheck
301
+ ? {
302
+ ok: false,
303
+ error: `Wave add refused: ${failedCheck.reason}`,
304
+ checks: graphPreview.checks,
305
+ warnings: graphPreview.warnings,
306
+ }
307
+ : ((loaded.topic.waves = waves),
308
+ await writeTopicYaml(loaded.topicYamlPath, loaded.topic),
309
+ {
310
+ ok: true,
311
+ topicId: loaded.topicId,
312
+ topicRef: toPortableRelativePath(path.relative(projectRoot, loaded.topicDir)),
313
+ waveId: wave.wave_id,
314
+ waveState: wave.state,
315
+ });
316
+ }
317
+ export async function selectWaveInTopic(projectRoot, input, waveId) {
318
+ const loaded = await loadTopicReport(projectRoot, input);
319
+ if (!loaded.ok) return loaded;
320
+ const authority = await loadTopicRuntimeAuthority(projectRoot);
321
+ if (!topicHasEnrichedShape(loaded.topic, authority))
322
+ return { ok: false, error: "Wave commands require an enriched topic root." };
323
+ const waves = getTopicWaves(loaded.topic),
324
+ wave = waves.find((entry) => entry.wave_id === waveId);
325
+ if (!wave) return { ok: false, error: `Wave not found: ${waveId}` };
326
+ if (["retired", "superseded", "closed", "overflowed"].includes(wave.state))
327
+ return {
328
+ ok: false,
329
+ error: `Wave select refused: ${waveId} is not selectable from state ${wave.state}`,
330
+ };
331
+ for (const entry of waves) entry.selected = entry.wave_id === waveId;
332
+ return (
333
+ (loaded.topic.waves = waves),
334
+ (loaded.topic.selected_next_target = waveId),
335
+ (loaded.topic.last_transition_at = buildTopicNow()),
336
+ (loaded.topic.last_transition_reason = `selected_${waveId}_as_next_execution_target`),
337
+ await writeTopicYaml(loaded.topicYamlPath, loaded.topic),
338
+ {
339
+ ok: true,
340
+ topicId: loaded.topicId,
341
+ topicRef: toPortableRelativePath(path.relative(projectRoot, loaded.topicDir)),
342
+ waveId,
343
+ selectedNextTarget: loaded.topic.selected_next_target,
344
+ }
345
+ );
346
+ }
347
+ export async function admitWaveInTopic(projectRoot, input, waveId) {
348
+ let validation = await validateWaveAdmission(projectRoot, input, waveId);
349
+ if (!validation.ok) {
350
+ const loadedForSelection = await loadTopicReport(projectRoot, input),
351
+ wavesForSelection = getTopicWaves(loadedForSelection.topic),
352
+ waveForSelection = wavesForSelection.find((entry) => entry.wave_id === waveId),
353
+ terminalIds = new Set(
354
+ wavesForSelection
355
+ .filter((entry) => ["closed", "retired", "superseded"].includes(entry.state))
356
+ .map((entry) => entry.wave_id),
357
+ ),
358
+ depsClosed =
359
+ waveForSelection &&
360
+ (Array.isArray(waveForSelection.deps) ? waveForSelection.deps : []).every((dep) =>
361
+ terminalIds.has(dep),
362
+ ),
363
+ canSelectForAdmission =
364
+ loadedForSelection.ok &&
365
+ (loadedForSelection.topic.selected_next_target === null ||
366
+ loadedForSelection.topic.selected_next_target === "topic_design_baseline") &&
367
+ waveForSelection &&
368
+ ["candidate", "preflight_draft", "needs_revision"].includes(waveForSelection.state) &&
369
+ depsClosed;
370
+ if (canSelectForAdmission) {
371
+ const selected = await selectWaveInTopic(projectRoot, input, waveId);
372
+ if (!selected.ok) return selected;
373
+ validation = await validateWaveAdmission(projectRoot, input, waveId);
374
+ }
375
+ }
376
+ if (!validation.ok) return validation;
377
+ const loaded = await loadTopicReport(projectRoot, input),
378
+ waves = getTopicWaves(loaded.topic),
379
+ wave = waves.find((entry) => entry.wave_id === waveId);
380
+ ((wave.state = "preflight_admitted"), (loaded.topic.waves = waves));
381
+ let nextState = loaded.topic.state;
382
+ (["proposal", "pending"].includes(loaded.topic.state) &&
383
+ ((nextState = "ongoing"), (loaded.topic.state = nextState)),
384
+ (loaded.topic.last_transition_at = buildTopicNow()),
385
+ (loaded.topic.last_transition_reason = `wave_${waveId}_preflight_admitted`));
386
+ const moved = await moveTopicDirectoryForState(
387
+ projectRoot,
388
+ loaded.topicDir,
389
+ loaded.topicId,
390
+ nextState,
391
+ );
392
+ return (
393
+ await writeTopicYaml(moved.topicYamlPath, loaded.topic),
394
+ {
395
+ ok: true,
396
+ topicId: loaded.topicId,
397
+ topicRef: toPortableRelativePath(path.relative(projectRoot, moved.topicDir)),
398
+ waveId,
399
+ waveState: wave.state,
400
+ state: loaded.topic.state,
401
+ }
402
+ );
403
+ }