@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.
- package/README.md +19 -20
- package/adapters/oh-my-codex/README.md +8 -9
- package/cli/commands/audit-sweep.mjs +10 -10
- package/cli/commands/classify-spec-tree.mjs +5 -0
- package/cli/commands/closeout.mjs +3 -0
- package/cli/commands/generate-spec-derived-docs.mjs +20 -0
- package/cli/commands/generate-spec-migration-plan.mjs +30 -0
- package/cli/commands/start.mjs +5 -1
- package/cli/commands/surface-validator-command.mjs +49 -0
- package/cli/commands/sweep-design.mjs +295 -0
- package/cli/commands/sweep.mjs +22 -0
- package/cli/commands/sync.mjs +132 -0
- package/cli/commands/topic-formatters.mjs +8 -8
- package/cli/commands/validate-ai-governance.mjs +167 -46
- package/cli/commands/validate-domain-admission.mjs +5 -0
- package/cli/commands/validate-guidance-bodies.mjs +5 -0
- package/cli/commands/validate-placement.mjs +5 -0
- package/cli/commands/validate-projection-edges.mjs +5 -0
- package/cli/commands/validate-spec-audit.mjs +5 -1
- package/cli/commands/validate-table-family.mjs +5 -0
- package/cli/commands/validate-tracked-output-admission.mjs +5 -0
- package/cli/constants.mjs +5 -49
- package/cli/help.mjs +33 -11
- package/cli/index.mjs +20 -2
- package/cli/lib/audit-sweep-runtime/admissions.mjs +38 -29
- package/cli/lib/audit-sweep-runtime/audit-validity.mjs +8 -0
- package/cli/lib/audit-sweep-runtime/chunks.mjs +11 -11
- package/cli/lib/audit-sweep-runtime/closeout.mjs +8 -8
- package/cli/lib/audit-sweep-runtime/codex-auditor-evidence.mjs +3 -3
- package/cli/lib/audit-sweep-runtime/codex-auditor.mjs +10 -10
- package/cli/lib/audit-sweep-runtime/common.mjs +7 -7
- package/cli/lib/audit-sweep-runtime/format.mjs +3 -3
- package/cli/lib/audit-sweep-runtime/ingest.mjs +8 -8
- package/cli/lib/audit-sweep-runtime/inventory-spec-chunks.mjs +24 -27
- package/cli/lib/audit-sweep-runtime/inventory.mjs +58 -18
- package/cli/lib/audit-sweep-runtime/ledger.mjs +1 -1
- package/cli/lib/audit-sweep-runtime/p0p1-profile.mjs +2 -2
- package/cli/lib/audit-sweep-runtime/remediation.mjs +6 -6
- package/cli/lib/audit-sweep-runtime/rerun.mjs +6 -6
- package/cli/lib/audit-sweep-runtime/status.mjs +1 -1
- package/cli/lib/audit-sweep-runtime/validators.mjs +2 -2
- package/cli/lib/authority-convergence.mjs +397 -2
- package/cli/lib/blueprint-audit.mjs +5 -5
- package/cli/lib/closeout.mjs +126 -3
- package/cli/lib/contracts.mjs +21 -17
- package/cli/lib/handoff.mjs +29 -11
- package/cli/lib/high-risk-admission.mjs +60 -11
- package/cli/lib/high-risk-decision.mjs +31 -2
- package/cli/lib/high-risk-ingest.mjs +5 -1
- package/cli/lib/high-risk-review.mjs +5 -1
- package/cli/lib/internal/contracts-parse.mjs +195 -24
- package/cli/lib/internal/contracts-validators.mjs +3 -2
- package/cli/lib/internal/doctor-bootstrap-surface.mjs +82 -35
- package/cli/lib/internal/doctor-delegated-surface.mjs +1 -1
- package/cli/lib/internal/doctor-finalize.mjs +12 -8
- package/cli/lib/internal/doctor-inspectors.mjs +34 -1
- package/cli/lib/internal/governance/ai/ai-context-budget-core.mjs +74 -12
- package/cli/lib/internal/governance/ai/ai-structure-budget-core.mjs +24 -6
- package/cli/lib/internal/governance/ai/check-agents-freshness.mjs +18 -23
- package/cli/lib/internal/surface-taxonomy-validators.mjs +931 -0
- package/cli/lib/internal/validators-spec.mjs +229 -20
- package/cli/lib/sweep-design-runtime/common.mjs +246 -0
- package/cli/lib/sweep-design-runtime/engine.mjs +733 -0
- package/cli/lib/sweep-design-runtime/fix-topic.mjs +414 -0
- package/cli/lib/sweep-design-runtime/lifecycle.mjs +54 -0
- package/cli/lib/sweep-design-runtime/results.mjs +324 -0
- package/cli/lib/sweep-design.mjs +8 -0
- package/cli/lib/sync.mjs +143 -0
- package/cli/lib/topic-artifacts.mjs +186 -0
- package/cli/lib/topic-authority-coverage.mjs +73 -0
- package/cli/lib/topic-closeout.mjs +560 -0
- package/cli/lib/topic-common.mjs +404 -0
- package/cli/lib/topic-decisions.mjs +332 -0
- package/cli/lib/topic-draft-packets.mjs +126 -7
- package/cli/lib/topic-execution.mjs +515 -0
- package/cli/lib/topic-goal.mjs +112 -33
- package/cli/lib/topic-ledger.mjs +281 -0
- package/cli/lib/topic-lifecycle-artifacts.mjs +173 -0
- package/cli/lib/topic-root-validation.mjs +288 -0
- package/cli/lib/topic-runner-commands.mjs +174 -0
- package/cli/lib/topic-runner-deferral.mjs +532 -0
- package/cli/lib/topic-runner-stale-gates.mjs +114 -0
- package/cli/lib/topic-runner-validation.mjs +138 -0
- package/cli/lib/topic-runner.mjs +109 -154
- package/cli/lib/topic-scaffold.mjs +252 -0
- package/cli/lib/topic-waves.mjs +403 -0
- package/cli/lib/topic.mjs +81 -93
- package/cli/lib/value-helpers.mjs +6 -1
- package/cli/seeds/bootstrap.mjs +96 -20
- package/cli/seeds/seed-policy.yaml +67 -0
- package/config/bootstrap.yaml +1 -1
- package/config/skill-manifest.yaml +4 -2
- package/config/spec-generation-inputs.yaml +41 -19
- package/contracts/audit-remediation-map.schema.yaml +1 -0
- package/contracts/audit-sweep-result.yaml +4 -0
- package/contracts/domain-admission.schema.yaml +56 -0
- package/contracts/migration-inventory.schema.yaml +80 -0
- package/contracts/negative-fixtures.yaml +91 -0
- package/contracts/placement-contract.schema.yaml +163 -0
- package/contracts/projection-edge.schema.yaml +130 -0
- package/contracts/shared-enums.yaml +68 -0
- package/contracts/spec-generation-audit.schema.yaml +19 -4
- package/contracts/spec-generation-inputs.schema.yaml +130 -29
- package/contracts/spec-reconstruction-result.yaml +9 -5
- package/contracts/surface-taxonomy.schema.yaml +201 -0
- package/contracts/sweep-design-result.yaml +349 -0
- package/contracts/table-family.schema.yaml +114 -0
- package/contracts/topic-goal.schema.yaml +10 -1
- package/contracts/tracked-output-admission.schema.yaml +70 -0
- package/contracts/workflow-consumer.schema.yaml +112 -0
- package/methodology/audit-sweep-p0p1-recall.yaml +1 -1
- package/methodology/spec-reconstruction.yaml +53 -30
- package/package.json +5 -4
- package/spec/_meta/command-gating-matrix.yaml +33 -0
- package/spec/_meta/generate-drift-migration-checklist.yaml +44 -62
- package/spec/_meta/governance-routing-cutover-checklist.yaml +3 -3
- package/spec/_meta/phase2-impacted-surface-matrix.yaml +14 -14
- package/spec/_meta/spec-authority-cutover-readiness.yaml +3 -5
- package/spec/_meta/spec-tree-model.yaml +104 -36
- package/spec/bootstrap-state.yaml +36 -36
- package/spec/product-scope.yaml +13 -10
package/cli/lib/topic-goal.mjs
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
|
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(
|
|
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, "")
|
|
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(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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,
|
|
287
|
+
function buildGoalCommand(topicId, executionStartWave, sourceArtifacts) {
|
|
229
288
|
const artifactList = sourceArtifacts.map((ref) => path.basename(ref)).join(", ");
|
|
230
|
-
|
|
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 =
|
|
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
|
|
295
|
-
const deps = Array.isArray(
|
|
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.
|
|
311
|
-
check("selected_wave_single_source",
|
|
312
|
-
check("wave_option_matches_selected", options.wave === null || options.wave ===
|
|
313
|
-
check("selected_wave_executable",
|
|
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",
|
|
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
|
|
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
|
|
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:
|
|
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
|
+
}
|