@opengsd/gsd-core 1.9.0 → 1.10.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 +2 -3
- package/.opencode/plugins/gsd-core.js +8 -1
- package/agents/gsd-code-fixer.md +131 -34
- package/agents/gsd-debugger.md +12 -246
- package/agents/gsd-executor.md +7 -5
- package/agents/gsd-integration-checker.md +3 -0
- package/agents/gsd-plan-checker.md +9 -0
- package/agents/gsd-planner.md +5 -8
- package/agents/gsd-roadmapper.md +21 -3
- package/agents/gsd-verifier.md +14 -70
- package/bin/install.js +503 -341
- package/commands/gsd/mempalace-capture.md +1 -1
- package/commands/gsd/new-milestone.md +1 -1
- package/commands/gsd/plan-phase.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +607 -63
- package/gsd-core/bin/lib/active-workstream-store.cjs +25 -0
- package/gsd-core/bin/lib/agent-install-check.cjs +38 -6
- package/gsd-core/bin/lib/api-coverage.cjs +120 -0
- package/gsd-core/bin/lib/audit.cjs +89 -1
- package/gsd-core/bin/lib/broken-windows.cjs +36 -6
- package/gsd-core/bin/lib/capability-registry.cjs +96 -110
- package/gsd-core/bin/lib/capability-validator.cjs +12 -2
- package/gsd-core/bin/lib/check-command-router.cjs +43 -1
- package/gsd-core/bin/lib/command-aliases.cjs +72 -0
- package/gsd-core/bin/lib/commands.cjs +26 -25
- package/gsd-core/bin/lib/commonjs-marker.cjs +136 -0
- package/gsd-core/bin/lib/config-loader.cjs +1 -0
- package/gsd-core/bin/lib/config.cjs +12 -1
- package/gsd-core/bin/lib/context-composer.cjs +278 -0
- package/gsd-core/bin/lib/context-predicates.cjs +506 -0
- package/gsd-core/bin/lib/core-utils.cjs +91 -12
- package/gsd-core/bin/lib/docs.cjs +3 -2
- package/gsd-core/bin/lib/external-job.cjs +19 -4
- package/gsd-core/bin/lib/frontmatter.cjs +84 -12
- package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
- package/gsd-core/bin/lib/git-base-branch.cjs +58 -15
- package/gsd-core/bin/lib/graphify.cjs +142 -27
- package/gsd-core/bin/lib/gsd2-import.cjs +27 -4
- package/gsd-core/bin/lib/host-integration.cjs +13 -1
- package/gsd-core/bin/lib/init-command-router.cjs +83 -8
- package/gsd-core/bin/lib/init.cjs +1021 -57
- package/gsd-core/bin/lib/install-engine.cjs +64 -10
- package/gsd-core/bin/lib/install-profiles.cjs +27 -1
- package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
- package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
- package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
- package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
- package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +87 -1
- package/gsd-core/bin/lib/io.cjs +28 -3
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +6 -0
- package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
- package/gsd-core/bin/lib/mcp-server.cjs +135 -3
- package/gsd-core/bin/lib/milestone.cjs +106 -51
- package/gsd-core/bin/lib/phase-id.cjs +63 -0
- package/gsd-core/bin/lib/phase-locator.cjs +138 -45
- package/gsd-core/bin/lib/phase.cjs +260 -25
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +232 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +4 -0
- package/gsd-core/bin/lib/project-root.cjs +48 -0
- package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
- package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +80 -0
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +99 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +30 -6
- package/gsd-core/bin/lib/roadmap-command-router.cjs +42 -9
- package/gsd-core/bin/lib/roadmap-parser.cjs +100 -18
- package/gsd-core/bin/lib/roadmap.cjs +37 -7
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +195 -62
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +15 -3
- package/gsd-core/bin/lib/runtime-homes.cjs +154 -41
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +105 -41
- package/gsd-core/bin/lib/section-manifest.cjs +209 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +113 -27
- package/gsd-core/bin/lib/smart-entry.cjs +12 -0
- package/gsd-core/bin/lib/state-transition.cjs +73 -8
- package/gsd-core/bin/lib/state.cjs +151 -62
- package/gsd-core/bin/lib/surface.cjs +12 -1
- package/gsd-core/bin/lib/uat-predicate.cjs +11 -1
- package/gsd-core/bin/lib/uat.cjs +320 -21
- package/gsd-core/bin/lib/unusable-input.cjs +9 -0
- package/gsd-core/bin/lib/verification.cjs +29 -12
- package/gsd-core/bin/lib/verify.cjs +29 -5
- package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +181 -18
- package/gsd-core/bin/lib/workstream-inventory.cjs +519 -27
- package/gsd-core/bin/lib/workstream.cjs +6 -0
- package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
- package/gsd-core/bin/lib/worktree-safety.cjs +276 -118
- package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
- package/gsd-core/references/artifact-types.md +10 -3
- package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
- package/gsd-core/references/debugger-techniques.md +255 -0
- package/gsd-core/references/research-documentation-lookup.md +5 -3
- package/gsd-core/references/specless-probe-fallback.md +7 -6
- package/gsd-core/references/verifier-wiring-patterns.md +100 -0
- package/gsd-core/references/worktree-branch-check.md +2 -2
- package/gsd-core/templates/summary-complex.md +2 -0
- package/gsd-core/templates/summary-minimal.md +2 -0
- package/gsd-core/templates/summary-standard.md +2 -0
- package/gsd-core/templates/summary.md +2 -0
- package/gsd-core/workflows/audit-milestone.md +3 -0
- package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
- package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
- package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
- package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
- package/gsd-core/workflows/autonomous.md +32 -69
- package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +83 -0
- package/gsd-core/workflows/code-review.md +42 -145
- package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
- package/gsd-core/workflows/complete-milestone.md +23 -81
- package/gsd-core/workflows/debug.md +9 -12
- package/gsd-core/workflows/diagnose-issues.md +22 -0
- package/gsd-core/workflows/discovery-phase.md +4 -4
- package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
- package/gsd-core/workflows/discuss-phase-assumptions.md +5 -16
- package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
- package/gsd-core/workflows/docs-update.md +8 -51
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +34 -2
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +19 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
- package/gsd-core/workflows/execute-phase.md +65 -137
- package/gsd-core/workflows/execute-plan.md +1 -1
- package/gsd-core/workflows/help/modes/full.md +6 -1
- package/gsd-core/workflows/ingest-docs.md +2 -1
- package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
- package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
- package/gsd-core/workflows/new-milestone.md +21 -38
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
- package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
- package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
- package/gsd-core/workflows/new-project.md +13 -226
- package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
- package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
- package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
- package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
- package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
- package/gsd-core/workflows/plan-phase.md +49 -193
- package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
- package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
- package/gsd-core/workflows/progress.md +11 -153
- package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
- package/gsd-core/workflows/quick/steps/quick-verification.md +46 -0
- package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
- package/gsd-core/workflows/quick.md +20 -390
- package/gsd-core/workflows/resume-project.md +3 -0
- package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
- package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
- package/gsd-core/workflows/review.md +15 -8
- package/gsd-core/workflows/section-manifest.json +219 -0
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/spec-phase.md +17 -14
- package/gsd-core/workflows/spike-wrap-up.md +20 -5
- package/gsd-core/workflows/spike.md +50 -16
- package/gsd-core/workflows/sync-skills.md +49 -11
- package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
- package/gsd-core/workflows/transition.md +8 -21
- package/gsd-core/workflows/ui-phase.md +8 -7
- package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
- package/gsd-core/workflows/update.md +18 -7
- package/gsd-core/workflows/verify-phase.md +4 -7
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
- package/gsd-core/workflows/verify-work.md +8 -58
- package/hooks/dist/gsd-agent-isolation-guard.js +428 -0
- package/hooks/dist/gsd-check-update-worker.js +14 -5
- package/hooks/dist/gsd-cursor-subagent-start.js +532 -26
- package/hooks/dist/gsd-read-injection-scanner.js +7 -0
- package/hooks/dist/gsd-statusline.js +72 -6
- package/hooks/dist/gsd-worktree-path-guard.js +2 -1
- package/hooks/dist/gsd-write-guard.js +359 -0
- package/hooks/dist/lib/isolation-sentinel.js +268 -0
- package/hooks/dist/managed-hooks-registry.cjs +2 -0
- package/hooks/gsd-agent-isolation-guard.js +428 -0
- package/hooks/gsd-check-update-worker.js +14 -5
- package/hooks/gsd-cursor-subagent-start.js +532 -26
- package/hooks/gsd-read-injection-scanner.js +7 -0
- package/hooks/gsd-statusline.js +72 -6
- package/hooks/gsd-worktree-path-guard.js +2 -1
- package/hooks/gsd-write-guard.js +359 -0
- package/hooks/hooks.json +12 -0
- package/hooks/lib/isolation-sentinel.js +268 -0
- package/hooks/managed-hooks-registry.cjs +2 -0
- package/package.json +14 -5
- package/pi/gsd.cjs +57 -12
- package/scripts/build-hooks.js +9 -0
- package/scripts/changeset/lint.cjs +9 -2
- package/scripts/changeset/serialize.cjs +5 -1
- package/scripts/gen-capability-matrix.cjs +1 -1
- package/scripts/gen-context-index.cjs +448 -0
- package/scripts/gen-inventory-manifest.cjs +101 -1
- package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
- package/scripts/gen-registry.cjs +39 -15
- package/scripts/gen-section-manifest.cjs +638 -0
- package/scripts/generate-package-identity.cjs +4 -2
- package/scripts/lint-allow-test-rule-refs.allowlist.json +17 -31
- package/scripts/lint-compiled-artifact-sync.cjs +6 -1
- package/scripts/lint-docs-command-form.cjs +195 -0
- package/scripts/lint-docs-required.cjs +9 -1
- package/scripts/lint-emitted-drift-ack.cjs +215 -20
- package/scripts/lint-example-parser-parity.cjs +395 -0
- package/scripts/lint-test-file-count.allowlist.json +27 -1
- package/scripts/mutation-matrix.cjs +13 -0
- package/scripts/prompt-injection-scan.sh +27 -6
- package/scripts/registry-schema.cjs +323 -94
- package/scripts/run-tests.cjs +3 -2
- package/scripts/validate-registry.cjs +10 -6
- package/skills/gsd-autonomous/SKILL.md +1 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-plan-phase/SKILL.md +2 -2
- package/vscode/package.json +1 -1
- package/scripts/gen-emitted-baseline.cjs +0 -145
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Section Manifest — pure `when=` evaluator over `InvocationFacts`, mapping
|
|
4
|
+
* a document-order list of parsed `<!-- gsd:section -->` sections (Phase 3,
|
|
5
|
+
* `src/workflow-fragments.cts`) to an included/excluded partition for one
|
|
6
|
+
* concrete invocation (ADR-1671 epic #1671, Phase 5 / issue #2932,
|
|
7
|
+
* `.gsd/phase/chore-2932-init-section-manifest/40-design.md`).
|
|
8
|
+
*
|
|
9
|
+
* Pure module: no I/O, no dependency beyond node built-ins and the sibling
|
|
10
|
+
* compiled module `workflow-fragments.cjs`, whose {@link
|
|
11
|
+
* workflowFragments.WHEN_VOCABULARY} is imported and never redeclared here
|
|
12
|
+
* (DEFECT.GENERATIVE-FIX — a second frozen copy of the same 4 strings would
|
|
13
|
+
* silently desync from the source of truth the moment either side is edited
|
|
14
|
+
* without the other).
|
|
15
|
+
*
|
|
16
|
+
* ## The evaluator is a LOOKUP, not a parser
|
|
17
|
+
*
|
|
18
|
+
* Derived from Greenspun's Tenth Rule (ADR-1671:69 cites it by name) and
|
|
19
|
+
* binding on this implementation: `when=` is a closed vocabulary, widened
|
|
20
|
+
* from 4 to 14 entries via the ADR-1671 amendment for #2992 (epic #1671
|
|
21
|
+
* Phase 6.1; see `.gsd/phase/chore-2992-widen-when-vocabulary/
|
|
22
|
+
* 40-design.md`), then from 14 to 19 via the ADR-1671 amendment for #2993
|
|
23
|
+
* (epic #1671 Phase 6.2; see `.gsd/phase/chore-2993-fragmentize-plan-phase/
|
|
24
|
+
* 40-design.md`), then from 19 to 20 via the ADR-1671 amendment for #2994
|
|
25
|
+
* (epic #1671 Phase 6.3), then from 20 to 23 via a further #2994 amendment
|
|
26
|
+
* fragmentizing `code-review.md` and `complete-milestone.md`, then from 23
|
|
27
|
+
* to 24 via a still further #2994 amendment fragmentizing `autonomous.md`,
|
|
28
|
+
* then from 24 to 26 via a still further #2994 amendment fragmentizing
|
|
29
|
+
* `review.md` and `discuss-phase-assumptions.md`, then from 26 to 30 — and
|
|
30
|
+
* finally to 29, `flag:--full` having been retired as dead vocabulary — via the
|
|
31
|
+
* final #2994 slice fragmentizing `docs-update.md`, `update.md`,
|
|
32
|
+
* `transition.md`, and `new-milestone.md`.
|
|
33
|
+
* {@link WHEN_PREDICATES} is a total map from each frozen
|
|
34
|
+
* vocabulary entry to exactly one predicate over {@link InvocationFacts}.
|
|
35
|
+
* It MUST NOT tokenize, split on operators, or interpret structure in the
|
|
36
|
+
* `when=` string — the moment it parses, the ad-hoc language has begun.
|
|
37
|
+
* Every entry below is therefore a HAND-WRITTEN LITERAL: deriving a
|
|
38
|
+
* predicate's flag/state name from its atom string (e.g. slicing `--fix`
|
|
39
|
+
* out of `'flag:--fix'`) is tokenization relocated into this map and is
|
|
40
|
+
* forbidden even though it would be shorter — the redundancy between each
|
|
41
|
+
* key and its literal token is deliberate, and the bidirectional parity
|
|
42
|
+
* test below catches any desync a hand-written entry could introduce. An
|
|
43
|
+
* unrecognized `when=` value fails closed via {@link selectSections}
|
|
44
|
+
* throwing a `TypeError` carrying `.reason = REASON.UNKNOWN_WHEN`; it is
|
|
45
|
+
* never silently excluded (Postel's Law: liberal on FORMAT elsewhere in the
|
|
46
|
+
* pipeline, strict on this SEMANTIC boundary — matching the discipline
|
|
47
|
+
* Phase 3 already established for the same vocabulary at parse time).
|
|
48
|
+
*
|
|
49
|
+
* ## Totality over facts
|
|
50
|
+
*
|
|
51
|
+
* Every predicate treats an absent/missing fact key as falsy WITHOUT
|
|
52
|
+
* throwing — {@link InvocationFacts} is a plain data object handed in by a
|
|
53
|
+
* caller (the init CLI seam) that may not always populate every field, and
|
|
54
|
+
* this module must never surprise that caller with an exception for an
|
|
55
|
+
* omission rather than a malformed `when=` value.
|
|
56
|
+
*
|
|
57
|
+
* ## Partition invariant
|
|
58
|
+
*
|
|
59
|
+
* {@link selectSections} returns `included` and `excluded` id arrays that
|
|
60
|
+
* together contain every input section's `id` exactly once, in the SAME
|
|
61
|
+
* relative document order they appeared in the input — never mutating the
|
|
62
|
+
* input array or its elements.
|
|
63
|
+
*
|
|
64
|
+
* ADR-457 build-at-publish: compiled by tsc to
|
|
65
|
+
* gsd-core/bin/lib/section-manifest.cjs (gitignored).
|
|
66
|
+
*/
|
|
67
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
68
|
+
exports.WHEN_PREDICATES = exports.REASON = void 0;
|
|
69
|
+
exports.selectSections = selectSections;
|
|
70
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- workflow-fragments.cjs is a CommonJS module compiled from a sibling .cts source; `import x = require()` reads its module.exports namespace directly.
|
|
71
|
+
const workflowFragments = require("./workflow-fragments.cjs");
|
|
72
|
+
/**
|
|
73
|
+
* Frozen, stable reason codes for every `fail()` throw site in this module.
|
|
74
|
+
* Tests assert via `assert.equal(err.reason, REASON.X)` rather than
|
|
75
|
+
* regex-/substring-matching the human-readable message (CONTRIBUTING.md
|
|
76
|
+
* "Prohibited: Raw Text Matching on Test Outputs"; shape copied from
|
|
77
|
+
* `src/workflow-fragments.cts`'s own `REASON` export) — a message reword
|
|
78
|
+
* must never silently pass a test that exists to catch a behavior
|
|
79
|
+
* regression.
|
|
80
|
+
*
|
|
81
|
+
* Adding a new reason requires updating this map AND the test that locks
|
|
82
|
+
* `Object.keys(REASON).sort()` as a coordinated change.
|
|
83
|
+
*/
|
|
84
|
+
exports.REASON = Object.freeze({
|
|
85
|
+
UNKNOWN_WHEN: 'unknown_when',
|
|
86
|
+
});
|
|
87
|
+
/**
|
|
88
|
+
* Throws a `TypeError` naming the offending `when` value, carrying `reason`
|
|
89
|
+
* (one of {@link REASON}) as a typed property so callers/tests never need
|
|
90
|
+
* to pattern-match the message prose.
|
|
91
|
+
*/
|
|
92
|
+
function fail(reason, message) {
|
|
93
|
+
const err = new TypeError(`section-manifest: ${message}`);
|
|
94
|
+
err.reason = reason;
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Safely tests whether `facts.flags` contains `flag`, tolerating an absent,
|
|
99
|
+
* `null`, or non-`Set` (e.g. array) `flags` value without throwing —
|
|
100
|
+
* `.has` is checked to be callable before it is called, rather than
|
|
101
|
+
* assuming every {@link InvocationFacts.flags} is a real `Set` (totality
|
|
102
|
+
* over facts; not duck-typed — an array `flags` degrades to "not present",
|
|
103
|
+
* it is never iterated or `.includes`-checked).
|
|
104
|
+
*/
|
|
105
|
+
function hasFlag(facts, flag) {
|
|
106
|
+
return typeof facts.flags?.has === 'function' && facts.flags.has(flag) === true;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Total map from each frozen {@link workflowFragments.WHEN_VOCABULARY}
|
|
110
|
+
* entry to exactly one predicate over {@link InvocationFacts}. This is a
|
|
111
|
+
* LOOKUP, never a parser — see the module doc comment's "The evaluator is a
|
|
112
|
+
* LOOKUP, not a parser" section. Every entry is a hand-written literal; see
|
|
113
|
+
* the module doc comment for why deriving a predicate from its atom string
|
|
114
|
+
* is forbidden. Semantics confirmed against the section bodies themselves
|
|
115
|
+
* (design doc "Semantics confirmed against the section bodies themselves,
|
|
116
|
+
* not inferred from the id"):
|
|
117
|
+
*
|
|
118
|
+
* - `gap-closure-artifacts` — "For decimal/polish phases only (X.Y
|
|
119
|
+
* pattern) … Skip if phase number has no decimal" -> `state:gap-closure-phase`.
|
|
120
|
+
* - `regression-gate` — "Skip if: this is the first phase (no prior
|
|
121
|
+
* phases)" -> `state:has-prior-phases`.
|
|
122
|
+
* - `partial-wave` — "If `WAVE_FILTER` was used" -> `flag:--wave`.
|
|
123
|
+
*/
|
|
124
|
+
exports.WHEN_PREDICATES = Object.freeze(Object.assign(Object.create(null), {
|
|
125
|
+
always: () => true,
|
|
126
|
+
'flag:--wave': (facts) => hasFlag(facts, '--wave'),
|
|
127
|
+
'state:gap-closure-phase': (facts) => typeof facts.phaseNumber === 'string' && facts.phaseNumber.includes('.'),
|
|
128
|
+
'state:has-prior-phases': (facts) => facts.hasPriorPhases === true,
|
|
129
|
+
'flag:--auto': (facts) => hasFlag(facts, '--auto'),
|
|
130
|
+
'flag:--discuss': (facts) => hasFlag(facts, '--discuss'),
|
|
131
|
+
'flag:--fix': (facts) => hasFlag(facts, '--fix'),
|
|
132
|
+
'flag:--forensic': (facts) => hasFlag(facts, '--forensic'),
|
|
133
|
+
'flag:--ingest': (facts) => hasFlag(facts, '--ingest'),
|
|
134
|
+
'flag:--prd': (facts) => hasFlag(facts, '--prd'),
|
|
135
|
+
'flag:--research': (facts) => hasFlag(facts, '--research'),
|
|
136
|
+
'flag:--research-phase': (facts) => hasFlag(facts, '--research-phase'),
|
|
137
|
+
'flag:--reset-phase-numbers': (facts) => hasFlag(facts, '--reset-phase-numbers'),
|
|
138
|
+
'flag:--reviews': (facts) => hasFlag(facts, '--reviews'),
|
|
139
|
+
'flag:--validate': (facts) => hasFlag(facts, '--validate'),
|
|
140
|
+
'state:auto-advance-active': (facts) => facts.autoAdvanceActive === true,
|
|
141
|
+
'state:chunked-mode': (facts) => facts.chunkedMode === true,
|
|
142
|
+
'state:fallow-enabled': (facts) => facts.fallowEnabled === true,
|
|
143
|
+
'state:flat-mode': (facts) => facts.flatMode === true,
|
|
144
|
+
'state:git-create-tag': (facts) => facts.gitCreateTag === true,
|
|
145
|
+
'state:is-monorepo': (facts) => facts.isMonorepo === true,
|
|
146
|
+
'state:needs-codebase-map': (facts) => facts.needsCodebaseMap === true,
|
|
147
|
+
'state:next-channel': (facts) => facts.nextChannel === true,
|
|
148
|
+
'state:phase-mvp-mode': (facts) => facts.phaseMvpMode === true,
|
|
149
|
+
'state:plan-strategy-converge': (facts) => facts.planStrategyConverge === true,
|
|
150
|
+
'state:reviewer-instances-configured': (facts) => facts.reviewerInstancesConfigured === true,
|
|
151
|
+
'state:ui-phase-active': (facts) => facts.uiPhaseActive === true,
|
|
152
|
+
'state:workstream-active': (facts) => facts.workstreamActive === true,
|
|
153
|
+
'state:worktrees-enabled': (facts) => facts.worktreesEnabled === true,
|
|
154
|
+
}));
|
|
155
|
+
// Coordinated-change guard, checked at module load: every entry of the
|
|
156
|
+
// frozen WHEN_VOCABULARY (imported, never redeclared — see module doc
|
|
157
|
+
// comment) must have exactly one predicate here, and vice versa. This is
|
|
158
|
+
// the load-bearing half of the DEFECT.GENERATIVE-FIX parity contract; the
|
|
159
|
+
// test-level half (50-test-matrix.md rows 21-23) additionally asserts it
|
|
160
|
+
// from the vocabulary's own export so a 5th vocabulary entry added without
|
|
161
|
+
// a predicate fails loudly rather than silently falling through to
|
|
162
|
+
// REASON.UNKNOWN_WHEN only at run time.
|
|
163
|
+
for (const when of workflowFragments.WHEN_VOCABULARY) {
|
|
164
|
+
if (!Object.hasOwn(exports.WHEN_PREDICATES, when)) {
|
|
165
|
+
throw new Error(`section-manifest: WHEN_VOCABULARY entry "${when}" has no predicate in WHEN_PREDICATES`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Reverse half of the same coordinated-change guard: every own key of
|
|
169
|
+
// WHEN_PREDICATES must also appear in WHEN_VOCABULARY. Without this, a
|
|
170
|
+
// predicate key with no vocabulary entry would let the evaluator accept an
|
|
171
|
+
// atom that the parser (classifyMarker) rejects — a real divergence between
|
|
172
|
+
// the two shared-constant halves (DEFECT.GENERATIVE-FIX; B9/B10 in
|
|
173
|
+
// `.gsd/phase/chore-2992-widen-when-vocabulary/50-test-matrix.md`).
|
|
174
|
+
const VOCABULARY_SET = new Set(workflowFragments.WHEN_VOCABULARY);
|
|
175
|
+
for (const predicateKey of Object.keys(exports.WHEN_PREDICATES)) {
|
|
176
|
+
if (!VOCABULARY_SET.has(predicateKey)) {
|
|
177
|
+
throw new Error(`section-manifest: WHEN_PREDICATES entry "${predicateKey}" has no matching WHEN_VOCABULARY entry`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Partition `sections` (document order) into `included`/`excluded` id
|
|
182
|
+
* arrays for one set of `facts`, per {@link WHEN_PREDICATES}. Exact
|
|
183
|
+
* partition: every input id appears in exactly one of the two output
|
|
184
|
+
* arrays, in the same relative order it appeared in `sections`. Never
|
|
185
|
+
* mutates `sections` or its elements.
|
|
186
|
+
*
|
|
187
|
+
* @param sections - document-order sections carrying at least `{id, when}`
|
|
188
|
+
* @param facts - the concrete invocation's resolved facts
|
|
189
|
+
* @throws {SectionManifestError} with `.reason = REASON.UNKNOWN_WHEN` when a
|
|
190
|
+
* section's `when` value has no entry in {@link WHEN_PREDICATES} (fail
|
|
191
|
+
* closed — never silently excluded).
|
|
192
|
+
*/
|
|
193
|
+
function selectSections(sections, facts) {
|
|
194
|
+
const included = [];
|
|
195
|
+
const excluded = [];
|
|
196
|
+
for (const section of sections) {
|
|
197
|
+
if (!Object.hasOwn(exports.WHEN_PREDICATES, section.when)) {
|
|
198
|
+
fail(exports.REASON.UNKNOWN_WHEN, `section "${section.id}" has unrecognized when= value "${section.when}"`);
|
|
199
|
+
}
|
|
200
|
+
const predicate = exports.WHEN_PREDICATES[section.when];
|
|
201
|
+
if (predicate(facts)) {
|
|
202
|
+
included.push(section.id);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
excluded.push(section.id);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { included, excluded };
|
|
209
|
+
}
|
|
@@ -15,6 +15,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
15
15
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
16
16
|
};
|
|
17
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.PATH_ACTION_REASON = void 0;
|
|
18
19
|
exports.toPosixPath = toPosixPath;
|
|
19
20
|
exports.toNativePath = toNativePath;
|
|
20
21
|
exports.posixNormalize = posixNormalize;
|
|
@@ -35,9 +36,11 @@ exports.projectCodexHookTomlCommand = projectCodexHookTomlCommand;
|
|
|
35
36
|
exports.escapePowerShellSingleQuoted = escapePowerShellSingleQuoted;
|
|
36
37
|
exports.escapePosixDoubleQuoted = escapePosixDoubleQuoted;
|
|
37
38
|
exports.escapeSingleQuotedShellLiteral = escapeSingleQuotedShellLiteral;
|
|
39
|
+
exports.projectPathExportLine = projectPathExportLine;
|
|
38
40
|
exports.renderShellActionLines = renderShellActionLines;
|
|
39
41
|
exports.projectPathActionProjection = projectPathActionProjection;
|
|
40
42
|
exports.projectPersistentPathExportActions = projectPersistentPathExportActions;
|
|
43
|
+
exports.isSpawnTimeout = isSpawnTimeout;
|
|
41
44
|
exports.execGit = execGit;
|
|
42
45
|
exports.execNpm = execNpm;
|
|
43
46
|
exports.execTool = execTool;
|
|
@@ -351,8 +354,34 @@ function projectLegacySettingsHookCommand({ absoluteRunner, scriptPath, scriptTo
|
|
|
351
354
|
platform,
|
|
352
355
|
});
|
|
353
356
|
}
|
|
357
|
+
// Implements the TOML v1.0.0 basic-string escaping grammar (toml.md, "Basic
|
|
358
|
+
// strings" section, https://toml.io/en/v1.0.0#string): a basic string must
|
|
359
|
+
// escape the quotation mark, backslash, and control characters other than
|
|
360
|
+
// tab (U+0000-U+0008, U+000A-U+001F, U+007F). Compact escapes are used where
|
|
361
|
+
// TOML defines them (\b \t \n \f \r \" \\); every other character in the
|
|
362
|
+
// required ranges falls back to \uXXXX. See #3118 — an earlier version
|
|
363
|
+
// escaped only backslash and quote, so a raw newline/CR/NUL in a value
|
|
364
|
+
// produced an unparseable config.toml.
|
|
365
|
+
const TOML_COMPACT_ESCAPES = {
|
|
366
|
+
'\x08': '\\b',
|
|
367
|
+
'\x09': '\\t',
|
|
368
|
+
'\x0A': '\\n',
|
|
369
|
+
'\x0C': '\\f',
|
|
370
|
+
'\x0D': '\\r',
|
|
371
|
+
};
|
|
372
|
+
// U+0000-U+0008, U+000A-U+001F, U+007F — control characters other than tab
|
|
373
|
+
// (U+0009), which the grammar permits unescaped.
|
|
374
|
+
const TOML_MUST_ESCAPE_CONTROL_CHARS = /[\x00-\x08\x0A-\x1F\x7F]/g;
|
|
354
375
|
function escapeTomlDoubleQuotedString(value) {
|
|
355
|
-
return String(value)
|
|
376
|
+
return String(value)
|
|
377
|
+
.replace(/\\/g, '\\\\')
|
|
378
|
+
.replace(/"/g, '\\"')
|
|
379
|
+
.replace(TOML_MUST_ESCAPE_CONTROL_CHARS, (ch) => {
|
|
380
|
+
const compact = TOML_COMPACT_ESCAPES[ch];
|
|
381
|
+
if (compact)
|
|
382
|
+
return compact;
|
|
383
|
+
return `\\u${ch.codePointAt(0).toString(16).padStart(4, '0')}`;
|
|
384
|
+
});
|
|
356
385
|
}
|
|
357
386
|
function projectCodexHookTomlCommand({ absoluteRunner, scriptPath, platform = process.platform }) {
|
|
358
387
|
const command = projectManagedHookCommand({
|
|
@@ -372,6 +401,27 @@ function escapePosixDoubleQuoted(value) {
|
|
|
372
401
|
function escapeSingleQuotedShellLiteral(value) {
|
|
373
402
|
return String(value).replace(/'/g, "'\\''");
|
|
374
403
|
}
|
|
404
|
+
/**
|
|
405
|
+
* The `export PATH="<dir>:$PATH"` line every persistence lane appends, plus the escaped directory
|
|
406
|
+
* token it embeds. One builder because three lanes emit this line: a lane that re-escapes it
|
|
407
|
+
* locally is how #3118 shipped a `$(…)` into ~/.bashrc, where it ran on every new shell. The
|
|
408
|
+
* escaping is for the line's FINAL context — a double-quoted string in an rc file — not for
|
|
409
|
+
* whatever transport (an `echo`, a paste) it passes through on the way there.
|
|
410
|
+
*/
|
|
411
|
+
function projectPathExportLine(targetDir) {
|
|
412
|
+
const escapedDir = escapePosixDoubleQuoted(String(targetDir));
|
|
413
|
+
return { escapedDir, line: `export PATH="${escapedDir}:$PATH"` };
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Why a PATH suggestion produced no actions. An empty `shellActions` alone folds two different
|
|
417
|
+
* facts together — "no target directory was given" and "this target directory cannot be
|
|
418
|
+
* expressed as a shell command" — and a caller that cannot tell them apart prints a header with
|
|
419
|
+
* nothing under it (#3118).
|
|
420
|
+
*/
|
|
421
|
+
exports.PATH_ACTION_REASON = Object.freeze({
|
|
422
|
+
NO_TARGET_DIR: 'no_target_dir',
|
|
423
|
+
WIN32_RESERVED_QUOTE: 'win32_reserved_quote',
|
|
424
|
+
});
|
|
375
425
|
function renderShellActionLines(shellActions = []) {
|
|
376
426
|
return shellActions.map((action) => {
|
|
377
427
|
if (!action || !action.command)
|
|
@@ -381,12 +431,18 @@ function renderShellActionLines(shellActions = []) {
|
|
|
381
431
|
}
|
|
382
432
|
function projectPathActionProjection({ mode = 'repair', targetDir, platform = process.platform, }) {
|
|
383
433
|
if (!targetDir)
|
|
384
|
-
return { shellActions: [], actionLines: [] };
|
|
434
|
+
return { shellActions: [], actionLines: [], reason: exports.PATH_ACTION_REASON.NO_TARGET_DIR };
|
|
435
|
+
// #3118: `"` is reserved on Windows, so a path containing one cannot exist — and it would close
|
|
436
|
+
// cmd's quoted region in the `powershell -Command "…"` lane below, turning the rest into cmd
|
|
437
|
+
// input. There is no correct command to suggest for an impossible path: fail closed rather than
|
|
438
|
+
// emit one whose quoting can be broken.
|
|
439
|
+
if (platform === 'win32' && String(targetDir).includes('"'))
|
|
440
|
+
return { shellActions: [], actionLines: [], reason: exports.PATH_ACTION_REASON.WIN32_RESERVED_QUOTE };
|
|
385
441
|
const isWin32 = platform === 'win32';
|
|
386
442
|
let shellActions;
|
|
387
443
|
if (isWin32) {
|
|
388
444
|
const psTargetDir = escapePowerShellSingleQuoted(targetDir);
|
|
389
|
-
const
|
|
445
|
+
const bashExportLine = escapeSingleQuotedShellLiteral(projectPathExportLine(posixNormalize(String(targetDir))).line);
|
|
390
446
|
shellActions = [
|
|
391
447
|
{
|
|
392
448
|
label: 'PowerShell',
|
|
@@ -401,42 +457,51 @@ function projectPathActionProjection({ mode = 'repair', targetDir, platform = pr
|
|
|
401
457
|
{
|
|
402
458
|
label: 'Git Bash',
|
|
403
459
|
shell: 'bash',
|
|
404
|
-
command: `echo '
|
|
460
|
+
command: `echo '${bashExportLine}' >> ~/.bashrc`,
|
|
405
461
|
},
|
|
406
462
|
];
|
|
407
463
|
}
|
|
408
464
|
else if (mode === 'persist') {
|
|
409
|
-
const
|
|
465
|
+
const exportLine = escapeSingleQuotedShellLiteral(projectPathExportLine(targetDir).line);
|
|
466
|
+
const fishTargetDir = escapeSingleQuotedShellLiteral(String(targetDir));
|
|
410
467
|
shellActions = [
|
|
411
468
|
{
|
|
412
469
|
label: 'zsh',
|
|
413
470
|
shell: 'zsh',
|
|
414
|
-
command: `echo '
|
|
471
|
+
command: `echo '${exportLine}' >> ~/.zshrc`,
|
|
415
472
|
},
|
|
416
473
|
{
|
|
417
474
|
label: 'bash',
|
|
418
475
|
shell: 'bash',
|
|
419
|
-
command: `echo '
|
|
476
|
+
command: `echo '${exportLine}' >> ~/.bashrc`,
|
|
420
477
|
},
|
|
421
478
|
// #323: fish has no `export`/`$PATH`-list syntax. `fish_add_path` is the
|
|
422
479
|
// fish-native API (>= fish 3.2, 2021) that persists to the universal
|
|
423
480
|
// variable store and de-duplicates. The directory is single-quoted with
|
|
424
481
|
// the same POSIX literal escaping as the zsh/bash siblings — `'\''` is
|
|
425
482
|
// also a valid escaped single quote in fish between quote spans.
|
|
483
|
+
//
|
|
484
|
+
// #3118 review MINOR: a `targetDir` with a leading `-` (e.g. `-v`) is a
|
|
485
|
+
// legal directory name, but fish's argparse-based option scanning
|
|
486
|
+
// treats a leading-dash token as a flag REGARDLESS of quoting, so
|
|
487
|
+
// `fish_add_path '-v'` misparses it and prints "No paths to add, not
|
|
488
|
+
// setting anything." (exit 1) instead of adding the path. `--` is
|
|
489
|
+
// fish's standard end-of-options separator; verified empirically
|
|
490
|
+
// against a real fish 4.8.1 install that `fish_add_path -- '-v'`
|
|
491
|
+
// succeeds where the unseparated form fails.
|
|
426
492
|
{
|
|
427
493
|
label: 'fish',
|
|
428
494
|
shell: 'fish',
|
|
429
|
-
command: `fish_add_path '${
|
|
495
|
+
command: `fish_add_path -- '${fishTargetDir}'`,
|
|
430
496
|
},
|
|
431
497
|
];
|
|
432
498
|
}
|
|
433
499
|
else {
|
|
434
|
-
const posixTargetDir = escapePosixDoubleQuoted(targetDir);
|
|
435
500
|
shellActions = [
|
|
436
501
|
{
|
|
437
502
|
label: null,
|
|
438
503
|
shell: 'posix',
|
|
439
|
-
command:
|
|
504
|
+
command: projectPathExportLine(targetDir).line,
|
|
440
505
|
},
|
|
441
506
|
];
|
|
442
507
|
}
|
|
@@ -451,18 +516,46 @@ function projectPersistentPathExportActions({ targetDir, platform = process.plat
|
|
|
451
516
|
targetDir,
|
|
452
517
|
platform,
|
|
453
518
|
});
|
|
454
|
-
return
|
|
519
|
+
return projected.reason === undefined
|
|
520
|
+
? { shellActions: projected.shellActions }
|
|
521
|
+
: { shellActions: projected.shellActions, reason: projected.reason };
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Returns true when a spawn/exec result indicates the subprocess was killed
|
|
525
|
+
* by a timeout, i.e. it never completed and reported a real answer. This is
|
|
526
|
+
* the single shared definition of "did this subprocess time out" — worktree
|
|
527
|
+
* safety (src/worktree-safety.cts) and worktree base-ref detection
|
|
528
|
+
* (src/worktree-base-ref.cts) both call this instead of maintaining their
|
|
529
|
+
* own copies (#3050 — "Generative Fix Divergence").
|
|
530
|
+
*
|
|
531
|
+
* Only `error.code === 'ETIMEDOUT'` is checked. Node.js guarantees this
|
|
532
|
+
* cross-platform when `spawnSync`'s `timeout` option fires. The `signal ===
|
|
533
|
+
* 'SIGTERM'` check some earlier code paired with it is platform-fragile —
|
|
534
|
+
* Windows does not necessarily report SIGTERM the same way — and pairing it
|
|
535
|
+
* in as a REQUIRED conjunct risks a false NEGATIVE (a timeout that silently
|
|
536
|
+
* fails to trip the guard) on that platform. There is no false-positive risk
|
|
537
|
+
* from dropping it: an externally-delivered SIGTERM (not a timeout) leaves
|
|
538
|
+
* `error` null, so `error.code === 'ETIMEDOUT'` alone still won't match it.
|
|
539
|
+
*/
|
|
540
|
+
function isSpawnTimeout(result) {
|
|
541
|
+
return result.error?.code === 'ETIMEDOUT';
|
|
455
542
|
}
|
|
456
543
|
function _spawnResult(result, program) {
|
|
457
544
|
if (result.error && result.error.code === 'ENOENT') {
|
|
458
|
-
return { exitCode: 127, stdout: '', stderr: `${program}: not found`, signal: null, error: result.error };
|
|
545
|
+
return { exitCode: 127, stdout: '', stderr: `${program}: not found`, signal: null, error: result.error, timedOut: false };
|
|
459
546
|
}
|
|
547
|
+
const signal = result.signal ?? null;
|
|
548
|
+
const error = result.error ?? null;
|
|
460
549
|
return {
|
|
461
550
|
exitCode: result.status ?? 1,
|
|
462
551
|
stdout: (result.stdout ?? '').toString().trim(),
|
|
463
552
|
stderr: (result.stderr ?? '').toString().trim(),
|
|
464
|
-
signal
|
|
465
|
-
error
|
|
553
|
+
signal,
|
|
554
|
+
error,
|
|
555
|
+
// Reuse the single shared timeout predicate (isSpawnTimeout, below) rather
|
|
556
|
+
// than re-deriving it here — see that function's docstring for why only
|
|
557
|
+
// error.code === 'ETIMEDOUT' is checked (not signal === 'SIGTERM').
|
|
558
|
+
timedOut: isSpawnTimeout({ error }),
|
|
466
559
|
};
|
|
467
560
|
}
|
|
468
561
|
function execGit(args, opts = {}) {
|
|
@@ -549,9 +642,9 @@ function resolveGsdToolsPath() {
|
|
|
549
642
|
* NEVER throws. Degrades to `{ ok:false, ... }` on:
|
|
550
643
|
* - a missing/invalid "family" (validated locally, no subprocess spawned)
|
|
551
644
|
* - ENOENT / a missing gsd-tools.cjs (via the injectable `gsdToolsPath`)
|
|
552
|
-
* - a wall-clock timeout (`timedOut:true`,
|
|
553
|
-
*
|
|
554
|
-
*
|
|
645
|
+
* - a wall-clock timeout (`timedOut:true`, via the shared `isSpawnTimeout`
|
|
646
|
+
* predicate defined above in this file — also used by worktree-safety.cts
|
|
647
|
+
* and worktree-base-ref.cts)
|
|
555
648
|
* - any other unanticipated throw from the underlying spawn (defensive
|
|
556
649
|
* try/catch — execTool itself is spawnSync-based and does not throw).
|
|
557
650
|
*/
|
|
@@ -592,16 +685,9 @@ function dispatchGsdCommand({ family, subcommand, args = [], cwd, timeout = 30_0
|
|
|
592
685
|
timedOut: false,
|
|
593
686
|
};
|
|
594
687
|
}
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
|
|
598
|
-
// to `NodeJS.ErrnoException | null` directly (paired with optional chaining)
|
|
599
|
-
// trips a typescript-eslint no-unnecessary-type-assertion false positive for
|
|
600
|
-
// this exact narrowing shape (all of ErrnoException's extra fields over Error
|
|
601
|
-
// are optional).
|
|
602
|
-
const timedOut = result.signal === 'SIGTERM'
|
|
603
|
-
&& result.error !== null
|
|
604
|
-
&& result.error.code === 'ETIMEDOUT';
|
|
688
|
+
// Delegates to the single shared predicate defined above in this file
|
|
689
|
+
// (#3050 — "Generative Fix Divergence") instead of a local inline copy.
|
|
690
|
+
const timedOut = isSpawnTimeout(result);
|
|
605
691
|
return {
|
|
606
692
|
ok: result.exitCode === 0 && !timedOut,
|
|
607
693
|
stdout: result.stdout,
|
|
@@ -61,6 +61,9 @@ const { stateExtractField } = stateDocument;
|
|
|
61
61
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
62
62
|
const phaseId = require("./phase-id.cjs");
|
|
63
63
|
const { comparePhaseNum, extractPhaseToken, normalizePhaseName, phaseTokenMatches } = phaseId;
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
65
|
+
const unusableInput = require("./unusable-input.cjs");
|
|
66
|
+
const { warnUnusableInput, UNUSABLE_REASON } = unusableInput;
|
|
64
67
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
65
68
|
/**
|
|
66
69
|
* Staleness threshold for idle-stranded detection. A clean tree whose last
|
|
@@ -290,7 +293,16 @@ function detectSignals(cwd, now = Date.now) {
|
|
|
290
293
|
// Stale = no recorded activity for IDLE_STALE_MS. Used only by idle-stranded.
|
|
291
294
|
// Computed here (with the clock seam) so the pure classify() stays a function
|
|
292
295
|
// of (signals, staleActivity) and detectSignals owns all disk reads.
|
|
296
|
+
// #3099 (ADR-1411 amendment): if last_activity is present but unparseable,
|
|
297
|
+
// emit a diagnostic so the silent fallback (stale_activity: false) is visible.
|
|
298
|
+
// The fallback itself stays — continuity is correct, the silence was the defect.
|
|
293
299
|
const lastActivityMs = parseActivityTimestamp(lastActivityRaw);
|
|
300
|
+
if (lastActivityRaw && lastActivityMs === null) {
|
|
301
|
+
warnUnusableInput({
|
|
302
|
+
reason: UNUSABLE_REASON.LAST_ACTIVITY_UNPARSEABLE,
|
|
303
|
+
source: paths.state,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
294
306
|
const staleActivity = lastActivityMs !== null && now() - lastActivityMs > IDLE_STALE_MS;
|
|
295
307
|
// Verify-failed may be signalled either by STATE.md status or by a failed
|
|
296
308
|
// STATUS: marker on the current phase's summary/verify artifact.
|
|
@@ -111,8 +111,28 @@ function applyStatePreservation(input) {
|
|
|
111
111
|
const derived = (postFm['progress'] ?? {});
|
|
112
112
|
const merged = { ...derived };
|
|
113
113
|
if (curated) {
|
|
114
|
+
// #2440: total_plans and total_phases always take the derived value.
|
|
115
|
+
// #2969: completed_plans and completed_phases take the derived value
|
|
116
|
+
// when it is GREATER than the curated value (gap-closure plans that
|
|
117
|
+
// completed after the plan count grew) — ratcheting UP only, never
|
|
118
|
+
// deriving downward (preserves the #3242 curated-progress protection
|
|
119
|
+
// for cases unrelated to plan-count growth, e.g. a deleted SUMMARY).
|
|
120
|
+
// percent also takes the derived value — the resync recomputed it from
|
|
121
|
+
// disk counts, and a stale curated percent would be incoherent against
|
|
122
|
+
// the ratcheted-up completed counts (e.g. 54/54 at 93%).
|
|
123
|
+
const ratchetUpKeys = new Set(['completed_plans', 'completed_phases']);
|
|
114
124
|
for (const [key, value] of Object.entries(curated)) {
|
|
115
|
-
if (key
|
|
125
|
+
if (key === 'total_plans' || key === 'total_phases' || key === 'percent')
|
|
126
|
+
continue;
|
|
127
|
+
if (ratchetUpKeys.has(key)) {
|
|
128
|
+
const derivedNum = typeof derived[key] === 'number' ? derived[key] : -Infinity;
|
|
129
|
+
const curatedNum = typeof value === 'number' ? value : -Infinity;
|
|
130
|
+
// Take the derived value only when it ratchets up; else keep curated.
|
|
131
|
+
if (derivedNum > curatedNum)
|
|
132
|
+
continue;
|
|
133
|
+
merged[key] = value;
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
116
136
|
merged[key] = value;
|
|
117
137
|
}
|
|
118
138
|
}
|
|
@@ -339,7 +359,24 @@ function locateCurrentPosition(body) {
|
|
|
339
359
|
let end = body.length;
|
|
340
360
|
for (let j = idx + 1; j < hs.length; j++) {
|
|
341
361
|
if (STOP_H2_PLUS(hs[j].level)) {
|
|
342
|
-
|
|
362
|
+
// Exclude the newline that separates this section from the next
|
|
363
|
+
// heading. Walk back over a bare `\n`, then over a `\r` if one
|
|
364
|
+
// immediately precedes it (CRLF), so a CRLF document's slice does not
|
|
365
|
+
// retain a stray unpaired trailing `\r` (#3118).
|
|
366
|
+
let e = hs[j].offset;
|
|
367
|
+
if (e > 0 && body[e - 1] === '\n') {
|
|
368
|
+
e -= 1;
|
|
369
|
+
if (e > 0 && body[e - 1] === '\r')
|
|
370
|
+
e -= 1;
|
|
371
|
+
}
|
|
372
|
+
// Clamp so the span can never invert (#3118 review): when the section
|
|
373
|
+
// is empty and the next heading follows with no blank line between,
|
|
374
|
+
// walking back over the newline(s) can land `e` before `start`. An
|
|
375
|
+
// inverted span makes every mutator's `body.slice(0, start) +
|
|
376
|
+
// sectionBody + body.slice(end)` reassembly duplicate the bytes in
|
|
377
|
+
// `[end, start)`. A zero-length span (`end === start`) is the correct
|
|
378
|
+
// representation of an empty-but-present section.
|
|
379
|
+
end = Math.max(e, start);
|
|
343
380
|
break;
|
|
344
381
|
}
|
|
345
382
|
}
|
|
@@ -744,8 +781,17 @@ function completePhaseCore(content, intent, deps) {
|
|
|
744
781
|
if (derived.totalPhases !== null)
|
|
745
782
|
derivedTotalPhases = derived.totalPhases;
|
|
746
783
|
}
|
|
784
|
+
// #3057 B9: only mark 'Completed Phases' updated when the text actually
|
|
785
|
+
// changed. `stateReplaceField` returns the full (re-)substituted content
|
|
786
|
+
// whenever the field pattern matches, REGARDLESS of whether newCompleted
|
|
787
|
+
// differs from the value already in `body` — so a truthy-only check here
|
|
788
|
+
// marked the field 'updated' even when the roadmap was unavailable and
|
|
789
|
+
// newCompleted is just completedRaw parsed back to itself. That collapsed
|
|
790
|
+
// "recomputed from roadmap" and "left as-is" into the same `updated`
|
|
791
|
+
// signal. Comparing to `body` (the idiom every other field in this
|
|
792
|
+
// function already uses) restores the distinction.
|
|
747
793
|
const completedAfter = (0, state_document_cjs_1.stateReplaceField)(body, 'Completed Phases', String(newCompleted));
|
|
748
|
-
if (completedAfter) {
|
|
794
|
+
if (completedAfter !== null && completedAfter !== body) {
|
|
749
795
|
body = completedAfter;
|
|
750
796
|
updated.push('Completed Phases');
|
|
751
797
|
}
|
|
@@ -753,8 +799,9 @@ function completePhaseCore(content, intent, deps) {
|
|
|
753
799
|
const totalPhases = derivedTotalPhases || (totalRaw ? parseInt(totalRaw, 10) : null);
|
|
754
800
|
if (totalPhases && totalPhases > 0) {
|
|
755
801
|
const newPercent = (0, phase_lifecycle_cjs_1.clampPercent)(newCompleted, totalPhases);
|
|
802
|
+
// Same guard as 'Completed Phases' above, and for the same reason.
|
|
756
803
|
const progAfter = (0, state_document_cjs_1.stateReplaceField)(body, 'Progress', `${newPercent}%`);
|
|
757
|
-
if (progAfter) {
|
|
804
|
+
if (progAfter !== null && progAfter !== body) {
|
|
758
805
|
body = progAfter;
|
|
759
806
|
updated.push('Progress');
|
|
760
807
|
}
|
|
@@ -1352,13 +1399,14 @@ function truncateForLog(s) {
|
|
|
1352
1399
|
function rebuildCore(content, _intent, deps) {
|
|
1353
1400
|
const timestamp = deps.clock.nowIso();
|
|
1354
1401
|
const log = [];
|
|
1402
|
+
const phaseInventoryScan = { failed: false, reason: null };
|
|
1355
1403
|
let modified = content;
|
|
1356
1404
|
// §2 Decision: re-derive derived sections, preserve others. Order is
|
|
1357
1405
|
// oldest-section-first so log entries appear in body order.
|
|
1358
1406
|
// sourcePath threaded so `state rebuild --dry-run` names the file: that branch reads STATE.md
|
|
1359
1407
|
// directly rather than through readModifyWriteStateMd, so nothing upstream has named it yet.
|
|
1360
1408
|
modified = reconcileCurrentPosition(modified, timestamp, log, deps.sourcePath);
|
|
1361
|
-
modified = reconcileByPhaseTable(modified, deps, timestamp, log);
|
|
1409
|
+
modified = reconcileByPhaseTable(modified, deps, timestamp, log, phaseInventoryScan);
|
|
1362
1410
|
modified = stripTemplatePlaceholders(modified, timestamp, log);
|
|
1363
1411
|
modified = deduplicateSessionArchive(modified, timestamp, log);
|
|
1364
1412
|
// §3 + §4: append the audit log ONLY when mutations occurred. The
|
|
@@ -1375,6 +1423,11 @@ function rebuildCore(content, _intent, deps) {
|
|
|
1375
1423
|
mutated: log.length > 0,
|
|
1376
1424
|
mutations: log.length,
|
|
1377
1425
|
log,
|
|
1426
|
+
// #3057 B1: distinguishable from a clean "nothing to reconcile" — a
|
|
1427
|
+
// failed phase-inventory scan means the by-phase table was NOT
|
|
1428
|
+
// verified against disk, even though `mutated` may still be false.
|
|
1429
|
+
phase_inventory_scan_failed: phaseInventoryScan.failed,
|
|
1430
|
+
...(phaseInventoryScan.reason !== null ? { phase_inventory_scan_reason: phaseInventoryScan.reason } : {}),
|
|
1378
1431
|
},
|
|
1379
1432
|
};
|
|
1380
1433
|
}
|
|
@@ -1451,12 +1504,24 @@ function reconcileCurrentPosition(content, timestamp, log, sourcePath) {
|
|
|
1451
1504
|
* Leaky-Abstractions guard (ADR-1817 §1): when `phaseInventoryProvider` is
|
|
1452
1505
|
* absent (no disk scan wired), this step is a no-op. The core stays pure and
|
|
1453
1506
|
* testable without disk I/O.
|
|
1507
|
+
*
|
|
1508
|
+
* A DIFFERENT case is a scan that ran but failed (`ok:false`): that is NOT a
|
|
1509
|
+
* no-op-equivalent "nothing to reconcile" — the table is left untouched (we
|
|
1510
|
+
* have no trustworthy inventory to reconcile against) but the failure is
|
|
1511
|
+
* recorded into `meta` so the caller (`rebuildCore`) can surface it instead
|
|
1512
|
+
* of reporting a clean, fully-reconciled rebuild (#3057 B1).
|
|
1454
1513
|
*/
|
|
1455
|
-
function reconcileByPhaseTable(content, deps, timestamp, log) {
|
|
1514
|
+
function reconcileByPhaseTable(content, deps, timestamp, log, meta) {
|
|
1456
1515
|
if (!deps.phaseInventoryProvider)
|
|
1457
1516
|
return content;
|
|
1458
|
-
const
|
|
1459
|
-
if (!
|
|
1517
|
+
const result = deps.phaseInventoryProvider();
|
|
1518
|
+
if (!result.ok) {
|
|
1519
|
+
meta.failed = true;
|
|
1520
|
+
meta.reason = result.reason;
|
|
1521
|
+
return content; // cannot reconcile without a trustworthy inventory — leave the table as-is
|
|
1522
|
+
}
|
|
1523
|
+
const inventory = result.phases;
|
|
1524
|
+
if (inventory.length === 0)
|
|
1460
1525
|
return content;
|
|
1461
1526
|
// The canonical table shape (from gsd-core/templates/state.md):
|
|
1462
1527
|
// | Phase | Plans | Total | Avg/Plan |
|