@opengsd/gsd-core 1.7.0 → 1.9.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 -1
- package/.opencode/plugins/gsd-core.js +45 -1
- package/README.md +2 -0
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-codebase-mapper.md +1 -1
- package/agents/gsd-debug-session-manager.md +78 -4
- package/agents/gsd-debugger.md +87 -29
- package/agents/gsd-executor.md +49 -9
- package/agents/gsd-intel-updater.md +3 -3
- package/agents/gsd-phase-researcher.md +4 -2
- package/agents/gsd-plan-checker.md +20 -0
- package/agents/gsd-planner.md +44 -59
- package/agents/gsd-project-researcher.md +2 -2
- package/agents/gsd-ui-auditor.md +0 -40
- package/agents/gsd-verifier.md +2 -2
- package/bin/install.js +1338 -135
- package/commands/gsd/ai-integration-phase.md +1 -1
- package/commands/gsd/mempalace-capture.md +9 -5
- package/commands/gsd/new-milestone.md +1 -1
- package/commands/gsd/plan-phase.md +5 -3
- package/commands/gsd/plan-review-convergence.md +7 -2
- package/gsd-core/bin/gsd-tools.cjs +2690 -2472
- package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
- package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
- package/gsd-core/bin/lib/api-coverage.cjs +360 -53
- package/gsd-core/bin/lib/audit.cjs +8 -8
- package/gsd-core/bin/lib/broken-windows.cjs +716 -0
- package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
- package/gsd-core/bin/lib/capability-consent.cjs +40 -1
- package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
- package/gsd-core/bin/lib/capability-loader.cjs +23 -1
- package/gsd-core/bin/lib/capability-registry.cjs +1450 -160
- package/gsd-core/bin/lib/capability-trust.cjs +468 -33
- package/gsd-core/bin/lib/capability-validator.cjs +882 -6
- package/gsd-core/bin/lib/capability-writer.cjs +6 -1
- package/gsd-core/bin/lib/check-command-router.cjs +140 -27
- package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +209 -31
- package/gsd-core/bin/lib/claude-orchestration.cjs +203 -25
- package/gsd-core/bin/lib/command-aliases.cjs +14 -0
- package/gsd-core/bin/lib/commands.cjs +326 -21
- package/gsd-core/bin/lib/config-loader.cjs +214 -30
- package/gsd-core/bin/lib/config.cjs +158 -22
- package/gsd-core/bin/lib/core-utils.cjs +6 -1
- package/gsd-core/bin/lib/decisions.cjs +32 -8
- package/gsd-core/bin/lib/docs.cjs +6 -0
- package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
- package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
- package/gsd-core/bin/lib/frontmatter.cjs +125 -15
- package/gsd-core/bin/lib/gap-checker.cjs +17 -2
- package/gsd-core/bin/lib/host-integration.cjs +215 -8
- package/gsd-core/bin/lib/init.cjs +155 -66
- package/gsd-core/bin/lib/install-engine.cjs +299 -23
- package/gsd-core/bin/lib/install-profiles.cjs +239 -1
- package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
- package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +44 -5
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +107 -0
- package/gsd-core/bin/lib/milestone.cjs +248 -14
- package/gsd-core/bin/lib/model-catalog.cjs +69 -4
- package/gsd-core/bin/lib/model-resolver.cjs +189 -7
- package/gsd-core/bin/lib/observability/logger.cjs +7 -2
- package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
- package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
- package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
- package/gsd-core/bin/lib/phase-id.cjs +304 -9
- package/gsd-core/bin/lib/phase.cjs +258 -17
- package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
- package/gsd-core/bin/lib/plan-scan.cjs +70 -2
- package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
- package/gsd-core/bin/lib/profile-output.cjs +34 -8
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
- package/gsd-core/bin/lib/roadmap-parser.cjs +61 -10
- package/gsd-core/bin/lib/roadmap.cjs +23 -7
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +38 -5
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +23 -9
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +156 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
- package/gsd-core/bin/lib/smart-entry.cjs +70 -5
- package/gsd-core/bin/lib/state-document.cjs +171 -24
- package/gsd-core/bin/lib/state-transition.cjs +50 -11
- package/gsd-core/bin/lib/state.cjs +206 -32
- package/gsd-core/bin/lib/surface.cjs +51 -9
- package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
- package/gsd-core/bin/lib/uat.cjs +428 -11
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
- package/gsd-core/bin/lib/unusable-input.cjs +216 -0
- package/gsd-core/bin/lib/validate.cjs +44 -8
- package/gsd-core/bin/lib/verification.cjs +163 -31
- package/gsd-core/bin/lib/verify.cjs +348 -42
- package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +4 -15
- package/gsd-core/bin/shared/model-catalog.json +5 -0
- package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
- package/gsd-core/references/api-coverage.md +37 -7
- package/gsd-core/references/checkpoints.md +1 -1
- package/gsd-core/references/common-bug-patterns.md +13 -0
- package/gsd-core/references/context-budget.md +40 -0
- package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
- package/gsd-core/references/debugger-fix-acceptance.md +157 -0
- package/gsd-core/references/debugger-philosophy.md +1 -0
- package/gsd-core/references/debugger-prevention.md +98 -0
- package/gsd-core/references/debugger-rca-branching.md +98 -0
- package/gsd-core/references/debugger-repro-hardening.md +130 -0
- package/gsd-core/references/debugger-sbfl.md +110 -0
- package/gsd-core/references/debugger-semantic-recall.md +81 -0
- package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
- package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
- package/gsd-core/references/execute-phase-response-language.md +7 -0
- package/gsd-core/references/gate-prompts.md +6 -3
- package/gsd-core/references/model-profile-resolution.md +64 -13
- package/gsd-core/references/offer-next.md +88 -0
- package/gsd-core/references/planner-antipatterns.md +6 -0
- package/gsd-core/references/planner-mvp-mode.md +12 -13
- package/gsd-core/references/planner-preconditions.md +156 -0
- package/gsd-core/references/planner-reversibility.md +132 -0
- package/gsd-core/references/planning-config.md +2 -1
- package/gsd-core/references/reviewer-instances.md +28 -19
- package/gsd-core/references/runtime-aware-dispatch.md +42 -0
- package/gsd-core/references/skeleton-template.md +1 -1
- package/gsd-core/references/thinking-models-planning.md +3 -1
- package/gsd-core/references/ui-consideration-probe.md +2 -2
- package/gsd-core/references/worktree-branch-check.md +4 -4
- package/gsd-core/templates/DEBUG.md +5 -3
- package/gsd-core/templates/summary-minimal.md +4 -0
- package/gsd-core/templates/summary-standard.md +4 -0
- package/gsd-core/templates/summary.md +7 -0
- package/gsd-core/workflows/add-phase.md +2 -0
- package/gsd-core/workflows/add-tests.md +3 -1
- package/gsd-core/workflows/add-todo.md +32 -1
- package/gsd-core/workflows/ai-integration-phase.md +8 -6
- package/gsd-core/workflows/audit-fix.md +6 -2
- package/gsd-core/workflows/audit-milestone.md +8 -0
- package/gsd-core/workflows/autonomous.md +19 -15
- package/gsd-core/workflows/check-todos.md +5 -3
- package/gsd-core/workflows/cleanup.md +7 -1
- package/gsd-core/workflows/code-review-fix.md +14 -6
- package/gsd-core/workflows/code-review.md +93 -24
- package/gsd-core/workflows/complete-milestone.md +3 -0
- package/gsd-core/workflows/debug.md +35 -7
- package/gsd-core/workflows/diagnose-issues.md +5 -1
- package/gsd-core/workflows/discovery-phase.md +7 -0
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
- package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
- package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
- package/gsd-core/workflows/discuss-phase-assumptions.md +18 -9
- package/gsd-core/workflows/discuss-phase.md +2 -2
- package/gsd-core/workflows/do.md +7 -1
- package/gsd-core/workflows/docs-update.md +9 -0
- package/gsd-core/workflows/eval-review.md +4 -1
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
- package/gsd-core/workflows/execute-phase.md +110 -149
- package/gsd-core/workflows/execute-plan.md +20 -8
- package/gsd-core/workflows/explore.md +4 -0
- package/gsd-core/workflows/extract-learnings.md +21 -0
- package/gsd-core/workflows/graduation.md +3 -0
- package/gsd-core/workflows/health.md +7 -1
- package/gsd-core/workflows/help/modes/full.md +9 -5
- package/gsd-core/workflows/import.md +11 -2
- package/gsd-core/workflows/inbox.md +7 -0
- package/gsd-core/workflows/ingest-docs.md +19 -10
- package/gsd-core/workflows/manager.md +3 -1
- package/gsd-core/workflows/map-codebase.md +17 -10
- package/gsd-core/workflows/mvp-phase.md +3 -0
- package/gsd-core/workflows/new-milestone.md +79 -23
- package/gsd-core/workflows/new-project.md +28 -19
- package/gsd-core/workflows/new-workspace.md +3 -1
- package/gsd-core/workflows/next.md +5 -2
- package/gsd-core/workflows/onboard.md +3 -0
- package/gsd-core/workflows/plan-phase.md +56 -51
- package/gsd-core/workflows/plan-review-convergence.md +61 -12
- package/gsd-core/workflows/plant-seed.md +3 -0
- package/gsd-core/workflows/profile-user.md +7 -1
- package/gsd-core/workflows/progress.md +31 -3
- package/gsd-core/workflows/quick.md +33 -10
- package/gsd-core/workflows/remove-workspace.md +3 -0
- package/gsd-core/workflows/review.md +172 -585
- package/gsd-core/workflows/scan.md +10 -2
- package/gsd-core/workflows/secure-phase.md +13 -2
- package/gsd-core/workflows/settings-integrations.md +3 -0
- package/gsd-core/workflows/settings.md +3 -0
- package/gsd-core/workflows/ship.md +88 -11
- package/gsd-core/workflows/sketch.md +3 -0
- package/gsd-core/workflows/smart-entry.md +4 -1
- package/gsd-core/workflows/spike.md +7 -1
- package/gsd-core/workflows/ui-phase.md +11 -2
- package/gsd-core/workflows/ui-review.md +11 -1
- package/gsd-core/workflows/undo.md +7 -0
- package/gsd-core/workflows/update.md +106 -5
- package/gsd-core/workflows/validate-phase.md +13 -2
- package/gsd-core/workflows/verify-phase.md +2 -2
- package/gsd-core/workflows/verify-work.md +15 -4
- package/hooks/dist/gsd-context-monitor.js +27 -9
- package/hooks/dist/gsd-cursor-session-start.js +6 -2
- package/hooks/dist/gsd-cursor-stop.js +6 -2
- package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
- package/hooks/dist/gsd-graphify-update.sh +9 -0
- package/hooks/dist/gsd-phase-boundary.sh +14 -2
- package/hooks/dist/gsd-prompt-guard.js +101 -2
- package/hooks/dist/gsd-read-guard.js +100 -2
- package/hooks/dist/gsd-read-injection-scanner.js +109 -2
- package/hooks/dist/gsd-statusline.js +97 -9
- package/hooks/dist/gsd-workflow-guard.js +110 -6
- package/hooks/dist/gsd-worktree-path-guard.js +132 -8
- package/hooks/dist/lib/cursor-workspace.js +74 -0
- package/hooks/gsd-context-monitor.js +27 -9
- package/hooks/gsd-cursor-session-start.js +6 -2
- package/hooks/gsd-cursor-stop.js +6 -2
- package/hooks/gsd-cursor-subagent-start.js +6 -2
- package/hooks/gsd-graphify-update.sh +9 -0
- package/hooks/gsd-phase-boundary.sh +14 -2
- package/hooks/gsd-prompt-guard.js +101 -2
- package/hooks/gsd-read-guard.js +100 -2
- package/hooks/gsd-read-injection-scanner.js +109 -2
- package/hooks/gsd-statusline.js +97 -9
- package/hooks/gsd-workflow-guard.js +110 -6
- package/hooks/gsd-worktree-path-guard.js +132 -8
- package/hooks/lib/cursor-workspace.js +74 -0
- package/package.json +10 -8
- package/pi/gsd.cjs +34 -3
- package/scripts/changeset/lint.cjs +1 -0
- package/scripts/changeset/parse.cjs +26 -0
- package/scripts/check-coverage-gate.cjs +51 -0
- package/scripts/check-glossary-refs.cjs +244 -0
- package/scripts/ci-rebase-check.cjs +48 -4
- package/scripts/ci-test-scope.cjs +67 -17
- package/scripts/gen-adr-index.cjs +528 -0
- package/scripts/gen-capability-matrix.cjs +26 -2
- package/scripts/gen-capability-registry.cjs +132 -34
- package/scripts/gen-emitted-baseline.cjs +145 -0
- package/scripts/gen-test-timings.cjs +201 -0
- package/scripts/lint-compiled-artifact-sync.cjs +146 -0
- package/scripts/lint-emitted-drift-ack.cjs +149 -0
- package/scripts/lint-fix-has-regression-test.cjs +131 -0
- package/scripts/lint-portable-timeout.cjs +140 -0
- package/scripts/lint-resolution-provenance.cjs +9 -0
- package/scripts/lint-test-file-count.allowlist.json +1 -0
- package/scripts/mutation-matrix.cjs +4 -0
- package/scripts/prompt-injection-scan.sh +6 -0
- package/scripts/registry-schema.cjs +57 -8
- package/scripts/release-notes/conventional-title.cjs +19 -1
- package/scripts/release-notes/format-github-release-notes.cjs +7 -3
- package/scripts/release-tarball-smoke.cjs +18 -11
- package/scripts/run-tests.cjs +420 -58
- package/scripts/workflow-size.cjs +16 -8
- package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +9 -5
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-plan-phase/SKILL.md +5 -3
- package/skills/gsd-plan-review-convergence/SKILL.md +7 -2
- package/vscode/package.json +1 -1
- package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
- package/scripts/update-size-baseline.cjs +0 -68
|
@@ -66,8 +66,8 @@ function classifyElement(text) {
|
|
|
66
66
|
*/
|
|
67
67
|
exports.UI_TAXONOMY = [
|
|
68
68
|
{ id: 'empty', name: 'Empty / no data', elements: ['form', 'list-collection', 'media'], consideration: 'What is shown when there is no data — zero items, an unfilled form, or absent media?' },
|
|
69
|
-
{ id: 'loading', name: 'Loading / in-flight', elements: ['form', 'list-collection', 'media', 'nav'], consideration: 'What is shown while data or content is still loading (skeleton, spinner, progressive reveal)?' },
|
|
70
|
-
{ id: 'error', name: 'Error / failure', elements: ['form', 'list-collection', 'media', 'nav'], consideration: 'What is shown when the load or submit fails (message, retry affordance, partial fallback)?' },
|
|
69
|
+
{ id: 'loading', name: 'Loading / in-flight', elements: ['form', 'list-collection', 'media', 'nav', 'interactive-control'], consideration: 'What is shown while data or content is still loading (skeleton, spinner, progressive reveal)?' },
|
|
70
|
+
{ id: 'error', name: 'Error / failure', elements: ['form', 'list-collection', 'media', 'nav', 'interactive-control'], consideration: 'What is shown when the load or submit fails (message, retry affordance, partial fallback)?' },
|
|
71
71
|
{ id: 'populated', name: 'Populated / happy path', elements: ['list-collection', 'media'], consideration: 'What does the normal populated (happy-path) state look like at a typical volume of content?' },
|
|
72
72
|
{ id: 'partial', name: 'Partial / incomplete', elements: ['form', 'list-collection'], consideration: 'What is shown for partial or incomplete data — some fields or rows present, others missing?' },
|
|
73
73
|
{ id: 'overflow', name: 'Overflow / truncation', elements: ['list-collection', 'nav', 'static-content'], consideration: 'What happens when content exceeds its container — scroll, clip, wrap, or truncate?' },
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Unusable Input Diagnostic — the out-of-band half of ADR-1411's
|
|
4
|
+
* "corrupt is not absent" amendment (epic #1879).
|
|
5
|
+
*
|
|
6
|
+
* ADR-1411 splits the amendment's mechanism in two. Where a read already returns a
|
|
7
|
+
* provenance envelope, the cause is named *in-band* — that is `ConfigResolution.reason`
|
|
8
|
+
* (#1880, shipped). Where a read returns a bare sentinel or a plausible default it cannot
|
|
9
|
+
* extend, the return value is preserved exactly and the cause is surfaced *out-of-band*,
|
|
10
|
+
* as a deduplicated diagnostic on stderr. This module owns that second mechanism.
|
|
11
|
+
*
|
|
12
|
+
* It exists as a shared seam rather than a pattern copied per site because four call sites
|
|
13
|
+
* across four modules need identical behaviour (#1882 frontmatter, #1881 roadmap-parser,
|
|
14
|
+
* #1883 planning-workspace/verify, #1884 planning lock). Four hand-rolled copies of one
|
|
15
|
+
* behaviour is `DEFECT.GENERATIVE-FIX` by construction; one seam with a frozen reason set
|
|
16
|
+
* is the documented cure.
|
|
17
|
+
*
|
|
18
|
+
* Two contracts this module must not break:
|
|
19
|
+
*
|
|
20
|
+
* - **Unconditional.** ADR-1411 diverges deliberately from ADR-227's never-implemented
|
|
21
|
+
* `GSD_DEBUG` opt-in: "an opt-in nobody sets is indistinguishable from the silence
|
|
22
|
+
* #1879 is about". There is no config gate here, by design.
|
|
23
|
+
* - **Never throws.** Callers are leaf readers that promised a total function. A failed
|
|
24
|
+
* stderr write (closed stream, EPIPE) must not turn a silent degradation into a crash.
|
|
25
|
+
*/
|
|
26
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
27
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
28
|
+
};
|
|
29
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
30
|
+
// ─── Reason vocabulary ────────────────────────────────────────────────────────
|
|
31
|
+
/**
|
|
32
|
+
* Frozen so tests assert a typed surface instead of diagnostic prose
|
|
33
|
+
* (CONTRIBUTING.md — Prohibited: Raw Text Matching on Test Outputs).
|
|
34
|
+
*
|
|
35
|
+
* Adding a reason is three coordinated changes, matching the repo's `REASON`-enum
|
|
36
|
+
* convention: the entry here, the emitting call site, and the test that locks
|
|
37
|
+
* `Object.keys(UNUSABLE_REASON).sort()`. Each epic-#1879 phase adds only its own —
|
|
38
|
+
* pre-declaring the later phases' reasons would be speculative generality and would
|
|
39
|
+
* leave values no call site emits.
|
|
40
|
+
*/
|
|
41
|
+
const UNUSABLE_REASON = Object.freeze({
|
|
42
|
+
/**
|
|
43
|
+
* A file opened a `---` frontmatter fence at byte 0, carried at least one parseable
|
|
44
|
+
* key, and never closed the fence — a truncated or half-written file, NOT a file that
|
|
45
|
+
* legitimately has no frontmatter. (#1882)
|
|
46
|
+
*/
|
|
47
|
+
FRONTMATTER_UNTERMINATED: 'frontmatter_unterminated',
|
|
48
|
+
/**
|
|
49
|
+
* A ROADMAP.md exists but could not be read (EACCES/EIO/…). Distinct from a project that
|
|
50
|
+
* simply has no ROADMAP yet: absence returns the same sentinel, silently. (#1881)
|
|
51
|
+
*/
|
|
52
|
+
ROADMAP_UNREADABLE: 'roadmap_unreadable',
|
|
53
|
+
});
|
|
54
|
+
/** One human-readable clause per reason. Prose lives here, never in a test assertion. */
|
|
55
|
+
const REASON_PROSE = Object.freeze({
|
|
56
|
+
[UNUSABLE_REASON.FRONTMATTER_UNTERMINATED]: 'frontmatter opens with "---" but never closes; metadata was NOT applied',
|
|
57
|
+
[UNUSABLE_REASON.ROADMAP_UNREADABLE]: 'ROADMAP.md exists but could not be read; phase and milestone lookups fell back to defaults',
|
|
58
|
+
});
|
|
59
|
+
// ─── Dedup state ──────────────────────────────────────────────────────────────
|
|
60
|
+
/**
|
|
61
|
+
* Process-lifetime dedup set. Mirrors `config-loader.cjs`'s `_warnedUnknownConfigKeys`
|
|
62
|
+
* guard, which ADR-1411 names as the precedent to reuse.
|
|
63
|
+
*/
|
|
64
|
+
const _warnedUnusableInputs = new Set();
|
|
65
|
+
/**
|
|
66
|
+
* Count of diagnostics actually WRITTEN, which is not the same as the size of the dedup set:
|
|
67
|
+
* one emission records every key the input could later be identified by, so set size counts
|
|
68
|
+
* identities while this counts events. Tests assert on this because the behavioural claim is
|
|
69
|
+
* "how many diagnostics did the operator see", not "how many keys are interned".
|
|
70
|
+
*/
|
|
71
|
+
let _unusableInputEmissions = 0;
|
|
72
|
+
/**
|
|
73
|
+
* ASCII control characters (including NUL) are stripped from any path before it is used
|
|
74
|
+
* as a key component or written to a terminal. Two reasons, both real:
|
|
75
|
+
*
|
|
76
|
+
* - the key separator is NUL, so a `sourcePath` containing NUL could otherwise forge a
|
|
77
|
+
* collision with a different (path, reason) pair and suppress a genuine second failure;
|
|
78
|
+
* - a path carrying ANSI escapes would be replayed verbatim into the operator's terminal.
|
|
79
|
+
*/
|
|
80
|
+
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/g;
|
|
81
|
+
/**
|
|
82
|
+
* Strip control characters. Deliberately does NOT normalize path separators.
|
|
83
|
+
*
|
|
84
|
+
* An earlier revision folded backslashes to `/` unconditionally, reasoning that `C:\a\b.md`
|
|
85
|
+
* and `C:/a/b.md` are one file and should not report twice. That is true on Windows, and
|
|
86
|
+
* false — destructively — everywhere else: `\` is a legal filename character on Linux and
|
|
87
|
+
* macOS, so `/repo/weird\name/PLAN.md` and `/repo/weird/name/PLAN.md` are two genuinely
|
|
88
|
+
* different files that collapsed to one key, and the second one's diagnostic was silently
|
|
89
|
+
* swallowed. ADR-1411 forbids exactly that ("keying too coarsely suppresses a genuine second
|
|
90
|
+
* failure in a different file"), and this repo targets Linux/macOS/Windows alike.
|
|
91
|
+
*
|
|
92
|
+
* The trade is now explicit and one-directional: two spellings of one Windows path may
|
|
93
|
+
* report twice (mild noise), but two distinct files can never silence each other (lost
|
|
94
|
+
* signal). Dropping a real diagnostic is the strictly worse failure.
|
|
95
|
+
*/
|
|
96
|
+
function sanitizeSource(source) {
|
|
97
|
+
return source.replace(CONTROL_CHARS, '');
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Identify the offending input. A path is preferred because it is what an operator can act
|
|
101
|
+
* on. When the caller has only an in-memory string, fall back to a short content digest so
|
|
102
|
+
* that *different* bad inputs still produce *different* keys.
|
|
103
|
+
*
|
|
104
|
+
* The leading `p`/`d` tag is what keeps the two namespaces disjoint. Without it a caller
|
|
105
|
+
* whose file is literally named `<unnamed:8efa5269728e7271>` would key identically to a
|
|
106
|
+
* path-less caller whose content happens to hash to that digest — no brute force required,
|
|
107
|
+
* since the digest of any predictable content (a shared template, known boilerplate) can
|
|
108
|
+
* simply be computed and used as a filename to pre-seed suppression. Because control
|
|
109
|
+
* characters — including NUL — are stripped from `source`, a caller-supplied path can never
|
|
110
|
+
* contain the separator and so can never forge a key in the other namespace either.
|
|
111
|
+
*
|
|
112
|
+
* The digest is computed only on the emission path, which is rare, so it never costs
|
|
113
|
+
* anything on a healthy read.
|
|
114
|
+
*/
|
|
115
|
+
function sourceKey(source, content) {
|
|
116
|
+
if (typeof source === 'string' && source.trim() !== '') {
|
|
117
|
+
return `p\u0000${sanitizeSource(source)}`;
|
|
118
|
+
}
|
|
119
|
+
const digest = node_crypto_1.default.createHash('sha256').update(content ?? '').digest('hex').slice(0, 16);
|
|
120
|
+
return `d\u0000${digest}`;
|
|
121
|
+
}
|
|
122
|
+
/** Human-facing name for the offending input, derived from the same key. */
|
|
123
|
+
function displaySource(key) {
|
|
124
|
+
return key.startsWith('p\u0000') ? key.slice(2) : `<unnamed:${key.slice(2)}>`;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Emit a deduplicated diagnostic naming an input that exists but cannot be used.
|
|
128
|
+
*
|
|
129
|
+
* The key is `<normalized source>\0<reason>`. ADR-1411 requires the resolved path AND the
|
|
130
|
+
* distinguishing cause — keying on the path alone would let a second, different fault on
|
|
131
|
+
* the same file go unreported; keying on the message prose would couple the guard to
|
|
132
|
+
* wording.
|
|
133
|
+
*
|
|
134
|
+
* @returns `true` when this call actually wrote a diagnostic, `false` when it was
|
|
135
|
+
* deduplicated. Returning the decision is what lets tests assert emission *counts* on a
|
|
136
|
+
* typed surface rather than scraping stderr.
|
|
137
|
+
*/
|
|
138
|
+
function warnUnusableInput({ reason, source, content }) {
|
|
139
|
+
// Defensive: an unknown reason must not emit a diagnostic with `undefined` in it.
|
|
140
|
+
const prose = Object.prototype.hasOwnProperty.call(REASON_PROSE, reason)
|
|
141
|
+
? REASON_PROSE[reason]
|
|
142
|
+
: null;
|
|
143
|
+
if (prose === null)
|
|
144
|
+
return false;
|
|
145
|
+
// The guarantee, stated precisely, because it is not symmetric:
|
|
146
|
+
//
|
|
147
|
+
// * a file reported BY NAME is reported at most once, and
|
|
148
|
+
// * an anonymous re-parse of content already reported by name stays silent, and
|
|
149
|
+
// * two DIFFERENT files always both report, even when their truncated bytes are identical.
|
|
150
|
+
//
|
|
151
|
+
// The asymmetry is the anonymous-FIRST ordering (a path-less parse, then a named parse of the
|
|
152
|
+
// same content), which emits twice. That is a deliberate limit, not an oversight. A path-less
|
|
153
|
+
// caller cannot identify its file, so suppressing the later named report would also suppress a
|
|
154
|
+
// genuine second failure in a DIFFERENT file whenever two files share byte-identical truncated
|
|
155
|
+
// content — the over-coarse keying ADR-1411 explicitly forbids. Between a duplicate line and a
|
|
156
|
+
// swallowed diagnostic the ADR ranks the swallow worse, so the duplicate is accepted; and the
|
|
157
|
+
// second line is the more useful of the two, because it carries the filename.
|
|
158
|
+
//
|
|
159
|
+
// Mechanically: check ONLY the key matching what this caller actually knows, but record every
|
|
160
|
+
// key the input could later be identified by.
|
|
161
|
+
const identity = sourceKey(source, content);
|
|
162
|
+
const keys = [`${identity}\u0000${reason}`];
|
|
163
|
+
if (typeof content === 'string' && identity.startsWith('p\u0000')) {
|
|
164
|
+
keys.push(`${sourceKey(undefined, content)}\u0000${reason}`);
|
|
165
|
+
}
|
|
166
|
+
if (_warnedUnusableInputs.has(keys[0]))
|
|
167
|
+
return false;
|
|
168
|
+
for (const k of keys)
|
|
169
|
+
_warnedUnusableInputs.add(k);
|
|
170
|
+
try {
|
|
171
|
+
process.stderr.write(`gsd: warning — ${displaySource(identity)}: ${prose}. (#1879)\n`);
|
|
172
|
+
// Counted only after a write that actually completed. Incrementing before the try counted
|
|
173
|
+
// attempts, so on a broken stderr the counter claimed a diagnostic had reached the operator
|
|
174
|
+
// when nothing had — a seam documented as "written" reporting something else.
|
|
175
|
+
_unusableInputEmissions += 1;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* a closed or broken stderr must never escalate a degraded read into a crash */
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
// ─── Test seams ───────────────────────────────────────────────────────────────
|
|
183
|
+
/**
|
|
184
|
+
* Clear the dedup state between cases.
|
|
185
|
+
*
|
|
186
|
+
* This exists because the set is process-global: without it, the second test to use a key
|
|
187
|
+
* silently observes the first test's suppression. #2674 is the cautionary precedent — a
|
|
188
|
+
* reset helper that cleared two of three sets was a silent no-op for the very suite that
|
|
189
|
+
* existed to test it, and the cases only passed because each happened to pick a key no
|
|
190
|
+
* other case reused.
|
|
191
|
+
*/
|
|
192
|
+
function _resetUnusableInputWarningsForTests() {
|
|
193
|
+
_warnedUnusableInputs.clear();
|
|
194
|
+
_unusableInputEmissions = 0;
|
|
195
|
+
}
|
|
196
|
+
/** Number of diagnostics written — the typed surface tests assert on instead of stderr prose. */
|
|
197
|
+
function _unusableInputEmissionCountForTests() {
|
|
198
|
+
return _unusableInputEmissions;
|
|
199
|
+
}
|
|
200
|
+
/** Size of the dedup set (identities interned, not events). Retained for key-shape assertions. */
|
|
201
|
+
function _unusableInputWarningCountForTests() {
|
|
202
|
+
return _warnedUnusableInputs.size;
|
|
203
|
+
}
|
|
204
|
+
/** Test seam: the sanitized form of a source, so control-char handling is asserted on a
|
|
205
|
+
* returned value instead of by scraping what reached stderr. */
|
|
206
|
+
function _sanitizeSourceForTests(source) {
|
|
207
|
+
return sanitizeSource(source);
|
|
208
|
+
}
|
|
209
|
+
module.exports = {
|
|
210
|
+
UNUSABLE_REASON,
|
|
211
|
+
_sanitizeSourceForTests,
|
|
212
|
+
warnUnusableInput,
|
|
213
|
+
_resetUnusableInputWarningsForTests,
|
|
214
|
+
_unusableInputWarningCountForTests,
|
|
215
|
+
_unusableInputEmissionCountForTests,
|
|
216
|
+
};
|
|
@@ -37,30 +37,35 @@ exports.canonicalPlanStem = canonicalPlanStem;
|
|
|
37
37
|
exports.phaseVariants = phaseVariants;
|
|
38
38
|
exports.buildRoadmapPhaseVariants = buildRoadmapPhaseVariants;
|
|
39
39
|
exports.buildNotStartedPhaseVariants = buildNotStartedPhaseVariants;
|
|
40
|
+
exports.textEncodingError = textEncodingError;
|
|
40
41
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
41
42
|
const phaseIdMod = require("./phase-id.cjs");
|
|
42
|
-
const { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
|
|
43
|
+
const { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, PHASE_CONTINUATION_SEGMENT_SOURCE, } = phaseIdMod;
|
|
43
44
|
// ── Issue #26: regex constants (W005, W006-archived) ────────────────────────
|
|
44
45
|
// Matches legacy numeric dirs (01-setup), milestone-prefixed dirs (02-01-setup),
|
|
45
46
|
// deep dirs (02-04-01-deep), and project-code-prefixed variants (GSD-02-01-setup).
|
|
46
47
|
exports.phaseDirNameRe = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}\\d{2,}(?:-\\d+)*(?:\\.\\d+)*-[\\w-]+$`, 'i');
|
|
47
48
|
// Extracts the full phase token from a directory name, including milestone-prefixed
|
|
48
49
|
// multi-segment tokens like "02-01" from "02-01-setup" or "GSD-02-01-setup".
|
|
49
|
-
// #2043: a *continuation* sub-phase segment must be zero-padded
|
|
50
|
+
// #2043: a *continuation* sub-phase segment must be zero-padded, so a
|
|
50
51
|
// single-digit slug word after a phase number (e.g. "46-6-rs-…", slug "6 Rs …") is
|
|
51
|
-
// NOT absorbed — it captures "46", not "46-6".
|
|
52
|
+
// NOT absorbed — it captures "46", not "46-6". #2232: the continuation width is
|
|
53
|
+
// exactly 2 (PHASE_CONTINUATION_SEGMENT_SOURCE), so a ≥3-digit slug word (a year:
|
|
54
|
+
// "14-2026-photos-…") is not absorbed either — it captures "14", not "14-2026".
|
|
55
|
+
// The first component stays "\d+"
|
|
52
56
|
// (with the "[A-Z]?" suffix) so single-digit letter-suffixed phase ids ("1A") and
|
|
53
57
|
// milestone-prefixed single-digit sub-phases ("M1-2" → prefix "M1-" stripped, then
|
|
54
58
|
// "2") still match. The trailing boundary "(?:-|$)" (was "(?:-[a-z]|$)") lets a slug
|
|
55
59
|
// that starts with a digit terminate the token.
|
|
56
|
-
exports.PHASE_TOKEN_FROM_DIR_RE = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+(
|
|
60
|
+
exports.PHASE_TOKEN_FROM_DIR_RE = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+(?:-${PHASE_CONTINUATION_SEGMENT_SOURCE})*[A-Z]?(?:\\.\\d+)*)(?:-|$)`, 'i');
|
|
57
61
|
exports.MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i;
|
|
58
62
|
// ── Issue #26: I001 canonicalization ────────────────────────────────────────
|
|
59
63
|
function canonicalPlanStem(stem) {
|
|
60
|
-
// #2043: the plan component (after the phase number) must be zero-padded
|
|
61
|
-
//
|
|
62
|
-
// for a "46-6" phase/plan pair.
|
|
63
|
-
|
|
64
|
+
// #2043: the plan component (after the phase number) must be zero-padded,
|
|
65
|
+
// so a digit-leading slug word (e.g. "46-6-rs-…") is not mistaken
|
|
66
|
+
// for a "46-6" phase/plan pair. #2232: exactly 2 digits, so a year-leading
|
|
67
|
+
// slug ("14-2026-photos-…") is not mistaken for a "14-2026" pair either.
|
|
68
|
+
const m = stem.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE}-${PHASE_CONTINUATION_SEGMENT_SOURCE})`, 'i'));
|
|
64
69
|
return m ? m[1] : stem;
|
|
65
70
|
}
|
|
66
71
|
// ── Issue #6: phase variant helpers (W006/W007) ──────────────────────────────
|
|
@@ -133,3 +138,34 @@ function buildNotStartedPhaseVariants(roadmapContent) {
|
|
|
133
138
|
}
|
|
134
139
|
return notStartedPhases;
|
|
135
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Detect binary corruption (embedded NUL bytes) in a text artifact's bytes.
|
|
143
|
+
*
|
|
144
|
+
* #2701: the plan/summary/verification/state validators must FAIL LOUD on a
|
|
145
|
+
* NUL-corrupted file instead of reporting `valid: true`. A NUL byte is the
|
|
146
|
+
* unambiguous signal — UTF-8 text never contains 0x00 — and a file carrying one
|
|
147
|
+
* is binary-classified by `file(1)`, then silently OMITTED from recursive /
|
|
148
|
+
* binary-skipping search results (`rg -l`, `grep -rI`, exit 0), so the corruption
|
|
149
|
+
* reads downstream as "file absent" rather than "file corrupt." The error message
|
|
150
|
+
* names that consequence so the next investigator is not misdirected.
|
|
151
|
+
*
|
|
152
|
+
* This is a pure, opt-in check called explicitly by each validator at its own
|
|
153
|
+
* entry point. It is deliberately NOT placed inside the shared `platformReadSync`
|
|
154
|
+
* read primitive (which dozens of best-effort, tolerant reads flow through and
|
|
155
|
+
* which must not start hard-failing on encoding). It does NOT strip, sanitize, or
|
|
156
|
+
* repair the NUL bytes — corruption is a signal of an upstream authoring-tool bug
|
|
157
|
+
* and must stay visible.
|
|
158
|
+
*
|
|
159
|
+
* @param buf the file bytes (Buffer or string; a string is searched char-wise)
|
|
160
|
+
* @param relPath a path/label for the diagnostic message
|
|
161
|
+
* @returns an error string when NUL is found, or `null` when the bytes are clean text
|
|
162
|
+
*/
|
|
163
|
+
function textEncodingError(buf, relPath) {
|
|
164
|
+
const nul = typeof buf === 'string' ? buf.indexOf('\0') : buf.indexOf(0x00);
|
|
165
|
+
if (nul === -1)
|
|
166
|
+
return null;
|
|
167
|
+
return (`${relPath}: file contains NUL bytes (first at offset ${nul}). ` +
|
|
168
|
+
'Artifact files must be UTF-8 text. A NUL-corrupted file is binary-classified ' +
|
|
169
|
+
'and silently skipped by recursive / binary-skipping search tools (rg, grep -I), ' +
|
|
170
|
+
'so downstream verification reports its contents as missing rather than corrupt.');
|
|
171
|
+
}
|
|
@@ -15,6 +15,17 @@
|
|
|
15
15
|
* inside a fenced code block) is ignored — this is the exact failure mode that
|
|
16
16
|
* issue #586 / PR #650 identified. The shared extractFrontmatter parser anchors
|
|
17
17
|
* its regex at byte 0 of the document, which provides this guarantee.
|
|
18
|
+
*
|
|
19
|
+
* #2348 staleness signal: whether a *-VERIFICATION.md is stale (a summary newer
|
|
20
|
+
* than it) is decided from git commit time when a file is committed AND clean,
|
|
21
|
+
* and from filesystem mtime otherwise. mtimes are assigned at checkout time and
|
|
22
|
+
* are not preserved by `git clone` / `cp -R`, and any unrelated `touch` /
|
|
23
|
+
* reformat / editor-save re-stales a valid report — so a committed phase could
|
|
24
|
+
* read `passed` on one machine and `stale` on a fresh clone purely from checkout
|
|
25
|
+
* order. Git commit time is content-tied and clone-stable; mtime is retained
|
|
26
|
+
* only for uncommitted or working-tree-dirty files, where it is the true
|
|
27
|
+
* last-changed signal. Both are real wall-clock change times, so the comparison
|
|
28
|
+
* is sound even when one file uses each.
|
|
18
29
|
*/
|
|
19
30
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
20
31
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
@@ -29,6 +40,8 @@ const phaseId = require("./phase-id.cjs");
|
|
|
29
40
|
const frontmatterMod = require("./frontmatter.cjs");
|
|
30
41
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
|
|
31
42
|
const scanPhasePlans = require("./plan-scan.cjs");
|
|
43
|
+
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
44
|
+
const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
|
|
32
45
|
const { output, error } = io;
|
|
33
46
|
const { extractPhaseToken } = phaseId;
|
|
34
47
|
const { extractFrontmatter } = frontmatterMod;
|
|
@@ -49,6 +62,12 @@ const VERIFIER_STATUSES = ['passed', 'gaps_found', 'human_needed'];
|
|
|
49
62
|
*
|
|
50
63
|
* For 'gaps_found', next_command is built at call time in readVerificationStatus
|
|
51
64
|
* by substituting the phase number — it is NOT stored as a function in the table.
|
|
65
|
+
*
|
|
66
|
+
* #2617: `next_command` here holds a BARE command name (`execute-phase`), never a
|
|
67
|
+
* prefixed one. Every return path projects it through `formatGsdSlash` with the
|
|
68
|
+
* caller's runtime, so Codex sees `$gsd-execute-phase` and slash-hyphen runtimes
|
|
69
|
+
* see `/gsd-execute-phase`. Storing a prefixed literal is what leaked the
|
|
70
|
+
* hard-coded (and deprecated) `/gsd:` colon form to every runtime.
|
|
52
71
|
*/
|
|
53
72
|
const VERIFICATION_ROUTING_TABLE = {
|
|
54
73
|
passed: {
|
|
@@ -65,7 +84,12 @@ const VERIFICATION_ROUTING_TABLE = {
|
|
|
65
84
|
human_needed: {
|
|
66
85
|
status: 'human_needed',
|
|
67
86
|
next_action: "Human verification required. Complete the manual tests in the phase's *-UAT.md, then re-run the verify step until status is passed.",
|
|
68
|
-
|
|
87
|
+
// #2617: was '' — next_action told the user to "re-run the verify step" but
|
|
88
|
+
// named no command, while init.cts's parallel projector emitted
|
|
89
|
+
// `verify-work <N>` for this same state. The two surfaces disagreed on
|
|
90
|
+
// whether a next command existed at all; init's answer was the useful one,
|
|
91
|
+
// and init now delegates here rather than re-deriving it.
|
|
92
|
+
next_command: 'verify-work',
|
|
69
93
|
},
|
|
70
94
|
stale: {
|
|
71
95
|
status: 'stale',
|
|
@@ -77,30 +101,117 @@ const VERIFICATION_ROUTING_TABLE = {
|
|
|
77
101
|
missing: {
|
|
78
102
|
status: 'missing',
|
|
79
103
|
next_action: 'No verification report found — the verify step never completed. Re-run execute-phase.',
|
|
80
|
-
next_command: '
|
|
104
|
+
next_command: 'execute-phase',
|
|
81
105
|
},
|
|
82
106
|
// INTERNAL SENTINEL: constructed when the file has a status value not in
|
|
83
107
|
// VERIFIER_STATUSES. Never emitted by the verifier.
|
|
84
108
|
unknown: {
|
|
85
109
|
status: 'unknown',
|
|
86
110
|
next_action: '', // filled in dynamically with the raw value
|
|
87
|
-
next_command: '
|
|
111
|
+
next_command: 'execute-phase',
|
|
88
112
|
},
|
|
89
113
|
};
|
|
114
|
+
/**
|
|
115
|
+
* Project a BARE command name (plus optional argument tail) into the surface the
|
|
116
|
+
* given runtime actually installs (#2617).
|
|
117
|
+
*
|
|
118
|
+
* `formatGsdSlash` owns the per-runtime shape (`$gsd-<cmd>` for shell-var
|
|
119
|
+
* runtimes like Codex, `/gsd-<cmd>` otherwise) and is idempotent, so passing an
|
|
120
|
+
* already-prefixed string is safe. An empty command stays empty — "no next
|
|
121
|
+
* command" must not become a bare prefix.
|
|
122
|
+
*/
|
|
123
|
+
function projectNextCommand(bare, runtime, tail = '') {
|
|
124
|
+
if (!bare)
|
|
125
|
+
return '';
|
|
126
|
+
return `${(0, runtime_slash_cjs_1.formatGsdSlash)(bare, runtime)}${tail}`;
|
|
127
|
+
}
|
|
128
|
+
/** Normalize separators to posix (git emits `/`; callers may pass `\` on Windows). */
|
|
129
|
+
function toPosix(p) {
|
|
130
|
+
return p.replace(/\\/g, '/');
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Match a git-emitted (repo-root-relative) path back to the caller's
|
|
134
|
+
* phaseDir-relative request by exact match or `/`-bounded suffix — precise
|
|
135
|
+
* enough that a root file and a nested `plans/` file can never collide (a plain
|
|
136
|
+
* basename match could). Returns the original caller-form file string, or null.
|
|
137
|
+
*/
|
|
138
|
+
function matchRequestedFile(gitPath, requested, requestedPosix) {
|
|
139
|
+
const g = toPosix(gitPath);
|
|
140
|
+
for (let i = 0; i < requested.length; i++) {
|
|
141
|
+
const want = requestedPosix[i];
|
|
142
|
+
if (g === want || g.endsWith('/' + want))
|
|
143
|
+
return requested[i];
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Parse `git log --format=%ct --name-only` output into file → most-recent commit
|
|
149
|
+
* time (ms). Output is reverse-chronological, so a file's FIRST appearance
|
|
150
|
+
* top-down is its latest commit. `%ct` headers are pure digits; path lines
|
|
151
|
+
* contain a `.` (the `.md` extension) — so the two are unambiguous.
|
|
152
|
+
*/
|
|
153
|
+
function parseCommitTimes(stdout, requested, requestedPosix) {
|
|
154
|
+
const out = new Map();
|
|
155
|
+
let currentCt = null;
|
|
156
|
+
for (const line of stdout.split('\n')) {
|
|
157
|
+
if (line.length === 0)
|
|
158
|
+
continue;
|
|
159
|
+
if (/^\d+$/.test(line)) {
|
|
160
|
+
currentCt = Number.parseInt(line, 10);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (currentCt === null)
|
|
164
|
+
continue;
|
|
165
|
+
const rel = matchRequestedFile(line, requested, requestedPosix);
|
|
166
|
+
if (rel !== null && !out.has(rel))
|
|
167
|
+
out.set(rel, currentCt * 1000);
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
function defaultPhaseCleanCommitTimesMs(phaseDir, files, execGitFn = shell_command_projection_cjs_1.execGit) {
|
|
172
|
+
if (files.length === 0)
|
|
173
|
+
return new Map();
|
|
174
|
+
const requestedPosix = files.map(toPosix);
|
|
175
|
+
const logRes = execGitFn(['log', '--first-parent', '--format=%ct', '--name-only', '--', ...files], {
|
|
176
|
+
cwd: phaseDir,
|
|
177
|
+
});
|
|
178
|
+
if (logRes.error || logRes.exitCode !== 0 || logRes.stdout.length === 0)
|
|
179
|
+
return new Map();
|
|
180
|
+
const commitTimes = parseCommitTimes(logRes.stdout, files, requestedPosix);
|
|
181
|
+
if (commitTimes.size === 0)
|
|
182
|
+
return commitTimes;
|
|
183
|
+
// Drop dirty files (working tree ≠ HEAD) so their mtime is used instead. If the
|
|
184
|
+
// dirty-check itself is INCONCLUSIVE (git diff errored / non-zero — as opposed
|
|
185
|
+
// to "ran and reported no dirty files"), we cannot prove any file is clean, so
|
|
186
|
+
// fail SAFE: discard the commit times and let every file fall back to mtime,
|
|
187
|
+
// the same direction as a git-log failure. Trusting possibly-stale commit times
|
|
188
|
+
// here would silently mask a real edit (false "not stale"). (#2348)
|
|
189
|
+
const diffRes = execGitFn(['diff', '--name-only', 'HEAD', '--', ...files], { cwd: phaseDir });
|
|
190
|
+
if (diffRes.error || diffRes.exitCode !== 0)
|
|
191
|
+
return new Map();
|
|
192
|
+
for (const line of diffRes.stdout.split('\n')) {
|
|
193
|
+
if (line.length === 0)
|
|
194
|
+
continue;
|
|
195
|
+
const rel = matchRequestedFile(line, files, requestedPosix);
|
|
196
|
+
if (rel !== null)
|
|
197
|
+
commitTimes.delete(rel);
|
|
198
|
+
}
|
|
199
|
+
return commitTimes;
|
|
200
|
+
}
|
|
90
201
|
/**
|
|
91
202
|
* Build a 'missing' result from the routing table.
|
|
92
203
|
* Used for two early-return paths: no *-VERIFICATION.md file found, and
|
|
93
204
|
* file present but no parseable frontmatter status.
|
|
94
205
|
*/
|
|
95
|
-
function missingResult() {
|
|
206
|
+
function missingResult(runtime, phaseArg) {
|
|
96
207
|
const route = VERIFICATION_ROUTING_TABLE['missing'];
|
|
97
208
|
return {
|
|
98
209
|
status: route.status,
|
|
99
210
|
next_action: route.next_action,
|
|
100
|
-
next_command: route.next_command,
|
|
211
|
+
next_command: projectNextCommand(route.next_command, runtime, phaseArg),
|
|
101
212
|
};
|
|
102
213
|
}
|
|
103
|
-
function findStaleVerificationSummary(phaseDir, fsImpl = node_fs_1.default) {
|
|
214
|
+
function findStaleVerificationSummary(phaseDir, fsImpl = node_fs_1.default, phaseCleanCommitTimesMs = defaultPhaseCleanCommitTimesMs) {
|
|
104
215
|
// FS errors (TOCTOU: a SUMMARY listed by scanPhasePlans then removed before statSync;
|
|
105
216
|
// unreadable dir; broken symlink; file->dir swap) must degrade to "not stale" rather
|
|
106
217
|
// than throw uncaught into callers that are NOT under the planning lock
|
|
@@ -112,23 +223,31 @@ function findStaleVerificationSummary(phaseDir, fsImpl = node_fs_1.default) {
|
|
|
112
223
|
const verificationFile = phaseFiles.filter((f) => f.endsWith('-VERIFICATION.md')).sort()[0];
|
|
113
224
|
if (!verificationFile)
|
|
114
225
|
return null;
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
226
|
+
const summaryFiles = scanPhasePlans(phaseDir).summaryFiles
|
|
227
|
+
.slice()
|
|
228
|
+
.sort();
|
|
229
|
+
// No summary can be newer than the verification → never stale. Return before
|
|
230
|
+
// touching git so a phase with no summaries costs zero subprocesses. (#2348)
|
|
231
|
+
if (summaryFiles.length === 0)
|
|
232
|
+
return null;
|
|
233
|
+
// Each file's effective "last changed" time = its commit time when committed
|
|
234
|
+
// AND clean (content-tied and clone-stable), else its filesystem mtime (the
|
|
235
|
+
// uncommitted working-tree edit). Both are real wall-clock change times, so
|
|
236
|
+
// comparing a clean file's commit time against a dirty file's mtime is sound.
|
|
237
|
+
// One resolver call = two git subprocesses for the whole phase. (#2348)
|
|
238
|
+
const cleanCommitMs = phaseCleanCommitTimesMs(phaseDir, [verificationFile, ...summaryFiles]);
|
|
239
|
+
const effectiveTimeMs = (file) => cleanCommitMs.has(file)
|
|
240
|
+
? cleanCommitMs.get(file)
|
|
241
|
+
: fsImpl.statSync(node_path_1.default.join(phaseDir, file)).mtimeMs;
|
|
242
|
+
const verificationTimeMs = effectiveTimeMs(verificationFile);
|
|
243
|
+
for (const summaryFile of summaryFiles) {
|
|
244
|
+
// The caller only needs whether the phase is stale, not which summary —
|
|
245
|
+
// the first stale summary (in sorted order) is enough. Short-circuit.
|
|
246
|
+
if (effectiveTimeMs(summaryFile) > verificationTimeMs) {
|
|
247
|
+
return { verificationFile, summaryFile };
|
|
124
248
|
}
|
|
125
249
|
}
|
|
126
|
-
|
|
127
|
-
return null;
|
|
128
|
-
return {
|
|
129
|
-
verificationFile,
|
|
130
|
-
summaryFile: newestStaleSummary.summaryFile,
|
|
131
|
-
};
|
|
250
|
+
return null;
|
|
132
251
|
}
|
|
133
252
|
catch {
|
|
134
253
|
return null;
|
|
@@ -148,13 +267,25 @@ function findStaleVerificationSummary(phaseDir, fsImpl = node_fs_1.default) {
|
|
|
148
267
|
*
|
|
149
268
|
* @param phaseDir - Absolute path to the phase directory.
|
|
150
269
|
* @param opts - Options. `opts.fs` allows test injection (defaults to node:fs).
|
|
270
|
+
* `opts.runtime` selects the command surface `next_command` is
|
|
271
|
+
* projected into (#2617).
|
|
151
272
|
*/
|
|
152
273
|
function readVerificationStatus(phaseDir, opts = {}) {
|
|
153
274
|
const fsImpl = opts.fs ?? node_fs_1.default;
|
|
275
|
+
const phaseCleanCommitTimesMs = opts.phaseCleanCommitTimesMs ?? defaultPhaseCleanCommitTimesMs;
|
|
276
|
+
const runtime = opts.runtime ?? 'claude';
|
|
154
277
|
// Phase token for the gaps_found command
|
|
155
278
|
const baseName = node_path_1.default.basename(phaseDir);
|
|
156
279
|
const phaseToken = extractPhaseToken(baseName);
|
|
157
|
-
const
|
|
280
|
+
const derivedPhaseNumber = phaseToken.length > 0 ? phaseToken : baseName;
|
|
281
|
+
// #2617: the phase number becomes a COMMAND ARGUMENT, so it is appended only
|
|
282
|
+
// when it is unambiguously one. extractPhaseToken also returns project-code
|
|
283
|
+
// forms (`PROJ-07`), which are indistinguishable by shape from an ordinary
|
|
284
|
+
// directory name — `gsd-651-parent` yields `gsd-651` — and emitting
|
|
285
|
+
// `execute-phase gsd-651` is worse than emitting no argument at all. Callers
|
|
286
|
+
// that already know the number (init) pass it explicitly and always get it.
|
|
287
|
+
const phaseArgSource = opts.phaseNumber ?? (/^\d+(\.\d+)*$/.test(derivedPhaseNumber) ? derivedPhaseNumber : '');
|
|
288
|
+
const phaseArg = phaseArgSource ? ` ${phaseArgSource}` : '';
|
|
158
289
|
// 1. Find *-VERIFICATION.md
|
|
159
290
|
let verificationFile = null;
|
|
160
291
|
try {
|
|
@@ -167,7 +298,7 @@ function readVerificationStatus(phaseDir, opts = {}) {
|
|
|
167
298
|
verificationFile = null;
|
|
168
299
|
}
|
|
169
300
|
if (!verificationFile) {
|
|
170
|
-
return missingResult();
|
|
301
|
+
return missingResult(runtime, phaseArg);
|
|
171
302
|
}
|
|
172
303
|
// 2. Read and parse frontmatter using the shared parser.
|
|
173
304
|
// extractFrontmatter anchors at byte 0, so body `status:` lines are ignored.
|
|
@@ -175,7 +306,7 @@ function readVerificationStatus(phaseDir, opts = {}) {
|
|
|
175
306
|
let rawStatus = null;
|
|
176
307
|
try {
|
|
177
308
|
const content = fsImpl.readFileSync(filePath, 'utf-8');
|
|
178
|
-
const fm = extractFrontmatter(content);
|
|
309
|
+
const fm = extractFrontmatter(content, filePath);
|
|
179
310
|
const statusVal = fm['status'];
|
|
180
311
|
// status is always a scalar string in a well-formed VERIFICATION.md frontmatter;
|
|
181
312
|
// only accept string values — arrays and objects are not valid status values.
|
|
@@ -188,7 +319,7 @@ function readVerificationStatus(phaseDir, opts = {}) {
|
|
|
188
319
|
rawStatus = null;
|
|
189
320
|
}
|
|
190
321
|
if (!rawStatus) {
|
|
191
|
-
return missingResult();
|
|
322
|
+
return missingResult(runtime, phaseArg);
|
|
192
323
|
}
|
|
193
324
|
// gaps_found takes priority over stale — gap closure is the correct next
|
|
194
325
|
// step regardless of whether summaries are newer than the verification file.
|
|
@@ -197,16 +328,16 @@ function readVerificationStatus(phaseDir, opts = {}) {
|
|
|
197
328
|
return {
|
|
198
329
|
status: entry.status,
|
|
199
330
|
next_action: entry.next_action,
|
|
200
|
-
next_command:
|
|
331
|
+
next_command: projectNextCommand('plan-phase', runtime, `${phaseArg} --gaps`),
|
|
201
332
|
};
|
|
202
333
|
}
|
|
203
|
-
const staleVerification = findStaleVerificationSummary(phaseDir, fsImpl);
|
|
334
|
+
const staleVerification = findStaleVerificationSummary(phaseDir, fsImpl, phaseCleanCommitTimesMs);
|
|
204
335
|
if (staleVerification) {
|
|
205
336
|
const entry = VERIFICATION_ROUTING_TABLE['stale'];
|
|
206
337
|
return {
|
|
207
338
|
status: entry.status,
|
|
208
339
|
next_action: entry.next_action,
|
|
209
|
-
next_command:
|
|
340
|
+
next_command: projectNextCommand('verify-work', runtime, phaseArg),
|
|
210
341
|
};
|
|
211
342
|
}
|
|
212
343
|
// 3. Route — exclude internal sentinels from raw-file lookup (they are
|
|
@@ -220,7 +351,7 @@ function readVerificationStatus(phaseDir, opts = {}) {
|
|
|
220
351
|
return {
|
|
221
352
|
status: entry.status,
|
|
222
353
|
next_action: entry.next_action,
|
|
223
|
-
next_command: entry.next_command,
|
|
354
|
+
next_command: projectNextCommand(entry.next_command, runtime, phaseArg),
|
|
224
355
|
};
|
|
225
356
|
}
|
|
226
357
|
// Unknown value
|
|
@@ -228,7 +359,7 @@ function readVerificationStatus(phaseDir, opts = {}) {
|
|
|
228
359
|
return {
|
|
229
360
|
status: unknownRoute.status,
|
|
230
361
|
next_action: `Unexpected verification status '${rawStatus}'. Re-run execute-phase verification.`,
|
|
231
|
-
next_command: unknownRoute.next_command,
|
|
362
|
+
next_command: projectNextCommand(unknownRoute.next_command, runtime, phaseArg),
|
|
232
363
|
};
|
|
233
364
|
}
|
|
234
365
|
/**
|
|
@@ -245,12 +376,13 @@ function cmdVerificationStatus(cwd, phaseDirArg, raw) {
|
|
|
245
376
|
return;
|
|
246
377
|
}
|
|
247
378
|
const phaseDir = node_path_1.default.resolve(cwd, phaseDirArg);
|
|
248
|
-
const result = readVerificationStatus(phaseDir);
|
|
379
|
+
const result = readVerificationStatus(phaseDir, { runtime: (0, runtime_slash_cjs_1.resolveRuntime)(cwd) });
|
|
249
380
|
output(result, raw);
|
|
250
381
|
}
|
|
251
382
|
module.exports = {
|
|
252
383
|
VERIFIER_STATUSES,
|
|
253
384
|
VERIFICATION_ROUTING_TABLE,
|
|
385
|
+
defaultPhaseCleanCommitTimesMs,
|
|
254
386
|
findStaleVerificationSummary,
|
|
255
387
|
readVerificationStatus,
|
|
256
388
|
cmdVerificationStatus,
|