@opengsd/gsd-core 1.16.0 → 1.18.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -2
- package/.opencode/plugins/gsd-core.js +251 -1
- package/.opencode/plugins/gsd-statusline/tui.tsx +55 -0
- package/GEMINI.md +1 -1
- package/agents/gsd-advisor-researcher.compact.md +1 -1
- package/agents/gsd-advisor-researcher.md +1 -1
- package/agents/gsd-assumptions-analyzer.compact.md +1 -1
- package/agents/gsd-assumptions-analyzer.md +1 -1
- package/agents/gsd-code-fixer.compact.md +1 -1
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-code-reviewer.compact.md +1 -1
- package/agents/gsd-code-reviewer.md +1 -1
- package/agents/gsd-codebase-mapper.compact.md +1 -1
- package/agents/gsd-codebase-mapper.md +1 -1
- package/agents/gsd-debug-session-manager.compact.md +6 -0
- package/agents/gsd-debug-session-manager.md +6 -0
- package/agents/gsd-debugger.md +1 -1
- package/agents/gsd-doc-verifier.compact.md +4 -4
- package/agents/gsd-doc-verifier.md +4 -3
- package/agents/gsd-doc-writer.compact.md +23 -5
- package/agents/gsd-doc-writer.md +19 -6
- package/agents/gsd-eval-auditor.compact.md +1 -1
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +7 -3
- package/agents/gsd-integration-checker.compact.md +1 -1
- package/agents/gsd-integration-checker.md +1 -1
- package/agents/gsd-nyquist-auditor.compact.md +1 -1
- package/agents/gsd-nyquist-auditor.md +1 -1
- package/agents/gsd-phase-researcher.md +1 -1
- package/agents/gsd-plan-checker.md +2 -1
- package/agents/gsd-planner.md +5 -4
- package/agents/gsd-project-researcher.compact.md +1 -1
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-research-synthesizer.compact.md +5 -5
- package/agents/gsd-research-synthesizer.md +5 -5
- package/agents/gsd-roadmapper.compact.md +1 -1
- package/agents/gsd-roadmapper.md +1 -1
- package/agents/gsd-security-auditor.compact.md +1 -1
- package/agents/gsd-security-auditor.md +1 -1
- package/agents/gsd-ui-auditor.compact.md +1 -1
- package/agents/gsd-ui-auditor.md +1 -1
- package/agents/gsd-ui-checker.compact.md +1 -1
- package/agents/gsd-ui-checker.md +1 -1
- package/agents/gsd-ui-researcher.compact.md +13 -1
- package/agents/gsd-ui-researcher.md +28 -1
- package/agents/gsd-verifier.md +1 -1
- package/bin/gsd-mcp-server.js +3 -1
- package/bin/install.js +398 -95
- package/commands/gsd/code-review.md +8 -0
- package/commands/gsd/pause-work.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +34 -12
- package/gsd-core/bin/lib/adr-parser.cjs +37 -5
- package/gsd-core/bin/lib/capability-ledger.cjs +25 -0
- package/gsd-core/bin/lib/capability-lock.cjs +29 -7
- package/gsd-core/bin/lib/capability-registry.cjs +138 -80
- package/gsd-core/bin/lib/capability-source.cjs +3 -1
- package/gsd-core/bin/lib/capability-state.cjs +6 -3
- package/gsd-core/bin/lib/capability-validator.cjs +8 -5
- package/gsd-core/bin/lib/capability-writer.cjs +2 -1
- package/gsd-core/bin/lib/commands.cjs +33 -9
- package/gsd-core/bin/lib/config-loader.cjs +3 -3
- package/gsd-core/bin/lib/decision-coverage-support.cjs +19 -0
- package/gsd-core/bin/lib/gate-context-drift.cjs +5 -4
- package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +15 -0
- package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +13 -0
- package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +23 -3
- package/gsd-core/bin/lib/gate-predicate.cjs +8 -12
- package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +60 -5
- package/gsd-core/bin/lib/gate-ui-safety.cjs +2 -1
- package/gsd-core/bin/lib/graphify.cjs +3 -2
- package/gsd-core/bin/lib/init.cjs +43 -16
- package/gsd-core/bin/lib/install-effort-resolver.cjs +2 -2
- package/gsd-core/bin/lib/install-engine.cjs +11 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +4 -3
- package/gsd-core/bin/lib/install-profiles.cjs +5 -3
- package/gsd-core/bin/lib/install-shadow-report.cjs +12 -0
- package/gsd-core/bin/lib/installer-migrations/011-opencode-mcp-no-prompts.cjs +99 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +54 -1
- package/gsd-core/bin/lib/jsonc-edit.cjs +418 -0
- package/gsd-core/bin/lib/loop-host-contract.cjs +2 -1
- package/gsd-core/bin/lib/mcp-server.cjs +5 -1
- package/gsd-core/bin/lib/opencode-mcp-entry.cjs +39 -0
- package/gsd-core/bin/lib/opencode-version.cjs +486 -0
- package/gsd-core/bin/lib/phase.cjs +141 -11
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +2 -1
- package/gsd-core/bin/lib/report-parser.cjs +57 -1
- package/gsd-core/bin/lib/review-lane-invocation.cjs +40 -0
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +20 -1
- package/gsd-core/bin/lib/roadmap.cjs +56 -5
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +142 -6
- package/gsd-core/bin/lib/runtime-homes.cjs +18 -0
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +58 -3
- package/gsd-core/bin/lib/runtime-slash.cjs +13 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +2 -0
- package/gsd-core/bin/lib/smart-entry.cjs +100 -53
- package/gsd-core/bin/lib/state-document.cjs +37 -3
- package/gsd-core/bin/lib/state-transition.cjs +98 -19
- package/gsd-core/bin/lib/state.cjs +544 -126
- package/gsd-core/bin/lib/task-command-router.cjs +2 -3
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +5 -0
- package/gsd-core/bin/lib/tdd-test-files.cjs +42 -0
- package/gsd-core/bin/lib/update-context.cjs +18 -1
- package/gsd-core/bin/lib/verification.cjs +107 -17
- package/gsd-core/bin/lib/verify.cjs +36 -1
- package/gsd-core/references/agent-contracts.md +1 -1
- package/gsd-core/references/agent-skills-bootstrap.md +26 -7
- package/gsd-core/references/git-integration.md +3 -3
- package/gsd-core/references/planner-reversibility.md +18 -4
- package/gsd-core/references/planner-verify-command-grounding.md +18 -0
- package/gsd-core/references/tdd.md +4 -3
- package/gsd-core/references/verifier-phase-gates.md +8 -1
- package/gsd-core/workflows/add-backlog.md +10 -2
- package/gsd-core/workflows/add-phase.md +5 -1
- package/gsd-core/workflows/add-tests.md +5 -1
- package/gsd-core/workflows/add-todo.md +5 -1
- package/gsd-core/workflows/ai-integration-phase.md +5 -1
- package/gsd-core/workflows/audit-milestone.md +21 -9
- package/gsd-core/workflows/autonomous/templates/minimal-context.md +41 -0
- package/gsd-core/workflows/autonomous.md +52 -64
- package/gsd-core/workflows/check-todos.md +5 -1
- package/gsd-core/workflows/code-review-fix.md +5 -1
- package/gsd-core/workflows/code-review.md +21 -2
- package/gsd-core/workflows/complete-milestone/detail/elaboration.md +5 -1
- package/gsd-core/workflows/complete-milestone.md +25 -5
- package/gsd-core/workflows/debug.md +5 -1
- package/gsd-core/workflows/diagnose-issues.md +5 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +11 -3
- package/gsd-core/workflows/discuss-phase.md +5 -1
- package/gsd-core/workflows/docs-update/detail/elaboration.md +7 -6
- package/gsd-core/workflows/docs-update.md +27 -5
- package/gsd-core/workflows/edit-phase.md +5 -1
- package/gsd-core/workflows/eval-review.md +11 -3
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +5 -1
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +12 -6
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +19 -2
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +18 -1
- package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +5 -1
- package/gsd-core/workflows/execute-phase.md +40 -7
- package/gsd-core/workflows/execute-plan.md +8 -4
- package/gsd-core/workflows/extract-learnings.md +6 -2
- package/gsd-core/workflows/forensics.md +5 -1
- package/gsd-core/workflows/import.md +5 -1
- package/gsd-core/workflows/insert-phase.md +5 -1
- package/gsd-core/workflows/manager.md +10 -2
- package/gsd-core/workflows/map-codebase.md +5 -1
- package/gsd-core/workflows/milestone-summary.md +10 -2
- package/gsd-core/workflows/new-milestone.md +13 -5
- package/gsd-core/workflows/new-project.md +9 -5
- package/gsd-core/workflows/next.md +8 -4
- package/gsd-core/workflows/pause-work.md +3 -3
- package/gsd-core/workflows/plan-phase/steps/auto-advance-check.md +70 -0
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +5 -1
- package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +6 -2
- package/gsd-core/workflows/plan-phase.md +41 -73
- package/gsd-core/workflows/plan-review-convergence.md +17 -5
- package/gsd-core/workflows/plant-seed.md +8 -4
- package/gsd-core/workflows/progress.md +6 -2
- package/gsd-core/workflows/quick-batch.md +5 -1
- package/gsd-core/workflows/quick.md +22 -7
- package/gsd-core/workflows/remove-phase.md +5 -1
- package/gsd-core/workflows/resume-project.md +19 -11
- package/gsd-core/workflows/review.md +6 -2
- package/gsd-core/workflows/scan.md +5 -1
- package/gsd-core/workflows/secure-phase.md +6 -2
- package/gsd-core/workflows/settings.md +3 -3
- package/gsd-core/workflows/ship.md +13 -10
- package/gsd-core/workflows/thread.md +10 -2
- package/gsd-core/workflows/transition.md +5 -1
- package/gsd-core/workflows/ui-phase.md +5 -1
- package/gsd-core/workflows/ui-review.md +6 -2
- package/gsd-core/workflows/ultraplan-phase.md +5 -1
- package/gsd-core/workflows/update.md +5 -1
- package/gsd-core/workflows/validate-phase.md +6 -2
- package/gsd-core/workflows/verify-work/detail/elaboration.md +1 -1
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +5 -1
- package/gsd-core/workflows/verify-work.md +11 -3
- package/hooks/dist/gsd-agent-isolation-guard.js +3 -1
- package/hooks/dist/gsd-check-update.js +26 -20
- package/hooks/dist/gsd-context-monitor.js +14 -5
- package/hooks/dist/gsd-cursor-subagent-start.js +2 -1
- package/hooks/dist/gsd-secret-read-guard.js +189 -15
- package/hooks/dist/gsd-statusline.js +321 -22
- package/hooks/dist/gsd-validate-commit.sh +27 -5
- package/hooks/gsd-agent-isolation-guard.js +3 -1
- package/hooks/gsd-check-update.js +26 -20
- package/hooks/gsd-context-monitor.js +14 -5
- package/hooks/gsd-cursor-subagent-start.js +2 -1
- package/hooks/gsd-secret-read-guard.js +189 -15
- package/hooks/gsd-statusline.js +321 -22
- package/hooks/gsd-validate-commit.sh +27 -5
- package/package.json +1 -1
- package/scripts/audit-workflow-script-paths.cjs +1 -1
- package/scripts/diagnose-validate-commit-in-tier.cjs +270 -0
- package/scripts/diagnose-validate-commit-phases.cjs +348 -0
- package/scripts/diagnose-windows-spawn-watcher.cjs +317 -0
- package/scripts/docs-guard-registry.cjs +2 -0
- package/scripts/gen-capability-registry.cjs +7 -1
- package/scripts/gen-loop-host-contract.cjs +4 -1
- package/scripts/lib/macos-conformance-tier.generated.cjs +13 -0
- package/scripts/lib/platform-conformance-tier.generated.cjs +26 -0
- package/scripts/lint-phase-arg-assignment.cjs +2 -2
- package/scripts/lint-workflow-shellcheck-baseline.json +16 -16
- package/scripts/mutation-matrix.cjs +3 -1
- package/scripts/release-tarball-smoke.cjs +15 -7
- package/scripts/xtrace-phase-timing.cjs +205 -0
- package/skills/gsd-code-review/SKILL.md +8 -0
- package/skills/gsd-pause-work/SKILL.md +1 -1
- package/vscode/package.json +1 -1
|
@@ -14,11 +14,14 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
14
14
|
const pattern_cjs_1 = require("./pattern.cjs");
|
|
15
15
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
16
16
|
const ioMod = require("./io.cjs");
|
|
17
|
-
const { output, error, declineNoOp, formatDiagnosticToken } = ioMod;
|
|
17
|
+
const { output, error, declineNoOp, formatDiagnosticToken, ERROR_REASON } = ioMod;
|
|
18
18
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
19
19
|
const cliExitModule = require("./cli-exit.cjs");
|
|
20
20
|
const { ExitError } = cliExitModule;
|
|
21
21
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
22
|
+
const capabilityLedgerMod = require("./capability-ledger.cjs");
|
|
23
|
+
const { readSmallRegularFile } = capabilityLedgerMod;
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
22
25
|
const stateContract = require("./state-contract.cjs");
|
|
23
26
|
const { publishStateContract } = stateContract;
|
|
24
27
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -316,19 +319,51 @@ function cmdStateGet(cwd, section, raw) {
|
|
|
316
319
|
output({ error: `Section or field "${section}" not found` }, raw, '');
|
|
317
320
|
}
|
|
318
321
|
}
|
|
319
|
-
|
|
322
|
+
/**
|
|
323
|
+
* #4926: cap on a `--<flag>-file` text input. Twice the 128 KiB one argv string
|
|
324
|
+
* can carry on Linux, so the file form never takes less than the inline form,
|
|
325
|
+
* and the entry echoed back on stdout stays small.
|
|
326
|
+
*/
|
|
327
|
+
const TEXT_FILE_MAX_BYTES = 256 * 1024;
|
|
328
|
+
/**
|
|
329
|
+
* Resolve a text argument that may instead be supplied as a file (`--<flag>`
|
|
330
|
+
* or `--<flag>-file`).
|
|
331
|
+
*
|
|
332
|
+
* #4926: the file is a read-once INPUT — its contents are copied into
|
|
333
|
+
* STATE.md and the path itself is never written, recorded, or resolved
|
|
334
|
+
* again — so it is not a project-write resource and is not confined to the
|
|
335
|
+
* project root. A scratch file from an agent's temp directory is accepted.
|
|
336
|
+
* A relative path still resolves against the project root.
|
|
337
|
+
*
|
|
338
|
+
* A file that cannot be read is a caller error and goes through the fault
|
|
339
|
+
* path — stderr, non-zero exit (USAGE), `--json-errors` envelope — rather than
|
|
340
|
+
* an `{ added: false }` payload at exit 0 that a shell caller gating on `$?`
|
|
341
|
+
* reads as success while nothing was written.
|
|
342
|
+
*
|
|
343
|
+
* An unconfined path is hostile input, so the read goes through
|
|
344
|
+
* `readSmallRegularFile`: a FIFO, a device (`/dev/zero`, a piped `/dev/stdin`)
|
|
345
|
+
* or a directory is refused without blocking, and a file over
|
|
346
|
+
* TEXT_FILE_MAX_BYTES is refused without being read.
|
|
347
|
+
*/
|
|
348
|
+
function readTextArgOrFile(cwd, value, filePath, flag) {
|
|
320
349
|
if (!filePath)
|
|
321
350
|
return value;
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const { assertWithinRoot, PathAcceptance } = require('./security.cjs');
|
|
325
|
-
const contained = assertWithinRoot(filePath, cwd, `${label} path`, PathAcceptance.AbsoluteInsideRoot);
|
|
351
|
+
const refuse = (why) => error(`--${flag}-file could not be read: ${formatDiagnosticToken(filePath)}${why ? ` (${why})` : ''}`, ERROR_REASON.USAGE);
|
|
352
|
+
let content;
|
|
326
353
|
try {
|
|
327
|
-
|
|
328
|
-
}
|
|
329
|
-
catch {
|
|
330
|
-
throw new Error(`${label} file not found: ${filePath}`);
|
|
354
|
+
content = readSmallRegularFile(node_path_1.default.resolve(cwd, filePath), TEXT_FILE_MAX_BYTES);
|
|
331
355
|
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
const code = err.code;
|
|
358
|
+
if (code === 'EFBIG')
|
|
359
|
+
return refuse(`larger than ${TEXT_FILE_MAX_BYTES} bytes`);
|
|
360
|
+
if (code === 'EISDIR' || code === 'ENXIO')
|
|
361
|
+
return refuse('not a regular file');
|
|
362
|
+
return refuse(code);
|
|
363
|
+
}
|
|
364
|
+
if (content === null)
|
|
365
|
+
return refuse('ENOENT');
|
|
366
|
+
return content.trimEnd();
|
|
332
367
|
}
|
|
333
368
|
function cmdStatePatch(cwd, patches, raw) {
|
|
334
369
|
// Validate all field names before processing
|
|
@@ -1224,25 +1259,19 @@ function cmdStateUpdateProgress(cwd, raw) {
|
|
|
1224
1259
|
}
|
|
1225
1260
|
function cmdStateAddDecision(cwd, options, raw) {
|
|
1226
1261
|
const statePath = planningPaths(cwd).state;
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1262
|
+
// #4926: every refusal from this verb goes through the fault path (non-zero
|
|
1263
|
+
// exit), so a caller gating on `$?` never reads success while nothing was written.
|
|
1264
|
+
if (!node_fs_1.default.existsSync(statePath))
|
|
1265
|
+
return error('STATE.md not found', ERROR_REASON.USAGE);
|
|
1231
1266
|
const { phase, summary, summary_file, rationale, rationale_file } = options;
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
return;
|
|
1241
|
-
}
|
|
1242
|
-
if (!summaryText) {
|
|
1243
|
-
output({ error: 'summary required' }, raw, undefined);
|
|
1244
|
-
return;
|
|
1245
|
-
}
|
|
1267
|
+
const summaryText = readTextArgOrFile(cwd, summary, summary_file, 'summary');
|
|
1268
|
+
// The rationale is optional: blank, inline or file, means "no rationale".
|
|
1269
|
+
const rationaleInput = readTextArgOrFile(cwd, rationale || '', rationale_file, 'rationale') || '';
|
|
1270
|
+
const rationaleText = rationaleInput.trim() ? rationaleInput : '';
|
|
1271
|
+
// A blank inline value is refused the same as a blank file (the file form is
|
|
1272
|
+
// trimEnd()ed by readTextArgOrFile, the inline form is not).
|
|
1273
|
+
if (!summaryText || !summaryText.trim())
|
|
1274
|
+
return error('summary required — pass a non-blank --summary or --summary-file', ERROR_REASON.USAGE);
|
|
1246
1275
|
// #3231/#3481: `--phase` omitted → resolve from the STATE.md being written, via
|
|
1247
1276
|
// the canonical ladder `state prune` uses. A decision entry is a permanent
|
|
1248
1277
|
// record, so a literal `[Phase ?]` written while `current_phase` sat three
|
|
@@ -1310,23 +1339,14 @@ function cmdStateAddDecision(cwd, options, raw) {
|
|
|
1310
1339
|
}
|
|
1311
1340
|
function cmdStateAddBlocker(cwd, text, raw) {
|
|
1312
1341
|
const statePath = planningPaths(cwd).state;
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1342
|
+
// #4926: every refusal from this verb goes through the fault path (non-zero
|
|
1343
|
+
// exit), so a caller gating on `$?` never reads success while nothing was written.
|
|
1344
|
+
if (!node_fs_1.default.existsSync(statePath))
|
|
1345
|
+
return error('STATE.md not found', ERROR_REASON.USAGE);
|
|
1317
1346
|
const blockerOptions = typeof text === 'object' && text !== null ? text : { text: text };
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
}
|
|
1322
|
-
catch (err) {
|
|
1323
|
-
output({ added: false, reason: err.message }, raw, 'false');
|
|
1324
|
-
return;
|
|
1325
|
-
}
|
|
1326
|
-
if (!blockerText) {
|
|
1327
|
-
output({ error: 'text required' }, raw, undefined);
|
|
1328
|
-
return;
|
|
1329
|
-
}
|
|
1347
|
+
const blockerText = readTextArgOrFile(cwd, blockerOptions.text, blockerOptions.text_file, 'text');
|
|
1348
|
+
if (!blockerText || !blockerText.trim())
|
|
1349
|
+
return error('text required — pass a non-blank --text or --text-file', ERROR_REASON.USAGE);
|
|
1330
1350
|
const entry = `- ${blockerText}`;
|
|
1331
1351
|
let _added = false;
|
|
1332
1352
|
let created = false;
|
|
@@ -1379,25 +1399,16 @@ function cmdStateAddBlocker(cwd, text, raw) {
|
|
|
1379
1399
|
}
|
|
1380
1400
|
function cmdStateAddRoadmapEvolution(cwd, options, raw) {
|
|
1381
1401
|
const statePath = planningPaths(cwd).state;
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1402
|
+
// #4926: every refusal from this verb goes through the fault path (non-zero
|
|
1403
|
+
// exit), so a caller gating on `$?` never reads success while nothing was written.
|
|
1404
|
+
if (!node_fs_1.default.existsSync(statePath))
|
|
1405
|
+
return error('STATE.md not found', ERROR_REASON.USAGE);
|
|
1386
1406
|
const { phase, action, after, note, note_file, urgent } = options;
|
|
1387
|
-
|
|
1388
|
-
try {
|
|
1389
|
-
noteText = readTextArgOrFile(cwd, note, note_file, 'note');
|
|
1390
|
-
}
|
|
1391
|
-
catch (err) {
|
|
1392
|
-
output({ added: false, reason: err.message }, raw, 'false');
|
|
1393
|
-
return;
|
|
1394
|
-
}
|
|
1407
|
+
const noteText = readTextArgOrFile(cwd, note, note_file, 'note');
|
|
1395
1408
|
// Reject missing / empty / whitespace-only notes — an evolution entry with no
|
|
1396
1409
|
// narrative is meaningless and would corrupt the section with a dangling bullet.
|
|
1397
|
-
if (!noteText || !noteText.trim())
|
|
1398
|
-
|
|
1399
|
-
return;
|
|
1400
|
-
}
|
|
1410
|
+
if (!noteText || !noteText.trim())
|
|
1411
|
+
return error('note required — pass a non-blank --note or --note-file', ERROR_REASON.USAGE);
|
|
1401
1412
|
// Flatten line breaks so the entry is always a single Markdown bullet. The
|
|
1402
1413
|
// dedupe + rendering contract is line-oriented; a multiline --note-file would
|
|
1403
1414
|
// otherwise spill continuation lines outside the bullet and defeat dedupe.
|
|
@@ -4513,7 +4524,98 @@ function computeChangedFrontmatterFields(snapshotFm, persistedFm, bodyDeltas) {
|
|
|
4513
4524
|
* (#3818's own direction), which is why keeping it as the candidate
|
|
4514
4525
|
* source was rejected (design doc, Rejected #1).
|
|
4515
4526
|
*/
|
|
4516
|
-
|
|
4527
|
+
/**
|
|
4528
|
+
* ADR-4629 §8.2 (epic #4629, C2): the value a reported/asserted target
|
|
4529
|
+
* resolves to in one document side. Lifted verbatim out of
|
|
4530
|
+
* `reconcileReportedFields` (it was the private `valueOf` closure) so the C2
|
|
4531
|
+
* verifying executor reads a target through the SAME resolution chain the
|
|
4532
|
+
* measured-delta reporter uses — ADR-4629 Decision 4: extend the reporter,
|
|
4533
|
+
* never build a second one.
|
|
4534
|
+
*
|
|
4535
|
+
* #3471 review (unchanged): body-FIRST, frontmatter-key-FLAT-fallback,
|
|
4536
|
+
* dotted-PATH-fallback last. Body-first mirrors the actual write precedence
|
|
4537
|
+
* `patchCore`/`updateCore` apply (#1162's fix — a lowercase body label that
|
|
4538
|
+
* happens to case-exact-match a frontmatter key must still resolve against
|
|
4539
|
+
* the body). `field` a literal flat key (even one containing a `.`) is tried
|
|
4540
|
+
* before it is split and walked as a dotted path (test matrix row 26) —
|
|
4541
|
+
* `resolveFrontmatterPath` pins that same order for the frontmatter side.
|
|
4542
|
+
*
|
|
4543
|
+
* `Current Position` is special-cased: it names the WHOLE `## Current
|
|
4544
|
+
* Position` section, not a single `Label: value` line, so `stateExtractField`
|
|
4545
|
+
* can never resolve it (the root cause of the "Current Position undercount" —
|
|
4546
|
+
* a transform could correctly push `'Current Position'` into its own
|
|
4547
|
+
* `updated` list and it was still silently dropped, because both sides
|
|
4548
|
+
* resolved to `null` and `null === null` failed the old `intended !== null`
|
|
4549
|
+
* guard). `sliceCurrentPositionSection` is the existing fence-aware section
|
|
4550
|
+
* locator (state-transition.cts) — reused rather than re-derived.
|
|
4551
|
+
*
|
|
4552
|
+
* C2 generalizes that special case: a name in `sectionTargets` (a declared
|
|
4553
|
+
* `target: 'section'` assertion) resolves to its whole section body through
|
|
4554
|
+
* the markdown-sectionizer seam (ADR-1372), never a hand-rolled regex.
|
|
4555
|
+
* Existing callers pass no `sectionTargets` and resolve exactly as before.
|
|
4556
|
+
*/
|
|
4557
|
+
function readStateTarget(fm, body, field, sectionTargets) {
|
|
4558
|
+
if (field === 'Current Position') {
|
|
4559
|
+
const section = stateTransitionMod.sliceCurrentPositionSection(body);
|
|
4560
|
+
return section !== null ? section.trim() : null;
|
|
4561
|
+
}
|
|
4562
|
+
if (sectionTargets.has(field)) {
|
|
4563
|
+
const section = (0, markdown_sectionizer_cjs_1.collectSection)(body, (h) => h.text === field);
|
|
4564
|
+
return section !== null ? section.body.trim() : null;
|
|
4565
|
+
}
|
|
4566
|
+
const bodyValue = (0, state_document_cjs_1.stateExtractField)(body, field);
|
|
4567
|
+
if (bodyValue !== null)
|
|
4568
|
+
return bodyValue;
|
|
4569
|
+
if (Object.prototype.hasOwnProperty.call(fm, field))
|
|
4570
|
+
return String(fm[field]);
|
|
4571
|
+
if (field.includes('.')) {
|
|
4572
|
+
const resolved = resolveFrontmatterPath(fm, field);
|
|
4573
|
+
if (resolved !== STATE_FIELD_ABSENT) {
|
|
4574
|
+
return stateScalarString(resolved);
|
|
4575
|
+
}
|
|
4576
|
+
}
|
|
4577
|
+
return null;
|
|
4578
|
+
}
|
|
4579
|
+
const NO_SECTION_TARGETS = new Set();
|
|
4580
|
+
/**
|
|
4581
|
+
* ADR-4629 §8.2 (C2): did this target measurably change between the two
|
|
4582
|
+
* document sides? Lifted verbatim out of `reconcileReportedFields` (the private
|
|
4583
|
+
* `changed` closure) for the same one-rule reason as `readStateTarget`.
|
|
4584
|
+
*
|
|
4585
|
+
* A target can itself be a declared derived leaf (e.g. `plannedPhaseCore`
|
|
4586
|
+
* pushing `'progress.total_plans'` — state-transition.cts). `readStateTarget`'s
|
|
4587
|
+
* null-vs-string convention cannot tell "absent from the frontmatter" apart
|
|
4588
|
+
* from "resolved to the literal string 'null'/''", so it cannot carry the same
|
|
4589
|
+
* materialization rule `computeChangedFrontmatterFields` applies. Those targets
|
|
4590
|
+
* route through the SAME primitives (`resolveFrontmatterPath` + the
|
|
4591
|
+
* `STATE_FIELD_ABSENT` sentinel + `stateFieldValuesDiffer`) instead of a
|
|
4592
|
+
* second, parallel absence convention — one rule, reused, not duplicated.
|
|
4593
|
+
*/
|
|
4594
|
+
function stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody, field, sectionTargets) {
|
|
4595
|
+
const isDeclaredDerivedLeaf = field.includes('.') && Object.prototype.hasOwnProperty.call(FIELD_CLASSIFICATION, field);
|
|
4596
|
+
if (isDeclaredDerivedLeaf && !sectionTargets.has(field)) {
|
|
4597
|
+
const before = resolveFrontmatterPath(snapshotFm, field);
|
|
4598
|
+
const after = resolveFrontmatterPath(persistedFm, field);
|
|
4599
|
+
// Same generalized provenance rule as computeChangedFrontmatterFields:
|
|
4600
|
+
// absent-in-snapshot-materializing-in-persisted is the disk scan
|
|
4601
|
+
// catching a never-synced document up, not this write's own action.
|
|
4602
|
+
if (before === STATE_FIELD_ABSENT && after !== STATE_FIELD_ABSENT)
|
|
4603
|
+
return false;
|
|
4604
|
+
return stateFieldValuesDiffer(before, after);
|
|
4605
|
+
}
|
|
4606
|
+
const before = readStateTarget(snapshotFm, snapshotBody, field, sectionTargets);
|
|
4607
|
+
const after = readStateTarget(persistedFm, persistedBody, field, sectionTargets);
|
|
4608
|
+
if (before === null && after === null)
|
|
4609
|
+
return false;
|
|
4610
|
+
if (before === null || after === null)
|
|
4611
|
+
return true;
|
|
4612
|
+
return before.trim() !== after.trim();
|
|
4613
|
+
}
|
|
4614
|
+
function reconcileReportedFields(statePath, preWriteState, reported, divergedFields,
|
|
4615
|
+
// ADR-4629 §8.2 / Decision 4 (C2): names in `reported` that are declared
|
|
4616
|
+
// `target: 'section'` assertions, measured as whole-section deltas. Omitted
|
|
4617
|
+
// by every pre-C2 caller, whose output is unchanged.
|
|
4618
|
+
sectionTargets = NO_SECTION_TARGETS) {
|
|
4517
4619
|
void divergedFields; // ADR-3473 §8.7 D18: out-param only, not a candidate source here.
|
|
4518
4620
|
if (preWriteState.fm === undefined || preWriteState.body === undefined)
|
|
4519
4621
|
return [];
|
|
@@ -4522,71 +4624,7 @@ function reconcileReportedFields(statePath, preWriteState, reported, divergedFie
|
|
|
4522
4624
|
const persistedBody = stripFrontmatter(persisted);
|
|
4523
4625
|
const snapshotFm = preWriteState.fm;
|
|
4524
4626
|
const snapshotBody = preWriteState.body;
|
|
4525
|
-
|
|
4526
|
-
// FLAT-fallback, dotted-PATH-fallback last. Body-first mirrors the actual
|
|
4527
|
-
// write precedence `patchCore`/`updateCore` apply (#1162's fix — a
|
|
4528
|
-
// lowercase body label that happens to case-exact-match a frontmatter key
|
|
4529
|
-
// must still resolve against the body). `field` a literal flat key (even
|
|
4530
|
-
// one containing a `.`) is tried before it is split and walked as a
|
|
4531
|
-
// dotted path (test matrix row 26) — `resolveFrontmatterPath` pins that
|
|
4532
|
-
// same order for the frontmatter side alone.
|
|
4533
|
-
//
|
|
4534
|
-
// `Current Position` is special-cased: it names the WHOLE `## Current
|
|
4535
|
-
// Position` section, not a single `Label: value` line, so
|
|
4536
|
-
// `stateExtractField` can never resolve it (this is the root cause of the
|
|
4537
|
-
// "Current Position undercount" — a transform can correctly push
|
|
4538
|
-
// `'Current Position'` into its own `updated` list, and this function
|
|
4539
|
-
// still silently dropped it, because `valueOf` returned `null` for BOTH
|
|
4540
|
-
// sides and `null === null` failed the old `intended !== null` guard).
|
|
4541
|
-
// `sliceCurrentPositionSection` is the existing fence-aware section
|
|
4542
|
-
// locator (state-transition.cts) — reused rather than re-derived.
|
|
4543
|
-
const valueOf = (fm, body, field) => {
|
|
4544
|
-
if (field === 'Current Position') {
|
|
4545
|
-
const section = stateTransitionMod.sliceCurrentPositionSection(body);
|
|
4546
|
-
return section !== null ? section.trim() : null;
|
|
4547
|
-
}
|
|
4548
|
-
const bodyValue = (0, state_document_cjs_1.stateExtractField)(body, field);
|
|
4549
|
-
if (bodyValue !== null)
|
|
4550
|
-
return bodyValue;
|
|
4551
|
-
if (Object.prototype.hasOwnProperty.call(fm, field))
|
|
4552
|
-
return String(fm[field]);
|
|
4553
|
-
if (field.includes('.')) {
|
|
4554
|
-
const resolved = resolveFrontmatterPath(fm, field);
|
|
4555
|
-
if (resolved !== STATE_FIELD_ABSENT) {
|
|
4556
|
-
return stateScalarString(resolved);
|
|
4557
|
-
}
|
|
4558
|
-
}
|
|
4559
|
-
return null;
|
|
4560
|
-
};
|
|
4561
|
-
// A field in `reported` can itself be a declared derived leaf (e.g.
|
|
4562
|
-
// `plannedPhaseCore` pushing `'progress.total_plans'` — state-
|
|
4563
|
-
// transition.cts:1752). `valueOf`'s null-vs-string convention cannot tell
|
|
4564
|
-
// "absent from the frontmatter" apart from "resolved to the literal string
|
|
4565
|
-
// 'null'/''", so it cannot carry the same materialization rule
|
|
4566
|
-
// `computeChangedFrontmatterFields` applies below. Route these fields
|
|
4567
|
-
// through the SAME primitives (`resolveFrontmatterPath` + the
|
|
4568
|
-
// `STATE_FIELD_ABSENT` sentinel + `stateFieldValuesDiffer`) instead of a
|
|
4569
|
-
// second, parallel absence convention — one rule, reused, not duplicated.
|
|
4570
|
-
const isDeclaredDerivedLeaf = (candidate) => candidate.includes('.') && Object.prototype.hasOwnProperty.call(FIELD_CLASSIFICATION, candidate);
|
|
4571
|
-
const changed = (field) => {
|
|
4572
|
-
if (isDeclaredDerivedLeaf(field)) {
|
|
4573
|
-
const before = resolveFrontmatterPath(snapshotFm, field);
|
|
4574
|
-
const after = resolveFrontmatterPath(persistedFm, field);
|
|
4575
|
-
// Same generalized provenance rule as computeChangedFrontmatterFields:
|
|
4576
|
-
// absent-in-snapshot-materializing-in-persisted is the disk scan
|
|
4577
|
-
// catching a never-synced document up, not this write's own action.
|
|
4578
|
-
if (before === STATE_FIELD_ABSENT && after !== STATE_FIELD_ABSENT)
|
|
4579
|
-
return false;
|
|
4580
|
-
return stateFieldValuesDiffer(before, after);
|
|
4581
|
-
}
|
|
4582
|
-
const before = valueOf(snapshotFm, snapshotBody, field);
|
|
4583
|
-
const after = valueOf(persistedFm, persistedBody, field);
|
|
4584
|
-
if (before === null && after === null)
|
|
4585
|
-
return false;
|
|
4586
|
-
if (before === null || after === null)
|
|
4587
|
-
return true;
|
|
4588
|
-
return before.trim() !== after.trim();
|
|
4589
|
-
};
|
|
4627
|
+
const changed = (field) => stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody, field, sectionTargets);
|
|
4590
4628
|
// Candidate set = `reported` ∪ every frontmatter key (dotted-leaf
|
|
4591
4629
|
// granularity) whose persisted value differs from the snapshot, minus the
|
|
4592
4630
|
// provenance exclusion. A frontmatter-diff-discovered field is mapped
|
|
@@ -4614,6 +4652,361 @@ function reconcileReportedFields(statePath, preWriteState, reported, divergedFie
|
|
|
4614
4652
|
}
|
|
4615
4653
|
return reconciled;
|
|
4616
4654
|
}
|
|
4655
|
+
/** Why a verified write failed. A closed set, frozen like `ERROR_REASON`. */
|
|
4656
|
+
const STATE_WRITE_INTENT_FAILURE = Object.freeze({
|
|
4657
|
+
/** §8.3: the delta reached a region outside the declared scope. */
|
|
4658
|
+
OUT_OF_SCOPE: 'out_of_scope',
|
|
4659
|
+
/** §8.2: a `required` assertion did not land. */
|
|
4660
|
+
REQUIRED_ASSERTION_MISSED: 'required_assertion_missed',
|
|
4661
|
+
/** §8.2: the re-read file is not the content that was verified and written. */
|
|
4662
|
+
REREAD_MISMATCH: 'reread_mismatch',
|
|
4663
|
+
});
|
|
4664
|
+
/**
|
|
4665
|
+
* Split a STATE.md body into leaf units for the §8.3 diff: the preamble before
|
|
4666
|
+
* the first heading, then one unit per heading running to the NEXT heading of
|
|
4667
|
+
* any level, in document order. Leaf granularity means a declared
|
|
4668
|
+
* `## Accumulated Context` scope reaches its `### Decisions` child by heading
|
|
4669
|
+
* level (`stateSectionScopeUnits`), while an undeclared sibling section stays a separate unit. Built on
|
|
4670
|
+
* the markdown-sectionizer seam (ADR-1372), which is fence-aware, so a `#` line
|
|
4671
|
+
* inside a code fence is not a heading. A repeated heading gets an occurrence
|
|
4672
|
+
* suffix so neither shadows the other.
|
|
4673
|
+
*/
|
|
4674
|
+
function collectStateBodyUnits(body) {
|
|
4675
|
+
const units = new Map();
|
|
4676
|
+
const sections = (0, markdown_sectionizer_cjs_1.collectSections)(body, () => true);
|
|
4677
|
+
const preambleEnd = sections.length > 0 ? sections[0].heading.offset : body.length;
|
|
4678
|
+
units.set('(preamble)', { text: body.slice(0, preambleEnd).trim(), level: 0, region: { region: 'preamble', name: '' } });
|
|
4679
|
+
const occurrences = new Map();
|
|
4680
|
+
for (const section of sections) {
|
|
4681
|
+
const { heading } = section;
|
|
4682
|
+
const base = `${'#'.repeat(heading.level)} ${heading.text}`;
|
|
4683
|
+
const n = (occurrences.get(base) ?? 0) + 1;
|
|
4684
|
+
occurrences.set(base, n);
|
|
4685
|
+
units.set(n === 1 ? base : `${base} (#${n})`, {
|
|
4686
|
+
text: section.body.trim(),
|
|
4687
|
+
level: heading.level,
|
|
4688
|
+
region: { region: 'section', name: heading.text },
|
|
4689
|
+
});
|
|
4690
|
+
}
|
|
4691
|
+
return units;
|
|
4692
|
+
}
|
|
4693
|
+
/** The raw frontmatter region (everything before the body), for the unparseable-block case. */
|
|
4694
|
+
function stateFrontmatterRegion(content) {
|
|
4695
|
+
return content.slice(0, content.length - stripFrontmatter(content).length);
|
|
4696
|
+
}
|
|
4697
|
+
/**
|
|
4698
|
+
* The unit a declared field lives in: the first unit, in document order, where
|
|
4699
|
+
* `stateExtractField` resolves it. That mirrors `stateExtractField`'s own
|
|
4700
|
+
* first-match reading of the whole body, so a declared `Status` covers the one
|
|
4701
|
+
* line the seam treats as `Status`, and not a second `Status:` line elsewhere.
|
|
4702
|
+
*/
|
|
4703
|
+
function stateFieldOwnerUnit(units, field) {
|
|
4704
|
+
for (const [key, unit] of units) {
|
|
4705
|
+
if ((0, state_document_cjs_1.stateExtractField)(unit.text, field) !== null)
|
|
4706
|
+
return key;
|
|
4707
|
+
}
|
|
4708
|
+
return null;
|
|
4709
|
+
}
|
|
4710
|
+
/**
|
|
4711
|
+
* The units a declared section covers: the FIRST unit, in document order, whose
|
|
4712
|
+
* heading text is the declared name (the occurrence `readStateTarget` reads,
|
|
4713
|
+
* through `collectSection`'s first match), plus every following unit nested
|
|
4714
|
+
* under it by heading level. The section analogue of `stateFieldOwnerUnit`: a
|
|
4715
|
+
* repeated heading of the same name, and that occurrence's children, are not
|
|
4716
|
+
* covered, even though they sit under a heading with the same text.
|
|
4717
|
+
*/
|
|
4718
|
+
function stateSectionScopeUnits(units, sectionTargets) {
|
|
4719
|
+
const covered = new Set();
|
|
4720
|
+
const entries = [...units];
|
|
4721
|
+
for (const name of sectionTargets) {
|
|
4722
|
+
const start = entries.findIndex(([, u]) => u.region.region === 'section' && u.region.name === name);
|
|
4723
|
+
if (start < 0)
|
|
4724
|
+
continue;
|
|
4725
|
+
const ownerLevel = entries[start][1].level;
|
|
4726
|
+
covered.add(entries[start][0]);
|
|
4727
|
+
for (let i = start + 1; i < entries.length && entries[i][1].level > ownerLevel; i++)
|
|
4728
|
+
covered.add(entries[i][0]);
|
|
4729
|
+
}
|
|
4730
|
+
return covered;
|
|
4731
|
+
}
|
|
4732
|
+
/**
|
|
4733
|
+
* ADR-4629 §8.2 + §8.3: verify one write against its declared intent. PURE —
|
|
4734
|
+
* it compares two document sides and never touches disk.
|
|
4735
|
+
*
|
|
4736
|
+
* Both sides are first passed through `normalizeContent`, the OS projection
|
|
4737
|
+
* seam's own normalization (CRLF strip, blank-line-run collapse, one trailing
|
|
4738
|
+
* newline). `platformWriteSync` applies it to every STATE.md write, on this
|
|
4739
|
+
* path and the legacy one alike, so it is the seam's behavior, not the
|
|
4740
|
+
* intent's delta. Without it, a CRLF file would read as every section changed.
|
|
4741
|
+
*
|
|
4742
|
+
* §8.2 (verified post-state): every `required` assertion must land. With a
|
|
4743
|
+
* declared `value`, "landed" means the target reads that value afterwards (an
|
|
4744
|
+
* idempotent write that re-asserts the current value passes). Without one,
|
|
4745
|
+
* "landed" means the target is still present AND measurably changed
|
|
4746
|
+
* (`stateTargetChanged`, the measured-delta test `updated[]` uses); a deleted
|
|
4747
|
+
* target has not landed. Best-effort assertions are NOT judged: their report
|
|
4748
|
+
* bucket is ADR-4629 §8.4, an open question for Phase 3, so this result
|
|
4749
|
+
* deliberately carries no best-effort field for a later phase to be bound by.
|
|
4750
|
+
*
|
|
4751
|
+
* §8.3 (bounded mutation): the delta must stay inside the declared scope.
|
|
4752
|
+
* - Body: diffed per leaf unit (`collectStateBodyUnits`). A changed unit is in
|
|
4753
|
+
* scope when it is the first occurrence of a section a declared
|
|
4754
|
+
* `target: 'section'` assertion names, or nested under that occurrence
|
|
4755
|
+
* (`stateSectionScopeUnits`). Otherwise it is in scope only when replaying the
|
|
4756
|
+
* post-values of the declared fields THAT UNIT OWNS (`stateFieldOwnerUnit`)
|
|
4757
|
+
* onto its pre text reproduces its post text, so any other edit in the same
|
|
4758
|
+
* section, or the same label in another section, is caught. A change in
|
|
4759
|
+
* section ORDER is its own out-of-scope region.
|
|
4760
|
+
* - Frontmatter: keys with a body source (`getFrontmatterBodySource`) are
|
|
4761
|
+
* judged through that source, because `syncStateFrontmatter` re-derives them
|
|
4762
|
+
* from the body on every write. When the seam's `bodyDeltas` show the source
|
|
4763
|
+
* moved, the body check above already bounded that move. A mirrored key that
|
|
4764
|
+
* changed with no body move, or with no `bodyDeltas` to show one, must be
|
|
4765
|
+
* declared. Every other key goes through `computeChangedFrontmatterFields`
|
|
4766
|
+
* (the reporter's rule: `last_updated` provenance exclusion, declared-leaf
|
|
4767
|
+
* materialization). A schema-declared key (a `FIELD_CLASSIFICATION` row)
|
|
4768
|
+
* that is absent before and present after is the seam catching a document
|
|
4769
|
+
* up, not the caller's action, the same provenance rule the reporter
|
|
4770
|
+
* applies. A key already present that the seam re-derives to a new value
|
|
4771
|
+
* (for example `progress.*` under `resync`) IS counted: §8.3 bounds what the
|
|
4772
|
+
* write changed, whoever computed it.
|
|
4773
|
+
* - A frontmatter key is declared when an assertion names it exactly, or when
|
|
4774
|
+
* it is one of the schema-declared leaves (`declaredLeavesOf`) of a key an
|
|
4775
|
+
* assertion names. Declaring `progress` therefore covers the closed set of
|
|
4776
|
+
* `progress.*` rows `FIELD_CLASSIFICATION` lists, the frontmatter analogue of
|
|
4777
|
+
* a `target: 'section'` assertion covering its child sections. Nothing else
|
|
4778
|
+
* widens: a declared leaf (`progress.percent`) never covers a sibling leaf,
|
|
4779
|
+
* and a dotted key outside the schema's leaf set is never covered by its
|
|
4780
|
+
* prefix.
|
|
4781
|
+
* - `scope: 'broad'` puts the WHOLE frontmatter in scope. That is ADR-4629
|
|
4782
|
+
* §8.3's own worked case (`milestoneSwitch` declares a whole-frontmatter
|
|
4783
|
+
* scope). The body stays bounded: broad is audited, not exempted.
|
|
4784
|
+
* - `kind: 'rebuild'` (`cmdStateSync`, `REGENERATE_STATE`) is unbounded by
|
|
4785
|
+
* contract (ADR-3408 §8.3; ADR-4629 §8.3 names those two and no more), so
|
|
4786
|
+
* §8.3 is skipped for it. §8.2 still applies.
|
|
4787
|
+
*
|
|
4788
|
+
* `Current Position` is always read as a whole section, in §8.2 and §8.3 alike,
|
|
4789
|
+
* matching `readStateTarget`'s long-standing special case.
|
|
4790
|
+
*/
|
|
4791
|
+
function verifyStateWriteIntent(intent, preContent, postContent, statePath, bodyDeltas) {
|
|
4792
|
+
const pre = (0, shell_command_projection_cjs_1.normalizeContent)(statePath, preContent).content;
|
|
4793
|
+
const post = (0, shell_command_projection_cjs_1.normalizeContent)(statePath, postContent).content;
|
|
4794
|
+
const preFm = extractFrontmatter(pre, statePath);
|
|
4795
|
+
const postFm = extractFrontmatter(post, statePath);
|
|
4796
|
+
const preBody = stripFrontmatter(pre);
|
|
4797
|
+
const postBody = stripFrontmatter(post);
|
|
4798
|
+
const isSection = (a) => a.target === 'section' || a.field === 'Current Position';
|
|
4799
|
+
const sectionTargets = new Set();
|
|
4800
|
+
const fieldTargets = new Set();
|
|
4801
|
+
for (const a of intent.assertions) {
|
|
4802
|
+
if (isSection(a))
|
|
4803
|
+
sectionTargets.add(a.field);
|
|
4804
|
+
else
|
|
4805
|
+
fieldTargets.add(a.field);
|
|
4806
|
+
}
|
|
4807
|
+
// §8.2 — required assertions.
|
|
4808
|
+
const missedRequired = [];
|
|
4809
|
+
for (const a of intent.assertions) {
|
|
4810
|
+
if (a.requirement !== 'required')
|
|
4811
|
+
continue;
|
|
4812
|
+
const targets = isSection(a) ? new Set([a.field]) : NO_SECTION_TARGETS;
|
|
4813
|
+
const actual = readStateTarget(postFm, postBody, a.field, targets);
|
|
4814
|
+
const landed = a.value !== undefined
|
|
4815
|
+
? actual !== null && actual.trim() === a.value.trim()
|
|
4816
|
+
: actual !== null && stateTargetChanged(preFm, preBody, postFm, postBody, a.field, targets);
|
|
4817
|
+
if (!landed)
|
|
4818
|
+
missedRequired.push(a.field);
|
|
4819
|
+
}
|
|
4820
|
+
// §8.3 — bounded mutation.
|
|
4821
|
+
const outOfScope = [];
|
|
4822
|
+
if (intent.kind !== 'rebuild') {
|
|
4823
|
+
if (intent.scope !== 'broad') {
|
|
4824
|
+
const declared = (key) => {
|
|
4825
|
+
if (fieldTargets.has(key))
|
|
4826
|
+
return true;
|
|
4827
|
+
const dot = key.indexOf('.');
|
|
4828
|
+
if (dot <= 0)
|
|
4829
|
+
return false;
|
|
4830
|
+
const parent = key.slice(0, dot);
|
|
4831
|
+
return fieldTargets.has(parent) && declaredLeavesOf(parent).includes(key);
|
|
4832
|
+
};
|
|
4833
|
+
if (isUnparseableFrontmatter(preFm) || isUnparseableFrontmatter(postFm)) {
|
|
4834
|
+
// An unparseable block has no keys to diff; judge the raw region.
|
|
4835
|
+
if (stateFrontmatterRegion(pre) !== stateFrontmatterRegion(post)) {
|
|
4836
|
+
outOfScope.push({ region: 'frontmatter', name: '(unparseable block)' });
|
|
4837
|
+
}
|
|
4838
|
+
}
|
|
4839
|
+
else {
|
|
4840
|
+
const schemaMaterialized = (key) => Object.prototype.hasOwnProperty.call(FIELD_CLASSIFICATION, key)
|
|
4841
|
+
&& resolveFrontmatterPath(preFm, key) === STATE_FIELD_ABSENT
|
|
4842
|
+
&& resolveFrontmatterPath(postFm, key) !== STATE_FIELD_ABSENT;
|
|
4843
|
+
// Non-body-sourced keys: `computeChangedFrontmatterFields` skips body-sourced
|
|
4844
|
+
// keys entirely when handed no `bodyDeltas`; those are judged just below.
|
|
4845
|
+
for (const key of computeChangedFrontmatterFields(preFm, postFm, undefined)) {
|
|
4846
|
+
if (!declared(key) && !schemaMaterialized(key))
|
|
4847
|
+
outOfScope.push({ region: 'frontmatter', name: key });
|
|
4848
|
+
}
|
|
4849
|
+
const topKeys = new Set([...Object.keys(preFm), ...Object.keys(postFm)]);
|
|
4850
|
+
for (const key of topKeys) {
|
|
4851
|
+
if (stateTransitionMod.getFrontmatterBodySource(key) === null)
|
|
4852
|
+
continue;
|
|
4853
|
+
const before = resolveFrontmatterPath(preFm, key);
|
|
4854
|
+
const after = resolveFrontmatterPath(postFm, key);
|
|
4855
|
+
if (!stateFieldValuesDiffer(before, after))
|
|
4856
|
+
continue;
|
|
4857
|
+
if (before === STATE_FIELD_ABSENT)
|
|
4858
|
+
continue; // back-fill (the #1264 case), not the caller's action
|
|
4859
|
+
const delta = bodyDeltas ? bodyDeltas[key] : undefined;
|
|
4860
|
+
const bodyMoved = delta !== undefined
|
|
4861
|
+
&& stateFieldValuesDiffer(delta.pre ?? STATE_FIELD_ABSENT, delta.post ?? STATE_FIELD_ABSENT);
|
|
4862
|
+
if (bodyMoved || declared(key))
|
|
4863
|
+
continue;
|
|
4864
|
+
outOfScope.push({ region: 'frontmatter', name: key });
|
|
4865
|
+
}
|
|
4866
|
+
}
|
|
4867
|
+
}
|
|
4868
|
+
const preUnits = collectStateBodyUnits(preBody);
|
|
4869
|
+
const postUnits = collectStateBodyUnits(postBody);
|
|
4870
|
+
const owners = new Map();
|
|
4871
|
+
for (const field of fieldTargets) {
|
|
4872
|
+
const owner = stateFieldOwnerUnit(postUnits, field) ?? stateFieldOwnerUnit(preUnits, field);
|
|
4873
|
+
if (owner !== null)
|
|
4874
|
+
owners.set(owner, [...(owners.get(owner) ?? []), field]);
|
|
4875
|
+
}
|
|
4876
|
+
const sectionScope = new Set([
|
|
4877
|
+
...stateSectionScopeUnits(preUnits, sectionTargets),
|
|
4878
|
+
...stateSectionScopeUnits(postUnits, sectionTargets),
|
|
4879
|
+
]);
|
|
4880
|
+
for (const key of new Set([...preUnits.keys(), ...postUnits.keys()])) {
|
|
4881
|
+
const before = preUnits.get(key);
|
|
4882
|
+
const after = postUnits.get(key);
|
|
4883
|
+
if (before !== undefined && after !== undefined && before.text === after.text)
|
|
4884
|
+
continue;
|
|
4885
|
+
const unit = (after ?? before);
|
|
4886
|
+
if (sectionScope.has(key))
|
|
4887
|
+
continue;
|
|
4888
|
+
if (before !== undefined && after !== undefined) {
|
|
4889
|
+
let replayed = before.text;
|
|
4890
|
+
for (const field of owners.get(key) ?? []) {
|
|
4891
|
+
const next = (0, state_document_cjs_1.stateExtractField)(after.text, field);
|
|
4892
|
+
if (next === null)
|
|
4893
|
+
continue;
|
|
4894
|
+
const applied = (0, state_document_cjs_1.stateReplaceField)(replayed, field, next);
|
|
4895
|
+
if (applied !== null)
|
|
4896
|
+
replayed = applied;
|
|
4897
|
+
}
|
|
4898
|
+
if (replayed.trim() === after.text)
|
|
4899
|
+
continue;
|
|
4900
|
+
}
|
|
4901
|
+
outOfScope.push(unit.region);
|
|
4902
|
+
}
|
|
4903
|
+
const preOrder = [...preUnits.keys()].filter((k) => postUnits.has(k));
|
|
4904
|
+
const postOrder = [...postUnits.keys()].filter((k) => preUnits.has(k));
|
|
4905
|
+
if (preOrder.join('\n') !== postOrder.join('\n'))
|
|
4906
|
+
outOfScope.push({ region: 'order', name: '' });
|
|
4907
|
+
}
|
|
4908
|
+
const reasons = [];
|
|
4909
|
+
if (outOfScope.length > 0)
|
|
4910
|
+
reasons.push(STATE_WRITE_INTENT_FAILURE.OUT_OF_SCOPE);
|
|
4911
|
+
if (missedRequired.length > 0)
|
|
4912
|
+
reasons.push(STATE_WRITE_INTENT_FAILURE.REQUIRED_ASSERTION_MISSED);
|
|
4913
|
+
return Object.freeze({
|
|
4914
|
+
ok: reasons.length === 0,
|
|
4915
|
+
reasons: Object.freeze(reasons),
|
|
4916
|
+
outOfScope: Object.freeze(outOfScope.map((r) => Object.freeze({ ...r }))),
|
|
4917
|
+
missedRequired: Object.freeze(missedRequired),
|
|
4918
|
+
});
|
|
4919
|
+
}
|
|
4920
|
+
const NO_UPDATED_FIELDS = Object.freeze([]);
|
|
4921
|
+
/**
|
|
4922
|
+
* ADR-4629 §8.2 + §8.3: apply a declared `StateWriteIntent` through the one
|
|
4923
|
+
* write seam and fail loud instead of reporting success.
|
|
4924
|
+
*
|
|
4925
|
+
* Under ONE lock (the same `acquireStateLock` `readModifyWriteStateMd` holds):
|
|
4926
|
+
* read -> transform -> `syncAndPreserveStateMd` (the owned composition,
|
|
4927
|
+
* driven by the intent's own `resync` / `deriveProgressKeys` /
|
|
4928
|
+
* `explicitProgressField`) -> verify the would-be content -> write only if
|
|
4929
|
+
* it verified -> re-read -> confirm the re-read file is exactly the verified
|
|
4930
|
+
* content (§8.2 verifies against the re-read file) -> report `updated[]`
|
|
4931
|
+
* from the measured disk delta.
|
|
4932
|
+
* A §8.2/§8.3 violation is refused BEFORE the write, so the file is left
|
|
4933
|
+
* untouched. The re-read check is the one failure found AFTER the write: if
|
|
4934
|
+
* the re-read does not match what was verified, the result is
|
|
4935
|
+
* `reasons: ['reread_mismatch']` with `written: true`. The file is NOT restored
|
|
4936
|
+
* on that arm: the write reached disk but is not verified, and the result says
|
|
4937
|
+
* so rather than attempting a restore through a write path that would itself
|
|
4938
|
+
* normalize the bytes. Its `outOfScope` / `missedRequired` are empty, because
|
|
4939
|
+
* the candidate content itself verified; what failed is the disk round-trip.
|
|
4940
|
+
*
|
|
4941
|
+
* "Loud" means the result, not an exception: a verification failure is a
|
|
4942
|
+
* frozen `ok: false` value naming each reason, never a success payload.
|
|
4943
|
+
* Turning that into a non-zero exit is the CLI caller's job, which lands with
|
|
4944
|
+
* the first migrated caller (C3+). Construction errors (no intent, a rebuild
|
|
4945
|
+
* intent) throw, like `createStateTransaction`'s.
|
|
4946
|
+
*
|
|
4947
|
+
* A transform whose output is byte-identical to its input is the #948 no-op:
|
|
4948
|
+
* nothing is written, but the intent is still verified, so a `required`
|
|
4949
|
+
* assertion that expected a change reports as missed rather than passing.
|
|
4950
|
+
*
|
|
4951
|
+
* Scope fence (C2): open-kind intents only. The two `rebuild` writers stay on
|
|
4952
|
+
* `writeStateMd`, the sanctioned exception path (ADR-3408 §8.3). The intent's
|
|
4953
|
+
* `snapshot` is not compared against the file read under the lock;
|
|
4954
|
+
* optimistic concurrency on the prior value is outside ADR-4629 §8 as written.
|
|
4955
|
+
*/
|
|
4956
|
+
function applyStateWriteIntent(statePath, intent, transformFn, cwd, options = {}, clock) {
|
|
4957
|
+
// Checked on an `unknown` view: `Array.isArray` on the typed ReadonlyArray would narrow it to `any[]`.
|
|
4958
|
+
if (intent === null || typeof intent !== 'object' || !Array.isArray(intent.assertions)) {
|
|
4959
|
+
const err = new Error('applyStateWriteIntent: a StateWriteIntent is required (build it with createStateWriteIntent). ' +
|
|
4960
|
+
'Per ADR-4629 §8.1, an absent intent is a construction failure, not an unverified write.');
|
|
4961
|
+
err.code = 'STATE_WRITE_INTENT_REQUIRED';
|
|
4962
|
+
throw err;
|
|
4963
|
+
}
|
|
4964
|
+
if (intent.kind === 'rebuild') {
|
|
4965
|
+
const err = new Error('applyStateWriteIntent: a rebuild intent cannot travel the preservation seam. The two rebuild ' +
|
|
4966
|
+
'writers (cmdStateSync, REGENERATE_STATE) stay on writeStateMd per ADR-3408 §8.3; ADR-4629 §8.3 ' +
|
|
4967
|
+
'treats them as unbounded by contract.');
|
|
4968
|
+
err.code = 'STATE_TRANSACTION_KIND_INVALID';
|
|
4969
|
+
throw err;
|
|
4970
|
+
}
|
|
4971
|
+
const lockPath = acquireStateLock(statePath, clock);
|
|
4972
|
+
try {
|
|
4973
|
+
const pre = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
|
|
4974
|
+
const transformed = transformFn(pre);
|
|
4975
|
+
const unwritten = (verification) => Object.freeze({ ...verification, written: false, updated: NO_UPDATED_FIELDS });
|
|
4976
|
+
if (transformed === pre) {
|
|
4977
|
+
return unwritten(verifyStateWriteIntent(intent, pre, pre, statePath));
|
|
4978
|
+
}
|
|
4979
|
+
const preWriteState = {};
|
|
4980
|
+
const candidate = syncAndPreserveStateMd(pre, transformed, statePath, cwd, {
|
|
4981
|
+
resync: intent.resync,
|
|
4982
|
+
deriveProgressKeys: intent.deriveProgressKeys,
|
|
4983
|
+
explicitProgressField: intent.explicitProgressField,
|
|
4984
|
+
authoritativeFm: options.authoritativeFm,
|
|
4985
|
+
preWriteState,
|
|
4986
|
+
});
|
|
4987
|
+
const verification = verifyStateWriteIntent(intent, pre, candidate, statePath, preWriteState.bodyDeltas);
|
|
4988
|
+
if (!verification.ok)
|
|
4989
|
+
return unwritten(verification);
|
|
4990
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(statePath, candidate);
|
|
4991
|
+
const reread = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
|
|
4992
|
+
if (reread !== (0, shell_command_projection_cjs_1.normalizeContent)(statePath, candidate).content) {
|
|
4993
|
+
return Object.freeze({
|
|
4994
|
+
ok: false,
|
|
4995
|
+
reasons: Object.freeze([STATE_WRITE_INTENT_FAILURE.REREAD_MISMATCH]),
|
|
4996
|
+
outOfScope: verification.outOfScope,
|
|
4997
|
+
missedRequired: verification.missedRequired,
|
|
4998
|
+
written: true,
|
|
4999
|
+
updated: NO_UPDATED_FIELDS,
|
|
5000
|
+
});
|
|
5001
|
+
}
|
|
5002
|
+
const sectionTargets = new Set(intent.assertions.filter((a) => a.target === 'section' || a.field === 'Current Position').map((a) => a.field));
|
|
5003
|
+
const updated = reconcileReportedFields(statePath, preWriteState, intent.assertions.map((a) => a.field), [], sectionTargets);
|
|
5004
|
+
return Object.freeze({ ...verification, written: true, updated: Object.freeze(updated) });
|
|
5005
|
+
}
|
|
5006
|
+
finally {
|
|
5007
|
+
releaseStateLock(lockPath);
|
|
5008
|
+
}
|
|
5009
|
+
}
|
|
4617
5010
|
function cmdStateJson(cwd, raw) {
|
|
4618
5011
|
const statePath = planningPaths(cwd).state;
|
|
4619
5012
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
@@ -4715,6 +5108,23 @@ function cmdStateJson(cwd, raw) {
|
|
|
4715
5108
|
preferNewerLastActivity(existingFm, built);
|
|
4716
5109
|
output(built, raw, JSON.stringify(built, null, 2));
|
|
4717
5110
|
}
|
|
5111
|
+
/**
|
|
5112
|
+
* 1-based position of the first plan with no summary, when SOME of the phase's plans already have
|
|
5113
|
+
* one (a gap-closure re-entry: plans 1..17 are done and 18 and 19 are the gap plans, #4945).
|
|
5114
|
+
* `null` when no plan is summarized yet, when every plan is, or when the phase directory cannot be
|
|
5115
|
+
* found: the phase then starts at plan 1, exactly as before. Plans and summaries are paired by the
|
|
5116
|
+
* phase locator's own rule, so this agrees with `init` and `phase-plan-index`.
|
|
5117
|
+
*/
|
|
5118
|
+
function firstPlanToRun(cwd, phaseNumber) {
|
|
5119
|
+
const located = phaseLocatorMod.findPhaseInternal(cwd, String(phaseNumber));
|
|
5120
|
+
if (!located || !located.found)
|
|
5121
|
+
return null;
|
|
5122
|
+
const { plans, incomplete_plans: incomplete } = located;
|
|
5123
|
+
if (incomplete.length === 0 || incomplete.length === plans.length)
|
|
5124
|
+
return null;
|
|
5125
|
+
const index = plans.indexOf(incomplete[0]);
|
|
5126
|
+
return index === -1 ? null : index + 1;
|
|
5127
|
+
}
|
|
4718
5128
|
/**
|
|
4719
5129
|
* Update STATE.md when a new phase begins execution.
|
|
4720
5130
|
* Updates body text fields (Current focus, Status, Last Activity, Current Position)
|
|
@@ -4762,6 +5172,8 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
4762
5172
|
phaseNumber: phaseNumber,
|
|
4763
5173
|
phaseName: phaseName ?? null,
|
|
4764
5174
|
planCount: planCount ?? null,
|
|
5175
|
+
// #4945: a gap-closure re-entry starts at the first plan still to run, not at plan 1.
|
|
5176
|
+
startPlan: firstPlanToRun(cwd, phaseNumber),
|
|
4765
5177
|
};
|
|
4766
5178
|
const deps = {
|
|
4767
5179
|
clock: clock_cjs_1.realClock,
|
|
@@ -6348,6 +6760,12 @@ module.exports = {
|
|
|
6348
6760
|
// pinning the table it reads (`_FRONTMATTER_KEY_TO_BODY_LABEL`) against
|
|
6349
6761
|
// itself.
|
|
6350
6762
|
_bodyLabelFor: bodyLabelFor,
|
|
6763
|
+
// ADR-4629 §8.2/§8.3 (epic #4629, C2 #4866): the verifying executor and its
|
|
6764
|
+
// pure verifier. No production caller constructs a StateWriteIntent yet (C3+),
|
|
6765
|
+
// so these are reached only by their positive-control tests.
|
|
6766
|
+
applyStateWriteIntent,
|
|
6767
|
+
_verifyStateWriteIntent: verifyStateWriteIntent,
|
|
6768
|
+
STATE_WRITE_INTENT_FAILURE,
|
|
6351
6769
|
// Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated
|
|
6352
6770
|
// steal decision is exercised without real pids. Mirrors capability-lock.cts.
|
|
6353
6771
|
_setLockProbes(probes) {
|