@opengsd/gsd-core 1.14.0 → 1.15.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/README.ja-JP.md +3 -3
- package/README.ko-KR.md +3 -3
- package/README.pt-BR.md +3 -3
- package/README.zh-CN.md +3 -3
- package/agents/gsd-code-fixer.compact.md +7 -6
- package/agents/gsd-code-fixer.md +9 -8
- package/agents/gsd-debug-session-manager.compact.md +17 -2
- package/agents/gsd-debug-session-manager.md +17 -2
- package/agents/gsd-debugger.md +2 -2
- package/agents/gsd-eval-auditor.compact.md +1 -1
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +13 -8
- package/agents/gsd-intel-updater.compact.md +1 -1
- package/agents/gsd-intel-updater.md +1 -1
- package/agents/gsd-phase-researcher.md +19 -11
- package/agents/gsd-plan-checker.md +8 -7
- package/agents/gsd-planner.md +12 -8
- package/agents/gsd-project-researcher.compact.md +1 -1
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-research-synthesizer.compact.md +1 -1
- package/agents/gsd-research-synthesizer.md +1 -1
- package/agents/gsd-ui-auditor.md +155 -17
- package/agents/gsd-ui-researcher.compact.md +1 -1
- package/agents/gsd-ui-researcher.md +1 -1
- package/agents/gsd-verifier.md +10 -9
- package/bin/install.js +642 -95
- package/commands/gsd/autonomous.md +2 -2
- package/commands/gsd/capture.md +1 -1
- package/commands/gsd/mempalace-capture.md +7 -3
- package/commands/gsd/plan-review-convergence.md +6 -6
- package/commands/gsd/progress.md +1 -1
- package/commands/gsd/quick-batch.md +1 -1
- package/commands/gsd/review.md +2 -3
- package/gsd-core/bin/gsd-tools.cjs +335 -22
- package/gsd-core/bin/lib/adr-parser.cjs +3 -1
- package/gsd-core/bin/lib/audit.cjs +81 -13
- package/gsd-core/bin/lib/capability-registry.cjs +82 -187
- package/gsd-core/bin/lib/capability-validator.cjs +0 -1
- package/gsd-core/bin/lib/check-command-router.cjs +101 -14
- package/gsd-core/bin/lib/codex-agent-toml.cjs +21 -25
- package/gsd-core/bin/lib/commands.cjs +175 -42
- package/gsd-core/bin/lib/config-loader.cjs +65 -4
- package/gsd-core/bin/lib/config.cjs +33 -7
- package/gsd-core/bin/lib/decisions.cjs +30 -14
- package/gsd-core/bin/lib/frontmatter.cjs +13 -0
- package/gsd-core/bin/lib/graphify.cjs +10 -2
- package/gsd-core/bin/lib/host-runtime-detection.cjs +9 -0
- package/gsd-core/bin/lib/init.cjs +207 -41
- package/gsd-core/bin/lib/install-engine.cjs +13 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +8 -1
- package/gsd-core/bin/lib/milestone.cjs +18 -5
- package/gsd-core/bin/lib/model-resolver.cjs +159 -50
- package/gsd-core/bin/lib/phase-command-router.cjs +9 -1
- package/gsd-core/bin/lib/phase-id-card.cjs +32 -0
- package/gsd-core/bin/lib/phase-id-display.cjs +78 -0
- package/gsd-core/bin/lib/phase-id.cjs +109 -7
- package/gsd-core/bin/lib/phase-locator.cjs +29 -10
- package/gsd-core/bin/lib/phase.cjs +227 -26
- package/gsd-core/bin/lib/plan-document.cjs +49 -1
- package/gsd-core/bin/lib/planning-document.cjs +459 -0
- package/gsd-core/bin/lib/planning-inspect.cjs +18 -1
- package/gsd-core/bin/lib/planning-workspace.cjs +8 -3
- package/gsd-core/bin/lib/pr-branch-patterns.cjs +57 -0
- package/gsd-core/bin/lib/probe-core.cjs +7 -1
- package/gsd-core/bin/lib/project-root.cjs +41 -2
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +10 -30
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +2 -2
- package/gsd-core/bin/lib/roadmap-command-router.cjs +12 -4
- package/gsd-core/bin/lib/roadmap-parser.cjs +163 -3
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +1539 -13
- package/gsd-core/bin/lib/roadmap.cjs +251 -31
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +283 -31
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -1
- package/gsd-core/bin/lib/runtime-homes.cjs +4 -0
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -33
- package/gsd-core/bin/lib/runtime-name-policy.cjs +111 -1
- package/gsd-core/bin/lib/shell-command-projection.cjs +10 -6
- package/gsd-core/bin/lib/state-transition.cjs +39 -2
- package/gsd-core/bin/lib/state.cjs +42 -0
- package/gsd-core/bin/lib/surface.cjs +17 -1
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +78 -5
- package/gsd-core/bin/lib/uat-predicate.cjs +47 -4
- package/gsd-core/bin/lib/uat.cjs +8 -0
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +15 -2
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +100 -9
- package/gsd-core/bin/lib/undo-commit-selection.cjs +131 -0
- package/gsd-core/bin/lib/verification.cjs +268 -15
- package/gsd-core/bin/lib/verify-command-grounding.cjs +46 -2
- package/gsd-core/bin/lib/verify.cjs +132 -25
- package/gsd-core/bin/lib/worktree-base-ref.cjs +482 -73
- package/gsd-core/bin/lib/worktree-safety.cjs +784 -51
- package/gsd-core/bin/shared/config-defaults.manifest.json +3 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
- package/gsd-core/references/checkpoints.md +5 -3
- package/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +28 -3
- package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +37 -4
- package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +28 -3
- package/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +28 -3
- package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +37 -4
- package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +37 -4
- package/gsd-core/references/edge-probe.md +195 -21
- package/gsd-core/references/execute-phase-between-wave-reset.md +7 -6
- package/gsd-core/references/execute-phase-wave-guard.md +22 -11
- package/gsd-core/references/gsd-run-resolver.md +1 -1
- package/gsd-core/references/model-profiles.md +1 -1
- package/gsd-core/references/phase-argument-parsing.md +9 -7
- package/gsd-core/references/phase-id-convention.md +28 -0
- package/gsd-core/references/planner-gap-closure.md +2 -0
- package/gsd-core/references/planner-load-graph-context.md +24 -13
- package/gsd-core/references/planner-verify-command-grounding.md +14 -0
- package/gsd-core/references/planning-config.md +11 -2
- package/gsd-core/references/tdd.md +27 -4
- package/gsd-core/references/ui-consideration-probe.md +10 -5
- package/gsd-core/references/verify-command-path-resolvability.md +10 -2
- package/gsd-core/references/worktree-path-safety.md +321 -0
- package/gsd-core/templates/verification-report.md +1 -1
- package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/gsd-core/workflows/add-backlog.md +1 -1
- package/gsd-core/workflows/add-phase.md +1 -1
- package/gsd-core/workflows/add-tests.md +2 -2
- package/gsd-core/workflows/add-todo.md +3 -3
- package/gsd-core/workflows/ai-integration-phase.md +11 -3
- package/gsd-core/workflows/audit-fix.md +1 -1
- package/gsd-core/workflows/audit-milestone.md +1 -1
- package/gsd-core/workflows/audit-uat.md +1 -1
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +9 -18
- package/gsd-core/workflows/autonomous.md +16 -6
- package/gsd-core/workflows/check-todos.md +2 -2
- package/gsd-core/workflows/cleanup.md +2 -2
- package/gsd-core/workflows/code-review/steps/dispatch-fix.md +4 -3
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +1 -1
- package/gsd-core/workflows/code-review-fix.md +108 -22
- package/gsd-core/workflows/code-review.md +63 -46
- package/gsd-core/workflows/complete-milestone/detail/elaboration.md +1 -1
- package/gsd-core/workflows/complete-milestone.md +2 -2
- package/gsd-core/workflows/debug.md +3 -3
- package/gsd-core/workflows/diagnose-issues.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
- package/gsd-core/workflows/discuss-phase.md +1 -1
- package/gsd-core/workflows/do.md +2 -2
- package/gsd-core/workflows/docs-update.md +3 -3
- package/gsd-core/workflows/edit-phase.md +1 -1
- package/gsd-core/workflows/eval-review.md +10 -3
- package/gsd-core/workflows/execute-phase/detail/elaboration.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/code-review-disposition.md +1017 -0
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +3 -3
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +37 -3
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +46 -9
- package/gsd-core/workflows/execute-phase/steps/protected-branch.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/ready-wave-gate.md +37 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/stale-reverification.md +24 -0
- package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/threat-id-gate.md +28 -0
- package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/worktree-base-check.md +25 -0
- package/gsd-core/workflows/execute-phase.md +36 -26
- package/gsd-core/workflows/execute-plan.md +5 -4
- package/gsd-core/workflows/explore.md +3 -3
- package/gsd-core/workflows/extract-learnings.md +2 -1
- package/gsd-core/workflows/fast.md +1 -1
- package/gsd-core/workflows/forensics.md +1 -1
- package/gsd-core/workflows/graduation.md +1 -1
- package/gsd-core/workflows/health.md +2 -2
- package/gsd-core/workflows/help/modes/full.compact.md +3 -3
- package/gsd-core/workflows/help/modes/full.md +5 -5
- package/gsd-core/workflows/help/modes/topic.md +15 -5
- package/gsd-core/workflows/import.md +2 -2
- package/gsd-core/workflows/inbox.md +2 -2
- package/gsd-core/workflows/ingest-docs.md +3 -3
- package/gsd-core/workflows/insert-phase.md +1 -1
- package/gsd-core/workflows/list-seeds.md +1 -1
- package/gsd-core/workflows/list-workspaces.md +1 -1
- package/gsd-core/workflows/manager.md +2 -2
- package/gsd-core/workflows/map-codebase.md +2 -2
- package/gsd-core/workflows/milestone-summary.md +1 -1
- package/gsd-core/workflows/mvp-phase.md +1 -1
- package/gsd-core/workflows/new-milestone.md +2 -2
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +3 -3
- package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +1 -1
- package/gsd-core/workflows/new-project.md +7 -7
- package/gsd-core/workflows/new-workspace.md +2 -2
- package/gsd-core/workflows/next.md +1 -1
- package/gsd-core/workflows/note.md +1 -1
- package/gsd-core/workflows/onboard.md +1 -1
- package/gsd-core/workflows/pause-work.md +1 -1
- package/gsd-core/workflows/plan-phase/detail/elaboration.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +17 -5
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +23 -5
- package/gsd-core/workflows/plan-phase.md +24 -7
- package/gsd-core/workflows/plan-review-convergence.md +21 -5
- package/gsd-core/workflows/plant-seed.md +62 -20
- package/gsd-core/workflows/pr-branch.md +113 -13
- package/gsd-core/workflows/profile-user.md +2 -2
- package/gsd-core/workflows/progress.md +1 -1
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +25 -0
- package/gsd-core/workflows/quick/steps/quick-verification.md +1 -1
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/batch-init.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/completion.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/merge-wave.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/planner-wave.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/research-phase.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/resume-mode.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/verification-wave.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +1 -1
- package/gsd-core/workflows/quick-batch.md +1 -1
- package/gsd-core/workflows/quick.md +21 -9
- package/gsd-core/workflows/reapply-patches.md +9 -3
- package/gsd-core/workflows/remove-phase.md +1 -1
- package/gsd-core/workflows/remove-workspace.md +2 -2
- package/gsd-core/workflows/resume-project.md +1 -1
- package/gsd-core/workflows/review.md +31 -16
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +3 -2
- package/gsd-core/workflows/settings-advanced.md +30 -10
- package/gsd-core/workflows/settings-integrations.md +2 -3
- package/gsd-core/workflows/settings.md +4 -4
- package/gsd-core/workflows/ship.md +3 -2
- package/gsd-core/workflows/sketch-wrap-up.md +1 -1
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/smart-entry.md +2 -2
- package/gsd-core/workflows/spec-phase.md +15 -5
- package/gsd-core/workflows/spike-wrap-up.md +1 -1
- package/gsd-core/workflows/spike.md +1 -1
- package/gsd-core/workflows/stats.md +1 -1
- package/gsd-core/workflows/sync-skills.md +5 -5
- package/gsd-core/workflows/thread.md +1 -1
- package/gsd-core/workflows/transition.md +1 -1
- package/gsd-core/workflows/ui-phase.md +44 -8
- package/gsd-core/workflows/ui-review.md +18 -4
- package/gsd-core/workflows/ultraplan-phase.md +1 -1
- package/gsd-core/workflows/undo.md +339 -20
- package/gsd-core/workflows/update.md +7 -7
- package/gsd-core/workflows/validate-phase.md +3 -2
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +1 -1
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
- package/gsd-core/workflows/verify-work.md +81 -16
- package/hooks/dist/gsd-agent-isolation-guard.js +24 -0
- package/hooks/dist/gsd-secret-read-guard.js +27 -1
- package/hooks/dist/gsd-statusline.js +70 -13
- package/hooks/dist/gsd-validate-commit.sh +63 -4
- package/hooks/gsd-agent-isolation-guard.js +24 -0
- package/hooks/gsd-secret-read-guard.js +27 -1
- package/hooks/gsd-statusline.js +70 -13
- package/hooks/gsd-validate-commit.sh +63 -4
- package/package.json +3 -2
- package/scripts/build-hooks.js +15 -6
- package/scripts/check-contract-drift.cjs +127 -11
- package/scripts/command-contract-helpers.cjs +3 -0
- package/scripts/docs-guard-registry.cjs +28 -0
- package/scripts/gen-loop-host-contract.cjs +69 -0
- package/scripts/lib/macos-conformance-tier.generated.cjs +14 -0
- package/scripts/lib/ndjson-reporter.cjs +3 -2
- package/scripts/lib/platform-conformance-tier.generated.cjs +11 -0
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +28 -1
- package/scripts/lint-phase-arg-assignment.cjs +257 -0
- package/scripts/lint-phase-id-drift.cjs +290 -5
- package/scripts/lint-pr-branch-pattern-drift.cjs +148 -0
- package/scripts/lint-retired-runtime-name.cjs +619 -0
- package/scripts/lint-state-write-path-drift.cjs +93 -0
- package/scripts/lint-test-file-count.allowlist.json +28 -9
- package/scripts/lint-workflow-shellcheck-baseline.json +15 -0
- package/scripts/prompt-injection-scan.sh +4 -0
- package/scripts/release-tarball-smoke.cjs +194 -1
- package/skills/gsd-autonomous/SKILL.md +2 -2
- package/skills/gsd-capture/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +7 -3
- package/skills/gsd-plan-review-convergence/SKILL.md +5 -5
- package/skills/gsd-progress/SKILL.md +1 -1
- package/skills/gsd-quick-batch/SKILL.md +1 -1
- package/skills/gsd-review/SKILL.md +2 -3
- package/vscode/package.json +1 -1
|
@@ -269,5 +269,53 @@ function parsePlanDocument(content, planPath = '') {
|
|
|
269
269
|
taskCount: tasks.length,
|
|
270
270
|
};
|
|
271
271
|
}
|
|
272
|
-
const planDocument = { TASK_KIND, parsePlanDocument, planIdFromFile };
|
|
272
|
+
const planDocument = { TASK_KIND, parsePlanDocument, planIdFromFile, extractThreatRegisterIds };
|
|
273
|
+
/**
|
|
274
|
+
* #4683 — first-cell IDs of the STRIDE register rows inside every
|
|
275
|
+
* `<threat_model>` block. The register is a markdown table (the
|
|
276
|
+
* `<threat_model>` template in agents/gsd-planner.md): one row per threat,
|
|
277
|
+
* first cell `T-{phase}-NN` — decimal phases included — or the reserved
|
|
278
|
+
* `T-{phase}-SC` supply-chain row. Only digit-suffixed IDs match: `-SC` is
|
|
279
|
+
* deliberately shared by EVERY plan in a phase (planner rule "Keep
|
|
280
|
+
* `T-{phase}-SC` in `<threat_model>`"), so it can never be a uniqueness
|
|
281
|
+
* violation. IDs in prose or non-threat tables never count; only register
|
|
282
|
+
* rows inside a threat_model block do. One entry per matched row, in document
|
|
283
|
+
* order — deciding that the same ID in two plans is a collision is the
|
|
284
|
+
* aggregator's question (init.cts), not the per-document parser's.
|
|
285
|
+
*
|
|
286
|
+
* Knowingly unmatched residual classes (#4683 review, accepted): lowercase
|
|
287
|
+
* `t-47-01`, letter suffixes (`T-47-05A`), annotated first cells
|
|
288
|
+
* (`| T-47-06 (revised) |`), IDs in non-first cells, and an unterminated
|
|
289
|
+
* `<threat_model>` block all yield no claim. All are off-template shapes — the
|
|
290
|
+
* planner template fixes the row grammar — so the residual risk is silent
|
|
291
|
+
* under-detection, never a false hard-stop.
|
|
292
|
+
*/
|
|
293
|
+
const THREAT_MODEL_BLOCK_RE = /<threat_model>([\s\S]*?)<\/threat_model>/gi;
|
|
294
|
+
const THREAT_REGISTER_ROW_RE = /^[^\S\n]*\|[^\S\n]*(T-\d+(?:\.\d+)?-\d+)[^\S\n]*\|/;
|
|
295
|
+
function extractThreatRegisterIds(content) {
|
|
296
|
+
// Fenced code blocks are prose, not registers (#4683 review MAJOR): a plan
|
|
297
|
+
// that QUOTES an existing register — exactly what the gap-closure flow tells
|
|
298
|
+
// the planner to read — must not have its quoted IDs counted as claims, or
|
|
299
|
+
// the execute-phase gate would hard-stop a correct phase. Same line-toggling
|
|
300
|
+
// idiom as the deferred-scope scan in phase.cts.
|
|
301
|
+
const lines = [];
|
|
302
|
+
let inFence = false;
|
|
303
|
+
for (const line of content.split(/\r?\n/)) {
|
|
304
|
+
if (/^\s*(?:```|~~~)/.test(line)) {
|
|
305
|
+
inFence = !inFence;
|
|
306
|
+
lines.push('');
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
lines.push(inFence ? '' : line);
|
|
310
|
+
}
|
|
311
|
+
const ids = [];
|
|
312
|
+
for (const blockMatch of lines.join('\n').matchAll(THREAT_MODEL_BLOCK_RE)) {
|
|
313
|
+
for (const line of blockMatch[1].split('\n')) {
|
|
314
|
+
const row = line.match(THREAT_REGISTER_ROW_RE);
|
|
315
|
+
if (row)
|
|
316
|
+
ids.push(row[1]);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return ids;
|
|
320
|
+
}
|
|
273
321
|
module.exports = planDocument;
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Planning Document — the parse -> mutate -> serialize seam for a `.planning/`
|
|
4
|
+
* root artifact BODY (ADR-4910, epic #4906 Phase 1, #4917).
|
|
5
|
+
*
|
|
6
|
+
* Composes the existing structural seams — never reimplements them:
|
|
7
|
+
* - `markdown-sectionizer.cjs` (`tokenizeHeadings`, `collectSections`,
|
|
8
|
+
* `scanFencedBlocks`, `scanInlineCodeSpans`) for headings/sections and
|
|
9
|
+
* fence/inline-code awareness.
|
|
10
|
+
* - `markdown-table.cjs` (`splitTableRow`, `isDelimiterRow`,
|
|
11
|
+
* `parseMarkdownTable`) for GFM table detection and validation.
|
|
12
|
+
* - `artifacts.cjs` (`isCanonicalPlanningFile`) for the artifact-kind gate.
|
|
13
|
+
*
|
|
14
|
+
* This phase migrates NO call site — it is purely additive (ADR-4910 §7).
|
|
15
|
+
* Only `boldField` nodes are writable; `table`/`checklist` nodes parse and
|
|
16
|
+
* read only. Phase 3 (#4958) checked its own evidence (#4736, #4793) and
|
|
17
|
+
* found neither needed a table/checklist writer here — see ADR-4910's
|
|
18
|
+
* 2026-09-24 amendment. A writer for either kind is unclaimed until a real
|
|
19
|
+
* call site names it.
|
|
20
|
+
*
|
|
21
|
+
* Hyrum's Law commitment (row 3 of the design's behaviour table): `serialize`
|
|
22
|
+
* with zero staged edits returns `doc.source` BYTE-IDENTICAL — never a
|
|
23
|
+
* re-render (#4499's root cause). Every byte outside an edited `valueSpan` is
|
|
24
|
+
* the ORIGINAL source, spliced, never regenerated.
|
|
25
|
+
*
|
|
26
|
+
* ADR-457 build-at-publish: source in src/planning-document.cts, compiled to
|
|
27
|
+
* gsd-core/bin/lib/planning-document.cjs (gitignored).
|
|
28
|
+
*/
|
|
29
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
30
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
31
|
+
};
|
|
32
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
|
+
exports.PLANNING_ARTIFACTS = void 0;
|
|
34
|
+
exports.parsePlanningDoc = parsePlanningDoc;
|
|
35
|
+
exports.findField = findField;
|
|
36
|
+
exports.readNode = readNode;
|
|
37
|
+
exports.setFieldValue = setFieldValue;
|
|
38
|
+
exports.hasUnreadableNodes = hasUnreadableNodes;
|
|
39
|
+
exports.serialize = serialize;
|
|
40
|
+
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
41
|
+
const markdown_table_cjs_1 = require("./markdown-table.cjs");
|
|
42
|
+
const artifacts_cjs_1 = require("./artifacts.cjs");
|
|
43
|
+
// `frontmatter.cts` uses `export =` (CJS-style single export object), so it
|
|
44
|
+
// is imported as a default import (esModuleInterop), not a named import.
|
|
45
|
+
const frontmatter_cjs_1 = __importDefault(require("./frontmatter.cjs"));
|
|
46
|
+
const { frontmatterRegion } = frontmatter_cjs_1.default;
|
|
47
|
+
/**
|
|
48
|
+
* Canonical `.planning/` root artifact basenames this seam recognises,
|
|
49
|
+
* derived from the SAME registry `isCanonicalPlanningFile` consults
|
|
50
|
+
* (`artifacts.cts`'s `CANONICAL_EXACT`) — never a second, independently
|
|
51
|
+
* maintained list.
|
|
52
|
+
*
|
|
53
|
+
* Filtered to `.md` names only: `CANONICAL_EXACT` also carries non-markdown
|
|
54
|
+
* artifacts (`config.json`, `state.json`, `milestone.lock`, …) that this
|
|
55
|
+
* parser has no grammar for. Handing that JSON/lock content to the markdown
|
|
56
|
+
* parser below returns a successful EMPTY document (`nodes: []`), which reads
|
|
57
|
+
* as "this document records nothing" when the truth is "wrong kind entirely"
|
|
58
|
+
* — the empty-vs-error confusion #4917 / ADR-4910 §5 exists to eliminate. Do
|
|
59
|
+
* NOT remove this filter to "restore" the full registry.
|
|
60
|
+
*/
|
|
61
|
+
exports.PLANNING_ARTIFACTS = Object.freeze(Array.from(artifacts_cjs_1.CANONICAL_EXACT).filter((name) => name.endsWith('.md')));
|
|
62
|
+
// ─── Internal helpers ───────────────────────────────────────────────────────
|
|
63
|
+
let nodeCounter = 0;
|
|
64
|
+
function mintId(kind) {
|
|
65
|
+
nodeCounter += 1;
|
|
66
|
+
return `${kind}-${nodeCounter}-${Math.random().toString(36).slice(2, 8)}`;
|
|
67
|
+
}
|
|
68
|
+
function splitLinesInfo(source) {
|
|
69
|
+
const out = [];
|
|
70
|
+
let offset = 0;
|
|
71
|
+
const rawLines = source.split('\n');
|
|
72
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
73
|
+
const raw = rawLines[i];
|
|
74
|
+
const hasCR = raw.endsWith('\r');
|
|
75
|
+
const text = hasCR ? raw.slice(0, -1) : raw;
|
|
76
|
+
out.push({ text, start: offset, end: offset + text.length });
|
|
77
|
+
offset += raw.length + 1; // +1 for the '\n' split on ('\r' already counted in raw.length)
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Locate the frontmatter block, if any, by COMPOSING `frontmatter.cts`'s
|
|
83
|
+
* `frontmatterRegion` — the fence-detection grammar (byte-0 rule, BOM strip,
|
|
84
|
+
* `\n---` search, CR handling) lives there, once, and this seam never
|
|
85
|
+
* re-derives it (ADR-4910 Decision 1).
|
|
86
|
+
*
|
|
87
|
+
* `frontmatterRegion` reports the YAML body's own bounds (`region`,
|
|
88
|
+
* `terminated`, and the possibly BOM-stripped `content`), not this seam's
|
|
89
|
+
* `Span` shape (an absolute byte range into the UNSTRIPPED `source`,
|
|
90
|
+
* inclusive of both fences). This adapter translates one into the other by
|
|
91
|
+
* reading ONLY the two boundary characters `frontmatterRegion` already
|
|
92
|
+
* anchored (whether the YAML end / closing fence sit on a CRLF line) — it
|
|
93
|
+
* does not re-scan for the fences themselves.
|
|
94
|
+
*/
|
|
95
|
+
function findFrontmatterSpan(source) {
|
|
96
|
+
const found = frontmatterRegion(source);
|
|
97
|
+
if (!found)
|
|
98
|
+
return null;
|
|
99
|
+
// `found.content` may be `source` with a single leading BOM stripped;
|
|
100
|
+
// every offset below is relative to `found.content`, so translate back to
|
|
101
|
+
// `source` coordinates by the same delta.
|
|
102
|
+
const bomDelta = source.length - found.content.length;
|
|
103
|
+
const content = found.content;
|
|
104
|
+
if (!found.terminated) {
|
|
105
|
+
return { span: { start: bomDelta, end: bomDelta + content.length }, terminated: false };
|
|
106
|
+
}
|
|
107
|
+
// `frontmatterRegion` already did fence DETECTION — `found` being non-null
|
|
108
|
+
// and `terminated` IS that result. It reports only the YAML body's bounds
|
|
109
|
+
// (`region`), not an absolute span, so recover the closing fence's end
|
|
110
|
+
// from `region`'s length. The one thing still read directly here is the
|
|
111
|
+
// opening fence's fixed-width line ending (`\n` vs `\r\n`), needed to
|
|
112
|
+
// translate `region`'s length into a `content` offset — not a re-scan for
|
|
113
|
+
// the fence itself.
|
|
114
|
+
const headerEnd = content.startsWith('---\r\n') ? 5 : 4;
|
|
115
|
+
const yamlEnd = headerEnd + found.region.length;
|
|
116
|
+
const closingLineStart = content[yamlEnd] === '\r' ? yamlEnd + 1 : yamlEnd;
|
|
117
|
+
const fenceLineStart = closingLineStart + 1;
|
|
118
|
+
let fenceEnd = fenceLineStart + 3;
|
|
119
|
+
if (content[fenceEnd] === '\r')
|
|
120
|
+
fenceEnd += 1;
|
|
121
|
+
return { span: { start: bomDelta, end: bomDelta + fenceEnd }, terminated: true };
|
|
122
|
+
}
|
|
123
|
+
/** Build the set of 0-based line indices that fall inside a fenced code
|
|
124
|
+
* block (opening/closing delimiter lines included), so `**Label:**`/table/
|
|
125
|
+
* checklist scanning never treats fenced content as a node (rows 9/14). */
|
|
126
|
+
function fencedLineIndices(lines) {
|
|
127
|
+
const raw = lines.map((l) => l.text);
|
|
128
|
+
const blocks = (0, markdown_sectionizer_cjs_1.scanFencedBlocks)(raw);
|
|
129
|
+
const set = new Set();
|
|
130
|
+
for (const b of blocks) {
|
|
131
|
+
const end = b.closeLineIdx === -1 ? raw.length - 1 : b.closeLineIdx;
|
|
132
|
+
for (let i = b.openLineIdx; i <= end; i++)
|
|
133
|
+
set.add(i);
|
|
134
|
+
}
|
|
135
|
+
return set;
|
|
136
|
+
}
|
|
137
|
+
/** Matches both shipped bold-field spellings: colon-inside (`**Label:**`,
|
|
138
|
+
* the original grammar) and colon-outside (`**Label**:`, the canonical form
|
|
139
|
+
* used throughout `templates/roadmap.md`). Each alternative's trailing
|
|
140
|
+
* marker is exactly 3 characters (`:**` or `**:`), so `token.slice(2, -3)`
|
|
141
|
+
* in `parseBoldFieldLine` strips the leading `**` and the spelling-specific
|
|
142
|
+
* trailing marker identically for both, yielding the same `label` either
|
|
143
|
+
* way. Deliberately excludes a bare unbolded `Label:` form — see Phase 1's
|
|
144
|
+
* prose-vs-field disambiguation design. */
|
|
145
|
+
const BOLD_FIELD_RE = /^(\s*)(\*\*[^*\r\n]+(?::\*\*|\*\*:))([ \t]*)([^\r\n]*)$/;
|
|
146
|
+
/** Boundary marking a hand-written trailing annotation on a field line —
|
|
147
|
+
* the token owner must never destroy prose past this separator. */
|
|
148
|
+
const TRAILING_SEPARATOR_RE = / — /;
|
|
149
|
+
function parseBoldFieldLine(line) {
|
|
150
|
+
const m = BOLD_FIELD_RE.exec(line.text);
|
|
151
|
+
if (!m)
|
|
152
|
+
return null;
|
|
153
|
+
const [, leading, token, spacing, rest] = m;
|
|
154
|
+
const labelStart = line.start + leading.length;
|
|
155
|
+
const labelSpan = { start: labelStart, end: labelStart + token.length };
|
|
156
|
+
const label = token.slice(2, -3);
|
|
157
|
+
const restStart = labelSpan.end + spacing.length;
|
|
158
|
+
const sepMatch = TRAILING_SEPARATOR_RE.exec(rest);
|
|
159
|
+
const valueRaw = sepMatch ? rest.slice(0, sepMatch.index) : rest;
|
|
160
|
+
const trimmedValue = valueRaw.replace(/\s+$/, '');
|
|
161
|
+
const valueSpan = { start: restStart, end: restStart + trimmedValue.length };
|
|
162
|
+
const trailingSpan = { start: valueSpan.end, end: line.end };
|
|
163
|
+
return {
|
|
164
|
+
kind: 'boldField',
|
|
165
|
+
id: mintId('boldField'),
|
|
166
|
+
span: { start: labelSpan.start, end: line.end },
|
|
167
|
+
error: null,
|
|
168
|
+
label,
|
|
169
|
+
labelSpan,
|
|
170
|
+
valueSpan,
|
|
171
|
+
trailingSpan,
|
|
172
|
+
value: trimmedValue,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/** A checklist line is one whose SOLE bullet, per `iterateBullets` (the same
|
|
176
|
+
* grammar the repo's other bullet consumers use), is a checkbox marker, OR
|
|
177
|
+
* whose bullet TEXT begins with a task-list marker.
|
|
178
|
+
*
|
|
179
|
+
* `iterateBullets` owns bullet *structure* — is this a bullet, where does its
|
|
180
|
+
* text start — and continues to own that here unchanged. It only classifies
|
|
181
|
+
* `-`-prefixed bullets as `checkbox-checked`/`checkbox-unchecked`; GFM also
|
|
182
|
+
* permits `*` and `+` as bullet markers, and `* [ ] x` / `+ [x] y` are valid
|
|
183
|
+
* GFM task-list items that `iterateBullets` reports as plain `dash`-family
|
|
184
|
+
* bullets with the `[ ]`/`[x]` left in the bullet's own text. Widening
|
|
185
|
+
* `iterateBullets` itself is forbidden by ADR-2143 §2's extend-never-mutate
|
|
186
|
+
* lock (inherited by this epic), so the task-list-marker interpretation is
|
|
187
|
+
* layered on here, over the bullet's already-extracted text — never by
|
|
188
|
+
* re-scanning the raw line with a new hand-rolled regex.
|
|
189
|
+
*
|
|
190
|
+
* Known limit inherited from `iterateBullets`, not introduced here:
|
|
191
|
+
* `-\t[ ] text` (a tab between the marker and the text) is not recognised as
|
|
192
|
+
* a bullet at all, so it can never become a checklist line. That is a
|
|
193
|
+
* pre-existing `markdown-sectionizer` boundary affecting every consumer of
|
|
194
|
+
* `iterateBullets`, and fixing it would mean altering the locked seam. */
|
|
195
|
+
function isChecklistLine(text) {
|
|
196
|
+
const items = (0, markdown_sectionizer_cjs_1.iterateBullets)(text);
|
|
197
|
+
if (items.length !== 1)
|
|
198
|
+
return false;
|
|
199
|
+
const item = items[0];
|
|
200
|
+
if (item.marker === 'checkbox-checked' || item.marker === 'checkbox-unchecked')
|
|
201
|
+
return true;
|
|
202
|
+
return /^\[[ xX]\] /.test(item.text);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Scan the document body (everything outside the frontmatter block and
|
|
206
|
+
* outside fenced code) for `boldField`, `table`, and `checklist` nodes, in
|
|
207
|
+
* document order.
|
|
208
|
+
*/
|
|
209
|
+
function scanBodyNodes(source, lines, frontmatterEnd) {
|
|
210
|
+
const fenced = fencedLineIndices(lines);
|
|
211
|
+
const nodes = [];
|
|
212
|
+
let i = 0;
|
|
213
|
+
while (i < lines.length) {
|
|
214
|
+
const line = lines[i];
|
|
215
|
+
if (fenced.has(i) || line.start < frontmatterEnd) {
|
|
216
|
+
i += 1;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const trimmed = line.text.trim();
|
|
220
|
+
// Table: a pipe-shaped header line followed by a valid delimiter row.
|
|
221
|
+
if (trimmed.startsWith('|') && trimmed.indexOf('|', 1) !== -1 && i + 1 < lines.length) {
|
|
222
|
+
const delimiterLine = lines[i + 1];
|
|
223
|
+
const delimiterCells = (0, markdown_table_cjs_1.splitTableRow)(delimiterLine.text);
|
|
224
|
+
const headerCells = (0, markdown_table_cjs_1.splitTableRow)(line.text);
|
|
225
|
+
if (delimiterLine.text.trim().startsWith('|')
|
|
226
|
+
&& (0, markdown_table_cjs_1.isDelimiterRow)(delimiterCells)
|
|
227
|
+
&& delimiterCells.length === headerCells.length
|
|
228
|
+
&& !fenced.has(i + 1)) {
|
|
229
|
+
let last = i + 1;
|
|
230
|
+
while (last + 1 < lines.length && lines[last + 1].text.trim().startsWith('|') && !fenced.has(last + 1)) {
|
|
231
|
+
last += 1;
|
|
232
|
+
}
|
|
233
|
+
const span = { start: line.start, end: lines[last].end };
|
|
234
|
+
const tableText = source.slice(span.start, span.end);
|
|
235
|
+
const parsed = (0, markdown_table_cjs_1.parseMarkdownTable)(tableText);
|
|
236
|
+
nodes.push(parsed.ok
|
|
237
|
+
? {
|
|
238
|
+
kind: 'table',
|
|
239
|
+
id: mintId('table'),
|
|
240
|
+
span,
|
|
241
|
+
error: null,
|
|
242
|
+
columns: parsed.value.columns,
|
|
243
|
+
}
|
|
244
|
+
: {
|
|
245
|
+
kind: 'table',
|
|
246
|
+
id: mintId('table'),
|
|
247
|
+
span,
|
|
248
|
+
error: { reason: parsed.reason, span },
|
|
249
|
+
columns: null,
|
|
250
|
+
});
|
|
251
|
+
i = last + 1;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Checklist: a contiguous run of checkbox-bullet lines.
|
|
256
|
+
if (isChecklistLine(line.text)) {
|
|
257
|
+
let last = i;
|
|
258
|
+
let count = 0;
|
|
259
|
+
while (last < lines.length && !fenced.has(last) && isChecklistLine(lines[last].text)) {
|
|
260
|
+
count += 1;
|
|
261
|
+
last += 1;
|
|
262
|
+
}
|
|
263
|
+
last -= 1;
|
|
264
|
+
const span = { start: line.start, end: lines[last].end };
|
|
265
|
+
nodes.push({ kind: 'checklist', id: mintId('checklist'), span, error: null, items: count });
|
|
266
|
+
i = last + 1;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
// Bold field.
|
|
270
|
+
const field = parseBoldFieldLine(line);
|
|
271
|
+
if (field) {
|
|
272
|
+
nodes.push(field);
|
|
273
|
+
i += 1;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
i += 1;
|
|
277
|
+
}
|
|
278
|
+
return nodes;
|
|
279
|
+
}
|
|
280
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
281
|
+
/**
|
|
282
|
+
* Parse `source` (the raw text of a `.planning/` root artifact) into a
|
|
283
|
+
* `PlanningDoc`. Document-level `Result` failure is reserved for: `artifact`
|
|
284
|
+
* not a recognised planning artifact kind, `source` not a readable string, or
|
|
285
|
+
* an opened-but-never-closed frontmatter fence (ADR-4910 §5's reservation).
|
|
286
|
+
* A malformed SUB-structure (a ragged table, say) never fails the whole
|
|
287
|
+
* document — it is recorded as that one node's `error`, and every sibling
|
|
288
|
+
* node stays readable (row 7). `nodes: []` on a genuinely empty document is
|
|
289
|
+
* success, not an error (row 15).
|
|
290
|
+
*/
|
|
291
|
+
function parsePlanningDoc(source, artifact) {
|
|
292
|
+
if (typeof source !== 'string') {
|
|
293
|
+
return { ok: false, reason: 'unreadable: source is not a string' };
|
|
294
|
+
}
|
|
295
|
+
if (typeof artifact !== 'string' ||
|
|
296
|
+
!(0, artifacts_cjs_1.isCanonicalPlanningFile)(artifact) ||
|
|
297
|
+
!exports.PLANNING_ARTIFACTS.includes(artifact)) {
|
|
298
|
+
return {
|
|
299
|
+
ok: false,
|
|
300
|
+
reason: `not a markdown planning document (artifact: ${String(artifact)})`,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const nodes = [];
|
|
304
|
+
let frontmatterEnd = 0;
|
|
305
|
+
const fm = findFrontmatterSpan(source);
|
|
306
|
+
if (fm) {
|
|
307
|
+
if (!fm.terminated) {
|
|
308
|
+
return { ok: false, reason: 'no frontmatter terminator' };
|
|
309
|
+
}
|
|
310
|
+
nodes.push({ kind: 'frontmatter', id: mintId('frontmatter'), span: fm.span, error: null });
|
|
311
|
+
frontmatterEnd = fm.span.end;
|
|
312
|
+
}
|
|
313
|
+
if (source.length === 0) {
|
|
314
|
+
return { ok: true, value: { source, artifact, nodes: [], staged: new Map() } };
|
|
315
|
+
}
|
|
316
|
+
const lines = splitLinesInfo(source);
|
|
317
|
+
// Sections: one per heading, in document order — every heading is its own
|
|
318
|
+
// boundary (`collectSections(source, () => true)`), so a nested `####`
|
|
319
|
+
// still gets its own SectionNode rather than being folded into its parent.
|
|
320
|
+
const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(source);
|
|
321
|
+
if (headings.length > 0) {
|
|
322
|
+
const sections = (0, markdown_sectionizer_cjs_1.collectSections)(source, () => true);
|
|
323
|
+
for (const s of sections) {
|
|
324
|
+
nodes.push({
|
|
325
|
+
kind: 'section',
|
|
326
|
+
id: mintId('section'),
|
|
327
|
+
span: { start: s.heading.offset, end: s.bodyEnd },
|
|
328
|
+
error: null,
|
|
329
|
+
heading: s.heading.text,
|
|
330
|
+
level: s.heading.level,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
nodes.push(...scanBodyNodes(source, lines, frontmatterEnd));
|
|
335
|
+
nodes.sort((a, b) => a.span.start - b.span.start);
|
|
336
|
+
return { ok: true, value: { source, artifact, nodes, staged: new Map() } };
|
|
337
|
+
}
|
|
338
|
+
/** Find the id of the (first, document-order) `boldField` node whose label
|
|
339
|
+
* exactly matches `label`, or `null` when none does. */
|
|
340
|
+
function findField(doc, label) {
|
|
341
|
+
for (const n of doc.nodes) {
|
|
342
|
+
if (n.kind === 'boldField' && n.label === label)
|
|
343
|
+
return n.id;
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
/** Read a node by id. Node-scoped failure only — an unknown id or a node
|
|
348
|
+
* that failed to parse never throws. */
|
|
349
|
+
function readNode(doc, id) {
|
|
350
|
+
const node = doc.nodes.find((n) => n.id === id);
|
|
351
|
+
if (!node) {
|
|
352
|
+
return { ok: false, reason: 'unknown node id', span: { start: 0, end: 0 } };
|
|
353
|
+
}
|
|
354
|
+
if (node.error) {
|
|
355
|
+
return { ok: false, reason: node.error.reason, span: node.error.span };
|
|
356
|
+
}
|
|
357
|
+
if (node.kind === 'boldField') {
|
|
358
|
+
return { ok: true, value: doc.staged.get(id) ?? node.value };
|
|
359
|
+
}
|
|
360
|
+
return { ok: true, value: doc.source.slice(node.span.start, node.span.end) };
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Stage a new value for a `boldField` node, returning a NEW `PlanningDoc`
|
|
364
|
+
* (immutable — `doc` itself is never mutated). Refuses an id this doc did
|
|
365
|
+
* not mint, and refuses any node kind other than `boldField` — only the
|
|
366
|
+
* `valueSpan` is ever writable this phase (ADR-4910 §1).
|
|
367
|
+
*/
|
|
368
|
+
function setFieldValue(doc, id, value) {
|
|
369
|
+
const node = doc.nodes.find((n) => n.id === id);
|
|
370
|
+
if (!node) {
|
|
371
|
+
return { ok: false, reason: 'unknown node id' };
|
|
372
|
+
}
|
|
373
|
+
if (node.kind !== 'boldField') {
|
|
374
|
+
return { ok: false, reason: `node kind '${node.kind}' is not writable this phase` };
|
|
375
|
+
}
|
|
376
|
+
// #4917 / ADR-4910 Decision 2 & 4: a boldField's token boundary is a LINE
|
|
377
|
+
// boundary, not just an offset range — a value containing \n or \r escapes
|
|
378
|
+
// the field's own span and reparses as sibling structure (a forged field)
|
|
379
|
+
// once spliced back into the source. Decision 4 licenses refusal for any
|
|
380
|
+
// value the grammar cannot represent; Phase 3 may widen this to escaping,
|
|
381
|
+
// but Phase 1 refuses outright. Do not remove this as an over-restriction.
|
|
382
|
+
if (/[\r\n]/.test(value)) {
|
|
383
|
+
return { ok: false, reason: 'field value must not contain a line break (\\r or \\n)' };
|
|
384
|
+
}
|
|
385
|
+
// #4917 / ADR-4910 Decision 4: "a value that cannot be represented in the
|
|
386
|
+
// grammar is refused by the writer, with a report." This is a GENERAL
|
|
387
|
+
// round-trip representability check, not a blacklist of forbidden
|
|
388
|
+
// substrings — the `\r`/`\n` guard above is a narrower special case kept
|
|
389
|
+
// for its clearer message, but THIS check is the backstop. It rebuilds the
|
|
390
|
+
// line exactly as it would be written (existing leading/label/spacing +
|
|
391
|
+
// the new value + the existing trailing text) and re-parses that line
|
|
392
|
+
// through the SAME `parseBoldFieldLine` grammar the reader uses. If the
|
|
393
|
+
// value the grammar reads back is not byte-identical to what the caller
|
|
394
|
+
// staged, the grammar cannot represent this value (e.g. it contains the
|
|
395
|
+
// ` — ` trailing-separator token, which would silently reclassify the
|
|
396
|
+
// rest of the value as trailing prose) and the write is refused. Do NOT
|
|
397
|
+
// replace this with a list of forbidden characters/substrings — the next
|
|
398
|
+
// separator the grammar grows would silently slip past a blacklist.
|
|
399
|
+
const leadingText = doc.source.slice(node.span.start, node.labelSpan.start);
|
|
400
|
+
const tokenText = doc.source.slice(node.labelSpan.start, node.labelSpan.end);
|
|
401
|
+
const spacingText = doc.source.slice(node.labelSpan.end, node.valueSpan.start);
|
|
402
|
+
const trailingText = doc.source.slice(node.trailingSpan.start, node.trailingSpan.end);
|
|
403
|
+
const candidateLine = `${leadingText}${tokenText}${spacingText}${value}${trailingText}`;
|
|
404
|
+
const candidateInfo = { text: candidateLine, start: 0, end: candidateLine.length };
|
|
405
|
+
const reparsed = parseBoldFieldLine(candidateInfo);
|
|
406
|
+
if (!reparsed || reparsed.value !== value) {
|
|
407
|
+
return {
|
|
408
|
+
ok: false,
|
|
409
|
+
reason: 'field value is not representable in the boldField grammar (would not round-trip)',
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
const staged = new Map(doc.staged);
|
|
413
|
+
staged.set(id, value);
|
|
414
|
+
return { ok: true, value: { source: doc.source, artifact: doc.artifact, nodes: doc.nodes, staged } };
|
|
415
|
+
}
|
|
416
|
+
/** True when any node in `doc` failed to parse. */
|
|
417
|
+
function hasUnreadableNodes(doc) {
|
|
418
|
+
return doc.nodes.some((n) => n.error !== null);
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Splice every staged edit into `doc.source` and return the resulting text.
|
|
422
|
+
* With zero staged edits, returns `doc.source` BYTE-IDENTICAL — never a
|
|
423
|
+
* re-render (row 3). Refuses outright — even with zero staged edits — when
|
|
424
|
+
* `hasUnreadableNodes(doc)` is true (the ADR-4910 amendment): `serialize`
|
|
425
|
+
* re-emits the WHOLE document, so the refusal is document-scoped, not
|
|
426
|
+
* mutation-scoped.
|
|
427
|
+
*/
|
|
428
|
+
function serialize(doc) {
|
|
429
|
+
if (hasUnreadableNodes(doc)) {
|
|
430
|
+
return {
|
|
431
|
+
ok: false,
|
|
432
|
+
reason: 'unreadable-nodes',
|
|
433
|
+
nodes: doc.nodes
|
|
434
|
+
.filter((n) => n.error !== null)
|
|
435
|
+
.map((n) => ({ id: n.id, kind: n.kind, span: n.error.span, reason: n.error.reason })),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
if (doc.staged.size === 0) {
|
|
439
|
+
return { ok: true, value: doc.source };
|
|
440
|
+
}
|
|
441
|
+
const edits = [];
|
|
442
|
+
for (const [id, value] of doc.staged) {
|
|
443
|
+
const node = doc.nodes.find((n) => n.id === id);
|
|
444
|
+
if (!node || node.kind !== 'boldField')
|
|
445
|
+
continue; // unreachable: setFieldValue already gated this
|
|
446
|
+
edits.push({ start: node.valueSpan.start, end: node.valueSpan.end, value });
|
|
447
|
+
}
|
|
448
|
+
edits.sort((a, b) => a.start - b.start);
|
|
449
|
+
let out = '';
|
|
450
|
+
let cursor = 0;
|
|
451
|
+
for (const e of edits) {
|
|
452
|
+
out += doc.source.slice(cursor, e.start) + e.value;
|
|
453
|
+
cursor = e.end;
|
|
454
|
+
}
|
|
455
|
+
out += doc.source.slice(cursor);
|
|
456
|
+
return { ok: true, value: out };
|
|
457
|
+
}
|
|
458
|
+
// Consumers: require('../gsd-core/bin/lib/planning-document.cjs')
|
|
459
|
+
// Named CJS exports are the canonical surface (ADR-457 .cts → .cjs build-at-publish).
|
|
@@ -917,7 +917,24 @@ function extractDependencyTokens(sectionBody) {
|
|
|
917
917
|
const m = DEPENDS_ON_LINE_RE.exec(sectionBody);
|
|
918
918
|
if (!m)
|
|
919
919
|
return [];
|
|
920
|
-
|
|
920
|
+
// #4764: phase REFERENCES, not digit runs — the same prose-anchored grammar
|
|
921
|
+
// init.manager's dep_phases extraction uses (owner: phase-id.cts's
|
|
922
|
+
// PHASE_DEP_REF_SOURCE). The whole-field token scrape this replaces pulled
|
|
923
|
+
// calendar dates, git shas and ledger ids in as dependencies. The grammar's
|
|
924
|
+
// capture group 1 already excludes the "Phase(s)" anchor word, so no
|
|
925
|
+
// prefix-strip literal is needed here. Self-exclusion (init.manager drops
|
|
926
|
+
// the row's own number) is deliberately NOT applied: this reader has no row
|
|
927
|
+
// context at the extraction site and reports informationally, it does not
|
|
928
|
+
// gate.
|
|
929
|
+
const refRe = new RegExp(phaseIdMod.PHASE_DEP_REF_SOURCE, 'gi');
|
|
930
|
+
const tokenRe = new RegExp(phaseIdMod.PHASE_NUMBER_TOKEN_SOURCE, 'g');
|
|
931
|
+
const tokens = [];
|
|
932
|
+
let refMatch;
|
|
933
|
+
while ((refMatch = refRe.exec(m[1])) !== null) {
|
|
934
|
+
for (const t of refMatch[1].matchAll(tokenRe))
|
|
935
|
+
tokens.push(t[0]);
|
|
936
|
+
}
|
|
937
|
+
return sortedUnique(tokens);
|
|
921
938
|
}
|
|
922
939
|
/**
|
|
923
940
|
* This phase's own ROADMAP.md section body — milestone-scoped via the SAME
|
|
@@ -114,13 +114,18 @@ const PLANNING_LOCK_RETRY_ERRNOS = new Set([
|
|
|
114
114
|
* two-readers-two-bases lesson).
|
|
115
115
|
*/
|
|
116
116
|
function resolveEnvWorkstream() {
|
|
117
|
-
|
|
117
|
+
const value = process.env['GSD_WORKSTREAM']?.trim();
|
|
118
|
+
return value || null;
|
|
118
119
|
}
|
|
119
120
|
function planningDir(cwd, ws, project) {
|
|
120
121
|
if (project === undefined)
|
|
121
|
-
project = process.env['GSD_PROJECT']
|
|
122
|
+
project = process.env['GSD_PROJECT']?.trim() || null;
|
|
123
|
+
else if (typeof project === 'string')
|
|
124
|
+
project = project.trim() || null;
|
|
122
125
|
if (ws === undefined)
|
|
123
126
|
ws = resolveEnvWorkstream();
|
|
127
|
+
else if (typeof ws === 'string')
|
|
128
|
+
ws = ws.trim() || null;
|
|
124
129
|
// Reject path separators and traversal components in project/workstream names
|
|
125
130
|
const BAD_SEGMENT = /[/\\]|\.\./;
|
|
126
131
|
if (project && BAD_SEGMENT.test(project)) {
|
|
@@ -187,7 +192,7 @@ function worktreesOptedOutUnguarded(cwd) {
|
|
|
187
192
|
const scoped = ownKey(readCfg(node_path_1.default.join(planningDir(cwd), 'config.json')));
|
|
188
193
|
if (scoped.present)
|
|
189
194
|
return scoped.value === false;
|
|
190
|
-
if (
|
|
195
|
+
if (resolveEnvWorkstream() !== null) {
|
|
191
196
|
const root = ownKey(readCfg(node_path_1.default.join(planningRoot(cwd), 'config.json')));
|
|
192
197
|
if (root.present)
|
|
193
198
|
return root.value === false;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* PR-branch `.planning/` path classification patterns — single source of
|
|
4
|
+
* truth (ADR-4910 §8, epic #4906 Phase 5; issues #4605, #4606).
|
|
5
|
+
*
|
|
6
|
+
* `gsd-core/workflows/pr-branch.md` cannot `require()` this module at
|
|
7
|
+
* runtime — a workflow markdown file has no module system, and its bash
|
|
8
|
+
* steps are copy-pasted text executed by whatever shell interprets the
|
|
9
|
+
* workflow. So the three exported constants below are mirrored VERBATIM
|
|
10
|
+
* into that file's `TRANSIENT_DIRS=`, `STRUCTURAL_RE=`, and
|
|
11
|
+
* `MILESTONE_PHASES_RE=` bash variable assignments, and
|
|
12
|
+
* `scripts/lint-pr-branch-pattern-drift.cjs` asserts the mirror never
|
|
13
|
+
* drifts from this file (compiled to `gsd-core/bin/lib/pr-branch-patterns.cjs`)
|
|
14
|
+
* as part of `npm run lint:ci`. Change a value here, then update
|
|
15
|
+
* `gsd-core/workflows/pr-branch.md` to match, or the drift guard fails.
|
|
16
|
+
*
|
|
17
|
+
* This module does not read the filesystem and has no dependencies (a leaf
|
|
18
|
+
* module, mirroring `src/secrets.cts` / `src/planning-scope.cts`).
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.MILESTONE_PHASES_RE_SRC = exports.STRUCTURAL_RE_SRC = exports.TRANSIENT_DIRS_SRC = void 0;
|
|
22
|
+
/**
|
|
23
|
+
* Space-separated list of `.planning/` subdirectory names that are reviewer
|
|
24
|
+
* noise (PLAN.md, SUMMARY.md, CONTEXT.md, RESEARCH.md, and friends) —
|
|
25
|
+
* filtered out of a PR branch in BOTH default and strict mode.
|
|
26
|
+
*
|
|
27
|
+
* Mirrored verbatim into `gsd-core/workflows/pr-branch.md`'s
|
|
28
|
+
* `TRANSIENT_DIRS="..."` assignment.
|
|
29
|
+
*/
|
|
30
|
+
exports.TRANSIENT_DIRS_SRC = 'phases quick research threads todos debug seeds codebase ui-reviews';
|
|
31
|
+
/**
|
|
32
|
+
* Structural planning files — repository planning state, preserved in
|
|
33
|
+
* default mode and filtered out in strict mode. Anchored on both
|
|
34
|
+
* alternatives so `.planning/STATEX.md` and `.planning/STATE.md.bak` are
|
|
35
|
+
* NOT treated as structural. The `milestones` alternative matches only
|
|
36
|
+
* FILES directly under `.planning/milestones/` (e.g. `v1.0-ROADMAP.md`) —
|
|
37
|
+
* not a `<milestone>-phases/` subdirectory nested there. That subdirectory
|
|
38
|
+
* is reviewer noise, not structural state (#4605); it falls through to
|
|
39
|
+
* `MILESTONE_PHASES_RE_SRC` below instead.
|
|
40
|
+
*
|
|
41
|
+
* Mirrored verbatim into `gsd-core/workflows/pr-branch.md`'s
|
|
42
|
+
* `STRUCTURAL_RE="..."` assignment.
|
|
43
|
+
*/
|
|
44
|
+
exports.STRUCTURAL_RE_SRC = '^\\.planning/(STATE|ROADMAP|MILESTONES|PROJECT|REQUIREMENTS)\\.md$|^\\.planning/milestones/[^/]+\\.md$';
|
|
45
|
+
/**
|
|
46
|
+
* Milestone-scoped phase-plan directories — the same reviewer noise as
|
|
47
|
+
* `TRANSIENT_DIRS_SRC`'s `phases` entry, but nested per-milestone once a
|
|
48
|
+
* project has passed at least one milestone:
|
|
49
|
+
* `.planning/milestones/<milestone>-phases/`. The milestone slug (`v1.0`,
|
|
50
|
+
* `m2`, ...) varies per project, so this is declared as a shape, not a
|
|
51
|
+
* literal path — a single path segment standing in for the slug, anchored
|
|
52
|
+
* the same way `STRUCTURAL_RE_SRC`'s alternatives are (#4605).
|
|
53
|
+
*
|
|
54
|
+
* Mirrored verbatim into `gsd-core/workflows/pr-branch.md`'s
|
|
55
|
+
* `MILESTONE_PHASES_RE="..."` assignment.
|
|
56
|
+
*/
|
|
57
|
+
exports.MILESTONE_PHASES_RE_SRC = '^\\.planning/milestones/[^/]+-phases/';
|
|
@@ -234,6 +234,12 @@ function analyzeCoverage(items, resolutions = [], validators) {
|
|
|
234
234
|
const unresolved = merged.filter((i) => i.status === 'unresolved').length;
|
|
235
235
|
const applicable = merged.length;
|
|
236
236
|
const resolved = applicable - unresolved; // closed set: resolved-status + dismissed
|
|
237
|
+
// #4656: the unclassified soft-signal rows count toward `applicable` (the
|
|
238
|
+
// rollup is count-preserving and `resolved = applicable - unresolved` is a
|
|
239
|
+
// documented identity), so the count is exposed as a SIBLING field — the
|
|
240
|
+
// zero-applicable guards can then also fire when EVERY requirement is
|
|
241
|
+
// unclassified, the case the spec-phase/ui-phase docs promise to catch.
|
|
242
|
+
const unclassified = merged.filter((i) => i.category === 'unclassified').length;
|
|
237
243
|
const byVerification = {};
|
|
238
244
|
for (const tier of validators.verification)
|
|
239
245
|
byVerification[tier] = 0;
|
|
@@ -242,7 +248,7 @@ function analyzeCoverage(items, resolutions = [], validators) {
|
|
|
242
248
|
byVerification[i.verification] = (byVerification[i.verification] ?? 0) + 1;
|
|
243
249
|
}
|
|
244
250
|
}
|
|
245
|
-
return { items: merged, coverage: { applicable, resolved, unresolved, byVerification } };
|
|
251
|
+
return { items: merged, coverage: { applicable, resolved, unresolved, unclassified, byVerification } };
|
|
246
252
|
}
|
|
247
253
|
/**
|
|
248
254
|
* The prohibition adapter's injected runtime validators (ADR-550 #5). There is no closed
|