@opengsd/gsd-core 1.9.1 → 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 +27 -3
- 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 +453 -289
- 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 +579 -66
- 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 +75 -47
- 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 -28
- 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/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 +10 -2
- 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 -160
- 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-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/run-tests.cjs +3 -2
- 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,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Conservative cleanup for artifact surfaces retired by a runtime descriptor.
|
|
4
|
+
*
|
|
5
|
+
* A descriptor may stop materializing an artifact kind while old installs
|
|
6
|
+
* still contain files from that surface. Install and profile/surface apply
|
|
7
|
+
* both call this helper so either path converges an existing installation.
|
|
8
|
+
* Only files proven managed by the previous install manifest are removed;
|
|
9
|
+
* modified and unknown files are preserved for the journaled installer
|
|
10
|
+
* migration (or the user) to handle without data loss.
|
|
11
|
+
*/
|
|
12
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
13
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
14
|
+
};
|
|
15
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
16
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
18
|
+
const installerMigrations = require("./installer-migrations.cjs");
|
|
19
|
+
const external_descriptor_trust_cjs_1 = require("./external-descriptor-trust.cjs");
|
|
20
|
+
// Generated at build time, so no TypeScript source declaration exists.
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
22
|
+
const capabilityRegistry = require('./capability-registry.cjs');
|
|
23
|
+
function retiredArtifactsFor(runtime) {
|
|
24
|
+
const configured = capabilityRegistry.runtimes?.[runtime]?.runtime?.hostBehaviors?.retiredArtifacts;
|
|
25
|
+
return Array.isArray(configured) ? configured : [];
|
|
26
|
+
}
|
|
27
|
+
function pruneRetiredRuntimeArtifacts(runtime, configDir) {
|
|
28
|
+
const result = { removed: [], preserved: [] };
|
|
29
|
+
const declarations = retiredArtifactsFor(runtime);
|
|
30
|
+
if (declarations.length === 0)
|
|
31
|
+
return result;
|
|
32
|
+
const manifest = installerMigrations.readInstallManifest(configDir);
|
|
33
|
+
for (const declaration of declarations) {
|
|
34
|
+
const destSubpath = declaration.destSubpath;
|
|
35
|
+
const prefix = declaration.prefix;
|
|
36
|
+
const suffix = declaration.suffix;
|
|
37
|
+
if (typeof destSubpath !== 'string' || destSubpath.length === 0 ||
|
|
38
|
+
typeof prefix !== 'string' || prefix.length === 0 ||
|
|
39
|
+
typeof suffix !== 'string' || suffix.length === 0 ||
|
|
40
|
+
!(0, external_descriptor_trust_cjs_1.isPathConfined)(destSubpath, configDir)) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const destDir = node_path_1.default.resolve(configDir, destSubpath);
|
|
44
|
+
let entries;
|
|
45
|
+
try {
|
|
46
|
+
if (!node_fs_1.default.existsSync(destDir) || node_fs_1.default.lstatSync(destDir).isSymbolicLink())
|
|
47
|
+
continue;
|
|
48
|
+
entries = node_fs_1.default.readdirSync(destDir, { withFileTypes: true });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (!entry.isFile() || !entry.name.startsWith(prefix) || !entry.name.endsWith(suffix))
|
|
55
|
+
continue;
|
|
56
|
+
const relPath = node_path_1.default.posix.join(destSubpath.replace(/\\/g, '/'), entry.name);
|
|
57
|
+
const artifact = installerMigrations.classifyArtifact(configDir, relPath, manifest);
|
|
58
|
+
if (artifact.classification !== 'managed-pristine') {
|
|
59
|
+
result.preserved.push(relPath);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
node_fs_1.default.unlinkSync(node_path_1.default.join(destDir, entry.name));
|
|
64
|
+
result.removed.push(relPath);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
result.preserved.push(relPath);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
if (node_fs_1.default.readdirSync(destDir).length === 0)
|
|
72
|
+
node_fs_1.default.rmdirSync(destDir);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Non-empty, unreadable, or concurrently changed: preserve the directory.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
module.exports = { pruneRetiredRuntimeArtifacts, retiredArtifactsFor };
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
*/
|
|
53
53
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
54
54
|
exports.DOCS_PARITY_VIOLATION = exports.LANE_SLUG_RE = exports.PARITY_VIOLATION = exports.REVIEWER_LANES = exports.ARGV_PLACEHOLDER = void 0;
|
|
55
|
+
exports.mergeReviewerLanes = mergeReviewerLanes;
|
|
55
56
|
exports.checkReviewerLaneParity = checkReviewerLaneParity;
|
|
56
57
|
exports.checkReviewerDocsParity = checkReviewerDocsParity;
|
|
57
58
|
/**
|
|
@@ -423,6 +424,104 @@ exports.REVIEWER_LANES = Object.freeze([
|
|
|
423
424
|
handler: null,
|
|
424
425
|
},
|
|
425
426
|
].map((lane) => Object.freeze(lane)));
|
|
427
|
+
/**
|
|
428
|
+
* Merge the first-party lane set with installed overlay `reviewer` bodies
|
|
429
|
+
* (ADR-2782 D8: first-party ∪ overlay, first-party wins on slug collision).
|
|
430
|
+
*
|
|
431
|
+
* `routeReviewLane` (gsd-core/bin/gsd-tools.cjs) consumes this to build the lane
|
|
432
|
+
* map its `sections`/`flags`/`plan`/`invoke` subcommands resolve against, so an
|
|
433
|
+
* installed, consented third-party reviewer capability (`role:"reviewer"` with a
|
|
434
|
+
* declared `reviewer` body) becomes selectable, plannable, and invocable through
|
|
435
|
+
* the same surface as a built-in lane — closing #2927, where the overlay was
|
|
436
|
+
* roster-visible (`deriveReviewerSlugs`) and disclosed at install
|
|
437
|
+
* (`collectReviewerLaneSurfaces`) but never reached the invocation path.
|
|
438
|
+
*
|
|
439
|
+
* ADR-2782 D1 deliberately made the manifest `reviewer` body field-identical to
|
|
440
|
+
* `ReviewerLaneCommon` so "Phase 2 harvests the shape into the capability manifest
|
|
441
|
+
* with no translation layer" (CONTEXT.md reviewer-lane-descriptor predicate). The
|
|
442
|
+
* overlay body IS already `ReviewerLane`-shaped; this function MERGES, it does not
|
|
443
|
+
* PROJECT — there is no field renaming, no vocabulary translation. That is the
|
|
444
|
+
* design decision the bug violated by simply never calling anything.
|
|
445
|
+
*
|
|
446
|
+
* Pure and TOTAL — never throws on any input, because third-party overlay
|
|
447
|
+
* manifests are untrusted data reaching this seam at load time. A cap whose
|
|
448
|
+
* `reviewer` body is absent, non-object, or carries an empty/grammar-invalid slug
|
|
449
|
+
* is SKIPPED (contributes no lane) rather than crashing, so one malformed overlay
|
|
450
|
+
* cannot take the whole `review-lane` surface (and every first-party lane with it)
|
|
451
|
+
* down. The slug grammar (`LANE_SLUG_RE`) is enforced HERE even though
|
|
452
|
+
* `resolveLanePlan`'s trust boundary re-checks it, because `sections`/`flags` read
|
|
453
|
+
* lane fields before reaching `resolveLanePlan` — defense in depth against the
|
|
454
|
+
* path-traversal class the invocation boundary exists for.
|
|
455
|
+
*
|
|
456
|
+
* First-party wins: an overlay declaring a slug that already names a first-party
|
|
457
|
+
* lane is silently superseded (the first-party entry is kept, identity-stable),
|
|
458
|
+
* never overwritten. The first-party object is returned by reference, not copied.
|
|
459
|
+
*
|
|
460
|
+
* @param firstParty The frozen first-party lane set (`REVIEWER_LANES`).
|
|
461
|
+
* @param registry The merged capability registry
|
|
462
|
+
* (`loadRegistry({ includeInstalled: true })`); only its
|
|
463
|
+
* `capabilities` map is read. A cap contributes a lane iff it
|
|
464
|
+
* carries an object `reviewer` body with a non-empty,
|
|
465
|
+
* grammar-valid `slug`. The `role:"runtime"` legacy
|
|
466
|
+
* `reviewerCli` alias contributes NO lane here — it has no lane
|
|
467
|
+
* descriptor, and the selection roster (`deriveReviewerSlugs`)
|
|
468
|
+
* is a separate surface.
|
|
469
|
+
* @returns A NEW array: first-party lanes (in order) followed by accepted overlay
|
|
470
|
+
* lanes (in registry iteration order). Callers must not mutate it.
|
|
471
|
+
*/
|
|
472
|
+
function mergeReviewerLanes(firstParty, registry) {
|
|
473
|
+
// First-party always wins and is returned by reference (identity-stable), so a
|
|
474
|
+
// collision cannot perturb the first-party entry a consumer already holds.
|
|
475
|
+
const bySlug = new Map();
|
|
476
|
+
const ordered = [];
|
|
477
|
+
for (const lane of firstParty) {
|
|
478
|
+
const slug = typeof lane.slug === 'string' ? lane.slug.trim() : '';
|
|
479
|
+
if (!slug)
|
|
480
|
+
continue; // unreachable for the shipped frozen set, but this is exported
|
|
481
|
+
if (!bySlug.has(slug)) {
|
|
482
|
+
bySlug.set(slug, lane);
|
|
483
|
+
ordered.push(lane);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
const capabilities = (registry && registry.capabilities) || {};
|
|
487
|
+
for (const cap of Object.values(capabilities)) {
|
|
488
|
+
if (cap === null || typeof cap !== 'object' || Array.isArray(cap))
|
|
489
|
+
continue;
|
|
490
|
+
const body = cap.reviewer;
|
|
491
|
+
// C1/C2 (mirroring collectReviewerLaneSurfaces): a missing, null, or
|
|
492
|
+
// non-object reviewer body declares no lane — never an error at this layer.
|
|
493
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body))
|
|
494
|
+
continue;
|
|
495
|
+
const rawSlug = body.slug;
|
|
496
|
+
const slug = typeof rawSlug === 'string' ? rawSlug.trim() : '';
|
|
497
|
+
// Empty/whitespace slug: nothing to key on. Grammar-invalid slug: the
|
|
498
|
+
// path-traversal class — skip rather than admit, even though resolveLanePlan
|
|
499
|
+
// would reject it downstream. Both are skips, not throws.
|
|
500
|
+
if (!slug || !exports.LANE_SLUG_RE.test(slug))
|
|
501
|
+
continue;
|
|
502
|
+
if (bySlug.has(slug))
|
|
503
|
+
continue; // D8: first-party (or an earlier overlay) wins
|
|
504
|
+
// The body is already ReviewerLane-shaped per ADR-2782 D1. We do NOT
|
|
505
|
+
// deep-validate every field here: the invocation trust boundary
|
|
506
|
+
// (resolveLanePlan) is the seam that re-validates a lane before it runs, and
|
|
507
|
+
// `sections`/`flags` are both tolerant of a partial body (flags filters by
|
|
508
|
+
// shape; sections reads slug/reviewsSection which a non-string cap simply
|
|
509
|
+
// renders as undefined). Admitting the body keeps the merge a pure merge.
|
|
510
|
+
//
|
|
511
|
+
// The slug is normalized to its trimmed value on the stored lane so the merge
|
|
512
|
+
// and the selection roster (`deriveReviewerSlugs`, which trims before adding)
|
|
513
|
+
// agree on the canonical key — otherwise a body declaring `slug: ' x '`
|
|
514
|
+
// would key the map on `'x'` but emit the raw `' x '` in `sections` and fail
|
|
515
|
+
// to match a `--selected` value coming from the roster. (The capability
|
|
516
|
+
// validator's grammar check rejects surrounding whitespace before this is
|
|
517
|
+
// reachable through a real install, so this is belt-and-braces parity with the
|
|
518
|
+
// roster, not a live-input guard.)
|
|
519
|
+
const lane = { ...body, slug };
|
|
520
|
+
bySlug.set(slug, lane);
|
|
521
|
+
ordered.push(lane);
|
|
522
|
+
}
|
|
523
|
+
return ordered;
|
|
524
|
+
}
|
|
426
525
|
/* ------------------------------------------------------------------ *
|
|
427
526
|
* DEFECT.GENERATIVE-FIX parity (CONTEXT.md:797)
|
|
428
527
|
* ------------------------------------------------------------------ */
|
|
@@ -283,18 +283,32 @@ function antigravityWatermark(workspace, deps) {
|
|
|
283
283
|
return { convId, lines: deps.readFile(tx).split(/\r?\n/).filter((l) => l.trim()).length };
|
|
284
284
|
}
|
|
285
285
|
catch {
|
|
286
|
-
|
|
286
|
+
// #3118: this conv-id pre-dates this run, so its transcript exists but this run cannot verify
|
|
287
|
+
// its line count. Reporting `lines: 0` would assert a fact we could not check — flag it instead
|
|
288
|
+
// so the fallback can decline rather than silently skip zero and replay a stale response.
|
|
289
|
+
return { convId, lines: 0, unreadable: true };
|
|
287
290
|
}
|
|
288
291
|
}
|
|
289
|
-
/**
|
|
292
|
+
/**
|
|
293
|
+
* Workspace lookup is case-insensitive — the leg's jq did `ascii_downcase` on both sides.
|
|
294
|
+
*
|
|
295
|
+
* #3118: a successful `JSON.parse` does not by itself make the payload a usable object —
|
|
296
|
+
* `JSON.parse('null')` succeeds and returns `null`, so a truncated/zeroed cache file slips past
|
|
297
|
+
* the callers' parse-only try/catch. `typeof null === 'object'`, so the guard below must exclude
|
|
298
|
+
* `null` explicitly. Arrays are excluded too (not a workspace map), which also falls out of the
|
|
299
|
+
* `Object.entries`/`hasOwnProperty` lookups below returning nothing for array input.
|
|
300
|
+
*/
|
|
290
301
|
function resolveConvId(cache, workspace) {
|
|
291
|
-
if (
|
|
292
|
-
|
|
302
|
+
if (cache === null || typeof cache !== 'object')
|
|
303
|
+
return '';
|
|
304
|
+
const record = cache;
|
|
305
|
+
if (Object.prototype.hasOwnProperty.call(record, workspace)) {
|
|
306
|
+
const direct = record[workspace];
|
|
293
307
|
if (typeof direct === 'string' && direct)
|
|
294
308
|
return direct;
|
|
295
309
|
}
|
|
296
310
|
const target = workspace.toLowerCase();
|
|
297
|
-
for (const [k, v] of Object.entries(
|
|
311
|
+
for (const [k, v] of Object.entries(record)) {
|
|
298
312
|
if (k.toLowerCase() === target && typeof v === 'string' && v)
|
|
299
313
|
return v;
|
|
300
314
|
}
|
|
@@ -324,6 +338,10 @@ function antigravityTranscriptFallback(workspace, mark, deps) {
|
|
|
324
338
|
const convId = resolveConvId(cache, workspace);
|
|
325
339
|
if (!convId)
|
|
326
340
|
return '';
|
|
341
|
+
// #3118: the watermark could not read this conversation's transcript, so there is no trustworthy
|
|
342
|
+
// skip for it. Declining is the fail-closed answer; skipping 0 would replay a prior run's review.
|
|
343
|
+
if (mark.unreadable === true && convId === mark.convId)
|
|
344
|
+
return '';
|
|
327
345
|
const tx = transcriptPath(deps.homeDir, convId);
|
|
328
346
|
if (!deps.exists(tx))
|
|
329
347
|
return '';
|
|
@@ -549,7 +567,13 @@ function runSpawnLane(plan, deps, repoRoot) {
|
|
|
549
567
|
? antigravityArgv(plan.argv, plan.promptPath, repoRoot, deps)
|
|
550
568
|
: plan.argv;
|
|
551
569
|
const out = deps.spawn(plan.binary, argv, { input, timeoutMs: plan.timeoutMs });
|
|
552
|
-
|
|
570
|
+
// #3086: surface spawn errors (ENOENT, ETIMEDOUT, etc.) that would otherwise
|
|
571
|
+
// be silently dropped — the review path read only stdout/stderr and treated
|
|
572
|
+
// an empty-stderr spawn failure as "the model had nothing to say".
|
|
573
|
+
const errContent = out.errorCode
|
|
574
|
+
? `${out.stderr ?? ''}\n[spawn error: ${out.errorCode}]\n`
|
|
575
|
+
: (out.stderr ?? '');
|
|
576
|
+
deps.writeFile(plan.errPath, errContent);
|
|
553
577
|
// `file-arg` lanes write the review themselves and their stdout is deliberately discarded (#1698).
|
|
554
578
|
let review = plan.outputTarget.kind === 'file'
|
|
555
579
|
? deps.exists(plan.outputTarget.path)
|
|
@@ -24,6 +24,9 @@ const { planningDir } = planningWorkspace;
|
|
|
24
24
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
25
25
|
const configLoaderMod = require("./config-loader.cjs");
|
|
26
26
|
const { loadConfig } = configLoaderMod;
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
28
|
+
const cliExitMod = require("./cli-exit.cjs");
|
|
29
|
+
const { ExitError } = cliExitMod;
|
|
27
30
|
// ─── W021 Implementation ──────────────────────────────────────────────────────
|
|
28
31
|
/**
|
|
29
32
|
* Check each phase entry in a milestone-prefixed ROADMAP.md for W021 violations.
|
|
@@ -110,12 +113,40 @@ function routeRoadmapCommand({ roadmap, args, cwd, raw, error }) {
|
|
|
110
113
|
'annotate-dependencies': () => roadmap.cmdRoadmapAnnotateDependencies(cwd, args[2], raw),
|
|
111
114
|
'validate': () => {
|
|
112
115
|
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
113
|
-
|
|
116
|
+
const warnings = [];
|
|
117
|
+
// #2978: structural validation. A verb named "validate" that cannot
|
|
118
|
+
// produce a negative result provides false assurance. Before the
|
|
119
|
+
// opt-in milestone-prefix check, verify the file is structurally a
|
|
120
|
+
// roadmap at all.
|
|
121
|
+
let roadmapContent;
|
|
114
122
|
try {
|
|
115
123
|
roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf8');
|
|
116
124
|
}
|
|
117
125
|
catch {
|
|
118
|
-
// ROADMAP.md missing —
|
|
126
|
+
// ROADMAP.md missing — not silent success.
|
|
127
|
+
warnings.push({ code: 'V001', message: 'ROADMAP.md not found or unreadable' });
|
|
128
|
+
const result = { warnings };
|
|
129
|
+
process.stdout.write(raw ? JSON.stringify(result) : JSON.stringify(result, null, 2));
|
|
130
|
+
throw new ExitError(1);
|
|
131
|
+
}
|
|
132
|
+
// Empty or whitespace-only.
|
|
133
|
+
if (roadmapContent.trim() === '') {
|
|
134
|
+
warnings.push({ code: 'V002', message: 'ROADMAP.md is empty' });
|
|
135
|
+
}
|
|
136
|
+
// Malformed frontmatter — a `---` opener with no matching closer.
|
|
137
|
+
// Tolerate a leading BOM (#3057) before the fence.
|
|
138
|
+
const contentAfterBom = roadmapContent.replace(/^\uFEFF/, '');
|
|
139
|
+
if (contentAfterBom.startsWith('---')) {
|
|
140
|
+
const closeMatch = contentAfterBom.slice(3).match(/\r?\n---\s*(\r?\n|$)/);
|
|
141
|
+
if (!closeMatch) {
|
|
142
|
+
warnings.push({ code: 'V003', message: 'ROADMAP.md frontmatter is malformed (unterminated --- fence)' });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// No recognizable phase structure — at least one `### Phase N:` heading.
|
|
146
|
+
// Mirrors the phase-heading pattern used across roadmap-parser.cts.
|
|
147
|
+
const hasPhaseEntry = /^#{2,4}\s*Phase\s+\S/im.test(roadmapContent);
|
|
148
|
+
if (!hasPhaseEntry && !warnings.some((w) => w.code === 'V002')) {
|
|
149
|
+
warnings.push({ code: 'V004', message: 'ROADMAP.md contains no recognizable phase entries (no "### Phase N:" headings)' });
|
|
119
150
|
}
|
|
120
151
|
// W021 only fires when phase_id_convention is explicitly 'milestone-prefixed'.
|
|
121
152
|
// Authoritative source: .planning/config.json (set by the upgrade command).
|
|
@@ -141,14 +172,16 @@ function routeRoadmapCommand({ roadmap, args, cwd, raw, error }) {
|
|
|
141
172
|
}
|
|
142
173
|
}
|
|
143
174
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
175
|
+
if (convention === 'milestone-prefixed') {
|
|
176
|
+
warnings.push(...checkW021(roadmapContent));
|
|
177
|
+
}
|
|
147
178
|
const result = { warnings };
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
179
|
+
process.stdout.write(raw ? JSON.stringify(result) : JSON.stringify(result, null, 2));
|
|
180
|
+
// #2978: exit non-zero on any warning, per the documented contract
|
|
181
|
+
// ("exits non-zero on any error or warning").
|
|
182
|
+
if (warnings.length > 0) {
|
|
183
|
+
throw new ExitError(1);
|
|
184
|
+
}
|
|
152
185
|
},
|
|
153
186
|
'upgrade': () => {
|
|
154
187
|
const dryRun = !args.includes('--apply');
|
|
@@ -36,21 +36,76 @@ const unusableInputMod = require("./unusable-input.cjs");
|
|
|
36
36
|
const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod;
|
|
37
37
|
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
38
38
|
// ─── Roadmap milestone scoping ───────────────────────────────────────────────
|
|
39
|
+
/**
|
|
40
|
+
* Markers that classify a MILESTONE HEADING (or `<summary>`) as closed/shipped
|
|
41
|
+
* versus still active. Hoisted to module scope in #2562 — three call sites
|
|
42
|
+
* (`extractCurrentMilestone`, `currentMilestoneRawRanges`,
|
|
43
|
+
* `isMilestoneShippedInRoadmap`) previously kept byte-identical copies.
|
|
44
|
+
*/
|
|
45
|
+
const MILESTONE_CLOSED_MARKER_PATTERN = /\b(?:CLOSED|ARCHIVED|ABANDONED|SHIPPED|FAILED)\b|✅|🗄/i;
|
|
46
|
+
const MILESTONE_ACTIVE_MARKER_PATTERN = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧|🔄/i;
|
|
47
|
+
function isClosedMilestoneHeading(headingText) {
|
|
48
|
+
return MILESTONE_CLOSED_MARKER_PATTERN.test(headingText) && !MILESTONE_ACTIVE_MARKER_PATTERN.test(headingText);
|
|
49
|
+
}
|
|
39
50
|
/**
|
|
40
51
|
* Strip shipped milestone content wrapped in <details> blocks.
|
|
41
52
|
*/
|
|
42
53
|
function stripShippedMilestones(content) {
|
|
43
54
|
return (0, markdown_sectionizer_cjs_1.stripTaggedBlocks)(content, 'details');
|
|
44
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* #2562: is the milestone `version` marked SHIPPED by the ROADMAP itself?
|
|
58
|
+
*
|
|
59
|
+
* Scoped deliberately narrowly, because a false positive here reproduces the
|
|
60
|
+
* exact symptom #2562 reports ("milestone complete" while phases are unstarted):
|
|
61
|
+
*
|
|
62
|
+
* - Only a MILESTONE HEADING (`^#{1,3}` that is not a `Phase N:` heading) or a
|
|
63
|
+
* `<summary>` line can carry the signal. A bullet or checklist item that
|
|
64
|
+
* merely NAMES the version (`- [x] 03-01: ship the v2.0 login endpoint ✅`)
|
|
65
|
+
* is prose about a phase, not a milestone verdict, and is ignored.
|
|
66
|
+
* - The version token is boundary-matched with `(?![\w.-])` (mirrors the #730
|
|
67
|
+
* sub-milestone boundary at `extractCurrentMilestone`), so `v2.0` does not
|
|
68
|
+
* match inside `v2.0.1` — `\b` alone would, since `.` is a non-word char.
|
|
69
|
+
* - Shipped/active classification reuses the same marker patterns the milestone
|
|
70
|
+
* sectioniser uses, so an in-progress marker on the line always wins.
|
|
71
|
+
*
|
|
72
|
+
* Both patterns are anchored and use only complementary character classes
|
|
73
|
+
* (`[^\n]`, `[^<]`, `[^>]`) with no overlapping alternation, so matching stays
|
|
74
|
+
* linear in the ROADMAP's length — an untrusted ROADMAP cannot drive backtracking.
|
|
75
|
+
*/
|
|
76
|
+
function isMilestoneShippedInRoadmap(content, version) {
|
|
77
|
+
const boundedVersion = `${escapeRegex(version)}(?![\\w.-])`;
|
|
78
|
+
const candidates = [
|
|
79
|
+
// A milestone heading: `## v2.0 Launch — ✅ SHIPPED`.
|
|
80
|
+
new RegExp(`^#{1,3}[^\\S\\n]+(?!Phase\\s+\\S)[^\\n]*${boundedVersion}[^\\n]*$`, 'gmi'),
|
|
81
|
+
// A collapsed shipped block's own summary: `<summary>✅ v2.0 … SHIPPED</summary>`.
|
|
82
|
+
new RegExp(`<summary[^>]*>[^<]*${boundedVersion}[^<]*<\\/summary>`, 'gi'),
|
|
83
|
+
];
|
|
84
|
+
for (const pattern of candidates) {
|
|
85
|
+
for (const match of content.matchAll(pattern)) {
|
|
86
|
+
if (isClosedMilestoneHeading(match[0]))
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
45
92
|
/**
|
|
46
93
|
* Extract the current milestone section from ROADMAP.md by positive lookup.
|
|
94
|
+
*
|
|
95
|
+
* @param content - ROADMAP.md content.
|
|
96
|
+
* @param cwd - Project working directory, used to read the companion STATE.md
|
|
97
|
+
* for the current `milestone:` version.
|
|
98
|
+
* @param ws - #2562: workstream name, so the companion STATE.md is read from
|
|
99
|
+
* `.planning/workstreams/<ws>/` instead of the project root. Omitted (the
|
|
100
|
+
* default) preserves the prior `planningDir(cwd)` resolution exactly,
|
|
101
|
+
* including its `GSD_WORKSTREAM` env fallback.
|
|
47
102
|
*/
|
|
48
|
-
function extractCurrentMilestone(content, cwd) {
|
|
103
|
+
function extractCurrentMilestone(content, cwd, ws) {
|
|
49
104
|
if (!cwd)
|
|
50
105
|
return stripShippedMilestones(content);
|
|
51
106
|
let version = null;
|
|
52
107
|
try {
|
|
53
|
-
const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
|
|
108
|
+
const statePath = node_path_1.default.join(planningDir(cwd, ws), 'STATE.md');
|
|
54
109
|
const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(statePath);
|
|
55
110
|
if (stateRaw !== null) {
|
|
56
111
|
const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
|
|
@@ -97,9 +152,7 @@ function extractCurrentMilestone(content, cwd) {
|
|
|
97
152
|
return stripShippedMilestones(content);
|
|
98
153
|
}
|
|
99
154
|
const allMatches = headingMatches;
|
|
100
|
-
const
|
|
101
|
-
const activeMarkerPattern = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧|🔄/i;
|
|
102
|
-
const isClosed = (h) => closedMarkerPattern.test(h) && !activeMarkerPattern.test(h);
|
|
155
|
+
const isClosed = isClosedMilestoneHeading;
|
|
103
156
|
const firstMatch = allMatches[0];
|
|
104
157
|
const selected = allMatches.find((m) => !isClosed(m[1])) || firstMatch;
|
|
105
158
|
const sectionStart = selected.index;
|
|
@@ -156,26 +209,36 @@ function extractCurrentMilestone(content, cwd) {
|
|
|
156
209
|
const detailsStart = detailsMatch.index ?? 0;
|
|
157
210
|
detailsSection = content.slice(detailsStart, computeSectionEnd(detailsMatch[0], detailsStart));
|
|
158
211
|
}
|
|
212
|
+
// #2947: the preamble strip removes `### Phase N:` detail headings from the
|
|
213
|
+
// pre-milestone region so they don't duplicate the ones inside the selected
|
|
214
|
+
// milestone section. But when the phase list lives under a non-version-bearing
|
|
215
|
+
// `## Phases` heading (the shipped greenfield template's own shape) and the
|
|
216
|
+
// selected version-bearing heading is a LATER progress/notes sub-heading with
|
|
217
|
+
// NO phase details of its own, stripping the preamble phases silently drops
|
|
218
|
+
// every phase (phase_count: 0, exit 0). Only strip preamble phase details when
|
|
219
|
+
// the selected milestone section actually contains its own — otherwise the
|
|
220
|
+
// preamble phases ARE this milestone's phases and must be preserved.
|
|
221
|
+
const currentSectionHasPhaseDetails = /^#{2,4}\s*Phase\s+\S/im.test(currentSection);
|
|
159
222
|
const preamble = (0, markdown_sectionizer_cjs_1.stripTaggedBlocks)(beforeMilestones, 'details')
|
|
160
223
|
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
|
161
|
-
.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim
|
|
224
|
+
.replace(currentSectionHasPhaseDetails ? /^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim : /$/, '')
|
|
162
225
|
.replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
|
|
163
226
|
return detailsSection
|
|
164
227
|
? preamble + currentSection + '\n' + detailsSection
|
|
165
228
|
: preamble + currentSection;
|
|
166
229
|
}
|
|
167
|
-
/**
|
|
168
|
-
* Replace a pattern only in the current milestone section of ROADMAP.md.
|
|
169
|
-
*/
|
|
170
230
|
function replaceInCurrentMilestone(content, pattern, replacement) {
|
|
231
|
+
const apply = (src) => typeof replacement === 'function'
|
|
232
|
+
? src.replace(pattern, replacement)
|
|
233
|
+
: src.replace(pattern, replacement);
|
|
171
234
|
const lastDetailsClose = content.lastIndexOf('</details>');
|
|
172
235
|
if (lastDetailsClose === -1) {
|
|
173
|
-
return content
|
|
236
|
+
return apply(content);
|
|
174
237
|
}
|
|
175
238
|
const offset = lastDetailsClose + '</details>'.length;
|
|
176
239
|
const before = content.slice(0, offset);
|
|
177
240
|
const after = content.slice(offset);
|
|
178
|
-
return before + after
|
|
241
|
+
return before + apply(after);
|
|
179
242
|
}
|
|
180
243
|
/**
|
|
181
244
|
* Resolve a single phase's detail-section heading (`### Phase N: …`, any level
|
|
@@ -452,16 +515,24 @@ function getMilestoneInfo(cwd) {
|
|
|
452
515
|
* free-form ROADMAPs that lack versioned milestone headings. When absent or
|
|
453
516
|
* any other value, the warning is suppressed — legacy/default projects must
|
|
454
517
|
* never see spurious warnings.
|
|
518
|
+
* @param ws - #2562: workstream name, so the ROADMAP/STATE pair is read from
|
|
519
|
+
* `.planning/workstreams/<ws>/` instead of the project root. Required by any
|
|
520
|
+
* caller that iterates workstreams (it cannot set `GSD_WORKSTREAM` per
|
|
521
|
+
* iteration). Omitted (the default) preserves the prior `planningDir(cwd)`
|
|
522
|
+
* resolution exactly, including its `GSD_WORKSTREAM` env fallback — every
|
|
523
|
+
* pre-#2562 call site is unaffected.
|
|
455
524
|
*/
|
|
456
|
-
function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
|
|
525
|
+
function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
|
|
457
526
|
const milestonePhaseNums = new Set();
|
|
458
527
|
let missingExplicitVersion = false;
|
|
528
|
+
let versionScoped = false;
|
|
529
|
+
let versionSectionFound = false;
|
|
459
530
|
try {
|
|
460
|
-
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
531
|
+
const roadmapPath = node_path_1.default.join(planningDir(cwd, ws), 'ROADMAP.md');
|
|
461
532
|
const roadmapContent = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
|
|
462
533
|
if (roadmapContent === null)
|
|
463
534
|
throw new Error('missing');
|
|
464
|
-
let roadmap = extractCurrentMilestone(roadmapContent, cwd);
|
|
535
|
+
let roadmap = extractCurrentMilestone(roadmapContent, cwd, ws);
|
|
465
536
|
const hasVersionedMilestonesGlobal = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent);
|
|
466
537
|
const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+[\w]/i.test(roadmapContent);
|
|
467
538
|
if (!hasVersionedMilestonesGlobal && hasPhaseHeadings && phaseIdConvention === 'milestone-prefixed') {
|
|
@@ -493,6 +564,8 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
|
|
|
493
564
|
}
|
|
494
565
|
}
|
|
495
566
|
else {
|
|
567
|
+
versionScoped = true;
|
|
568
|
+
versionSectionFound = true;
|
|
496
569
|
const sectionStart = sectionMatch.index;
|
|
497
570
|
const headingLevel = (sectionMatch[1].match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
|
|
498
571
|
const afterHeading = sectionStart + sectionMatch[0].length;
|
|
@@ -552,12 +625,20 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
|
|
|
552
625
|
const passAll = (() => true);
|
|
553
626
|
passAll.phaseCount = 0;
|
|
554
627
|
passAll.missingExplicitVersion = missingExplicitVersion;
|
|
628
|
+
passAll.versionScoped = false;
|
|
629
|
+
// #2562: preserved through the pass-all degrade precisely BECAUSE
|
|
630
|
+
// `versionScoped` is reset here — this is the only surviving evidence that
|
|
631
|
+
// the current milestone exists in the ROADMAP and simply has no phases yet.
|
|
632
|
+
passAll.versionSectionFound = versionSectionFound;
|
|
555
633
|
return passAll;
|
|
556
634
|
}
|
|
557
|
-
const normalized = new Set([...milestonePhaseNums].map(n => n.split('-').map(seg => (seg.replace(/^0+(?=\d)/, '') || '0')).join('-').toLowerCase()));
|
|
558
635
|
function normalizePhaseIdSegments(id) {
|
|
559
636
|
return id.split('-').map(seg => seg.replace(/^0+(?=\d)/, '') || '0').join('-');
|
|
560
637
|
}
|
|
638
|
+
// #2562: derive BOTH sides of every membership comparison from
|
|
639
|
+
// normalizePhaseIdSegments. This set previously inlined a byte-identical
|
|
640
|
+
// second copy of that logic — the drift-prone shape this issue is about.
|
|
641
|
+
const normalized = new Set([...milestonePhaseNums].map(n => normalizePhaseIdSegments(n).toLowerCase()));
|
|
561
642
|
const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-'));
|
|
562
643
|
// #2043: milestone-prefixed sub-phase components must be zero-padded — so a
|
|
563
644
|
// single-digit slug word after the phase
|
|
@@ -587,6 +668,8 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
|
|
|
587
668
|
}
|
|
588
669
|
isDirInMilestone.phaseCount = milestonePhaseNums.size;
|
|
589
670
|
isDirInMilestone.missingExplicitVersion = missingExplicitVersion;
|
|
671
|
+
isDirInMilestone.versionScoped = versionScoped;
|
|
672
|
+
isDirInMilestone.versionSectionFound = versionSectionFound;
|
|
590
673
|
return isDirInMilestone;
|
|
591
674
|
}
|
|
592
675
|
/**
|
|
@@ -628,9 +711,7 @@ function currentMilestoneRawRanges(content, cwd) {
|
|
|
628
711
|
const headingMatches = [...content.matchAll(sectionPattern)];
|
|
629
712
|
if (headingMatches.length === 0)
|
|
630
713
|
return null;
|
|
631
|
-
const
|
|
632
|
-
const activeMarkerPattern = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧|🔄/i;
|
|
633
|
-
const isClosed = (h) => closedMarkerPattern.test(h) && !activeMarkerPattern.test(h);
|
|
714
|
+
const isClosed = isClosedMilestoneHeading;
|
|
634
715
|
const firstMatch = headingMatches[0];
|
|
635
716
|
const selected = headingMatches.find((m) => !isClosed(m[1])) || firstMatch;
|
|
636
717
|
const sectionStart = selected.index ?? 0;
|
|
@@ -671,6 +752,7 @@ function currentMilestoneRawRanges(content, cwd) {
|
|
|
671
752
|
module.exports = {
|
|
672
753
|
stripShippedMilestones,
|
|
673
754
|
extractCurrentMilestone,
|
|
755
|
+
isMilestoneShippedInRoadmap,
|
|
674
756
|
replaceInCurrentMilestone,
|
|
675
757
|
getRoadmapPhaseInternal,
|
|
676
758
|
getMilestoneInfo,
|
|
@@ -265,7 +265,11 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
265
265
|
// Extract all phase headings: ## Phase N: Name or ### Phase N: Name
|
|
266
266
|
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
|
267
267
|
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
|
|
268
|
-
|
|
268
|
+
// #3036: widen the id capture to accept non-numeric-leading ids (e.g. B7, P0.3-2)
|
|
269
|
+
// that get-phase/execute-phase already resolve. An optional leading letter prefix
|
|
270
|
+
// ([A-Za-z]?) covers letter-prefixed ids without breaking numeric-leading ones.
|
|
271
|
+
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
|
|
272
|
+
const phasePattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([A-Za-z]?\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi;
|
|
269
273
|
const phases = [];
|
|
270
274
|
let match;
|
|
271
275
|
// Phase 0 (pre-milestone) and Phase 999 (backlog) are sentinels, not real
|
|
@@ -297,8 +301,9 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
297
301
|
const sectionStart = match.index;
|
|
298
302
|
const restOfContent = content.slice(sectionStart);
|
|
299
303
|
// #3691: `\d` → `\d[\d.]*` so decimal phase headings (e.g. `### Phase 02.3:`) are
|
|
300
|
-
// recognised as section boundaries.
|
|
301
|
-
|
|
304
|
+
// recognised as section boundaries. #3036: `[A-Za-z]?\d` so non-numeric-leading ids
|
|
305
|
+
// (e.g. B7) are also recognised.
|
|
306
|
+
const nextHeader = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]{1,200}\]\s*)?Phase\s+[A-Za-z]?\d[\d.-]*/i);
|
|
302
307
|
const sectionEnd = nextHeader ? sectionStart + nextHeader.index : content.length;
|
|
303
308
|
const section = content.slice(sectionStart, sectionEnd);
|
|
304
309
|
const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
|
|
@@ -389,7 +394,9 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
389
394
|
// IDs (e.g. `1-01`) match the detail-heading scanner above; otherwise they truncate
|
|
390
395
|
// at the dash (`1-01` -> `1`) and every such phase reports a phantom missing detail.
|
|
391
396
|
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
|
|
392
|
-
|
|
397
|
+
// #3036: widen to accept non-numeric-leading ids (same widening as the detail-heading pattern above).
|
|
398
|
+
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
|
|
399
|
+
const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+([A-Za-z]?\d+[A-Z]?(?:[.-]\d+)*)/gi;
|
|
393
400
|
const checklistPhases = new Set();
|
|
394
401
|
let checklistMatch;
|
|
395
402
|
while ((checklistMatch = checklistPattern.exec(content)) !== null) {
|
|
@@ -468,8 +475,13 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
468
475
|
// cmdPhaseComplete's gate (phase.cts:1436). Previously the checkbox fired the
|
|
469
476
|
// moment the last plan summary landed — before gsd-verifier had verified.
|
|
470
477
|
const phaseDir = node_path_1.default.join(cwd, phaseInfo.directory);
|
|
471
|
-
const
|
|
478
|
+
const verificationResult = readVerificationStatus(phaseDir);
|
|
479
|
+
const verificationPassed = verificationResult.status === 'passed';
|
|
472
480
|
const isComplete = summaryCount >= planCount && verificationPassed;
|
|
481
|
+
// #3057 B3: routing above is unchanged (an indeterminate staleness check
|
|
482
|
+
// still routes as if nothing were stale) — this only makes the fact visible
|
|
483
|
+
// to whatever reads this command's JSON output.
|
|
484
|
+
const verificationStaleCheckIndeterminate = verificationResult.staleCheckIndeterminate === true;
|
|
473
485
|
const status = isComplete ? 'Complete' : summaryCount > 0 ? 'In Progress' : 'Planned';
|
|
474
486
|
const today = clock_cjs_1.realClock.localToday();
|
|
475
487
|
if (!node_fs_1.default.existsSync(roadmapPath)) {
|
|
@@ -526,11 +538,28 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
526
538
|
// `**Plans**: N plans` — bold word + outer colon (gsd-core/templates/roadmap.md)
|
|
527
539
|
// `**Plans:** N plans` — bold "Plans:" (colon inside bold)
|
|
528
540
|
// `Plans: N plans` — plain text header
|
|
529
|
-
|
|
541
|
+
//
|
|
542
|
+
// #2853: the verb owns the count token ONLY — it must not destroy hand-written
|
|
543
|
+
// prose a human placed after the count (e.g. "(11-16 are gap closure ...)").
|
|
544
|
+
// Group $1 = phase header → `Plans:` label + trailing whitespace (unchanged).
|
|
545
|
+
// Group $2 = the existing count token to replace: matches `N/N plans complete`,
|
|
546
|
+
// `N/N plans executed`, or the bare template `N plans` form. Group $3 = whatever
|
|
547
|
+
// else is on the line (`[^\r\n]*`, so CRLF `\r` is preserved).
|
|
548
|
+
//
|
|
549
|
+
// Trailing text is preserved ONLY when a real count token ($2) was present —
|
|
550
|
+
// i.e. an annotation a human wrote after a real count. When $2 is absent the
|
|
551
|
+
// line is the fresh-template bracketed placeholder (`[Number of plans…]`) or
|
|
552
|
+
// other freeform guidance, not user prose: the count replaces the whole token,
|
|
553
|
+
// preserving the pre-#2853 clean-output behaviour on the template path.
|
|
554
|
+
const planCountPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)(\\d+\\s*\\/\\s*\\d+\\s+plans(?:\\s+(?:complete|executed))?|\\d+\\s+plans)?([^\\r\\n]*)`, 'i');
|
|
530
555
|
const planCountText = isComplete
|
|
531
556
|
? `${summaryCount}/${planCount} plans complete`
|
|
532
557
|
: `${summaryCount}/${planCount} plans executed`;
|
|
533
|
-
roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern,
|
|
558
|
+
roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, (_match, label, existingCount, trailing) => {
|
|
559
|
+
// Preserve trailing text only when a real count preceded it.
|
|
560
|
+
const suffix = existingCount ? trailing : '';
|
|
561
|
+
return `${label}${planCountText}${suffix}`;
|
|
562
|
+
});
|
|
534
563
|
// If complete: check checkbox
|
|
535
564
|
if (isComplete) {
|
|
536
565
|
const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i');
|
|
@@ -628,6 +657,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
628
657
|
summary_count: summaryCount,
|
|
629
658
|
status,
|
|
630
659
|
complete: isComplete,
|
|
660
|
+
verification_stale_check_indeterminate: verificationStaleCheckIndeterminate,
|
|
631
661
|
}, raw, `${summaryCount}/${planCount} ${status}`);
|
|
632
662
|
}
|
|
633
663
|
// ─── cmdRoadmapAnnotateDependencies ───────────────────────────────────────────
|