@opengsd/gsd-core 1.5.0-rc.3 → 1.5.0-rc.4

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.
Files changed (85) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-advisor-researcher.md +1 -1
  3. package/agents/gsd-assumptions-analyzer.md +1 -1
  4. package/agents/gsd-code-fixer.md +1 -1
  5. package/agents/gsd-code-reviewer.md +1 -1
  6. package/agents/gsd-codebase-mapper.md +1 -1
  7. package/agents/gsd-debugger.md +1 -1
  8. package/agents/gsd-doc-writer.md +1 -1
  9. package/agents/gsd-eval-auditor.md +1 -1
  10. package/agents/gsd-executor.md +1 -1
  11. package/agents/gsd-integration-checker.md +1 -1
  12. package/agents/gsd-nyquist-auditor.md +1 -0
  13. package/agents/gsd-phase-researcher.md +1 -1
  14. package/agents/gsd-plan-checker.md +1 -1
  15. package/agents/gsd-planner.md +1 -1
  16. package/agents/gsd-project-researcher.md +1 -1
  17. package/agents/gsd-research-synthesizer.md +1 -1
  18. package/agents/gsd-roadmapper.md +55 -2
  19. package/agents/gsd-security-auditor.md +1 -0
  20. package/agents/gsd-ui-auditor.md +1 -1
  21. package/agents/gsd-ui-checker.md +1 -1
  22. package/agents/gsd-ui-researcher.md +1 -1
  23. package/agents/gsd-verifier.md +13 -2
  24. package/bin/install.js +36 -57
  25. package/commands/gsd/progress.md +2 -1
  26. package/gemini-extension.json +1 -1
  27. package/gsd-core/bin/gsd-tools.cjs +167 -3
  28. package/gsd-core/bin/lib/active-workstream-store.cjs +6 -0
  29. package/gsd-core/bin/lib/capability-state.cjs +97 -3
  30. package/gsd-core/bin/lib/capability-writer.cjs +354 -0
  31. package/gsd-core/bin/lib/config.cjs +80 -24
  32. package/gsd-core/bin/lib/edge-probe.cjs +25 -2
  33. package/gsd-core/bin/lib/frontmatter.cjs +53 -1
  34. package/gsd-core/bin/lib/git-base-branch.cjs +194 -0
  35. package/gsd-core/bin/lib/init.cjs +28 -6
  36. package/gsd-core/bin/lib/install-profiles.cjs +55 -0
  37. package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
  38. package/gsd-core/bin/lib/phase.cjs +28 -12
  39. package/gsd-core/bin/lib/plan-drift-guard.cjs +117 -0
  40. package/gsd-core/bin/lib/probe-core.cjs +117 -1
  41. package/gsd-core/bin/lib/roadmap-parser.cjs +13 -3
  42. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +246 -0
  43. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +34 -2
  44. package/gsd-core/bin/lib/state.cjs +240 -59
  45. package/gsd-core/bin/lib/verify.cjs +73 -4
  46. package/gsd-core/bin/lib/worktree-safety.cjs +2 -1
  47. package/gsd-core/references/edge-probe.md +11 -0
  48. package/gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json +14 -0
  49. package/gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json +4 -0
  50. package/gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json +32 -0
  51. package/gsd-core/references/prohibition-probe.md +248 -0
  52. package/gsd-core/templates/spec.md +14 -0
  53. package/gsd-core/workflows/complete-milestone.md +1 -5
  54. package/gsd-core/workflows/execute-phase.md +4 -3
  55. package/gsd-core/workflows/execute-plan.md +21 -6
  56. package/gsd-core/workflows/help/modes/full.md +4 -0
  57. package/gsd-core/workflows/next.md +50 -2
  58. package/gsd-core/workflows/pause-work.md +7 -1
  59. package/gsd-core/workflows/plan-phase.md +2 -0
  60. package/gsd-core/workflows/plan-review-convergence.md +14 -4
  61. package/gsd-core/workflows/pr-branch.md +4 -2
  62. package/gsd-core/workflows/quick.md +3 -2
  63. package/gsd-core/workflows/resume-project.md +17 -1
  64. package/gsd-core/workflows/settings.md +27 -1
  65. package/gsd-core/workflows/ship.md +1 -5
  66. package/gsd-core/workflows/spec-phase.md +75 -0
  67. package/gsd-core/workflows/verify-phase.md +14 -4
  68. package/hooks/dist/gsd-ensure-canonical-path.js +305 -0
  69. package/hooks/dist/gsd-statusline.js +1 -1
  70. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  71. package/hooks/gsd-ensure-canonical-path.js +305 -0
  72. package/hooks/gsd-statusline.js +1 -1
  73. package/hooks/hooks.json +1 -0
  74. package/hooks/managed-hooks-registry.cjs +1 -0
  75. package/package.json +3 -3
  76. package/scripts/build-hooks.js +7 -0
  77. package/scripts/changeset/new.cjs +17 -3
  78. package/scripts/fix-slash-commands.cjs +15 -3
  79. package/scripts/gen-capability-registry.cjs +14 -1
  80. package/scripts/lint-allow-test-rule-refs.allowlist.json +2 -1
  81. package/scripts/lint-test-file-count.allowlist.json +6 -0
  82. package/scripts/mutation-matrix.cjs +108 -7
  83. package/scripts/pr-target-policy.cjs +63 -0
  84. package/scripts/research-profiles.cjs +5 -5
  85. package/scripts/run-tests.cjs +107 -6
@@ -0,0 +1,194 @@
1
+ "use strict";
2
+ /**
3
+ * Git Base-Branch Resolver — issue #1146.
4
+ *
5
+ * Single source of truth for detecting the repository's default branch.
6
+ * Replaces the duplicated per-workflow bash detection that only consulted
7
+ * `refs/remotes/origin/HEAD` then hardcoded `:-main`, which silently
8
+ * returned "main" for repos whose default branch is "master" whenever
9
+ * origin/HEAD was unset (git init + remote add / fetch without set-head /
10
+ * most CI checkouts / many worktrees).
11
+ *
12
+ * Precedence ladder (highest to lowest):
13
+ * 1. `git.base_branch` config override from .planning/config.json
14
+ * 2. `git symbolic-ref --short refs/remotes/origin/HEAD` (fast, no network)
15
+ * 3. `git remote show origin` HEAD branch ← AUTHORITATIVE; works when #2 unset
16
+ * 4. Local branch existence: "master" present + "main" absent → "master";
17
+ * "main" present → "main"
18
+ * 5. "main" (last-resort default)
19
+ *
20
+ * Every git subprocess is bounded with a timeout (≤ 30 s); on timeout/error
21
+ * the resolver degrades gracefully to the next tier — it never throws.
22
+ *
23
+ * Pure/testable: all I/O is injectable via the `deps` argument so unit
24
+ * tests can run without touching the real filesystem or spawning real git.
25
+ */
26
+ var __importDefault = (this && this.__importDefault) || function (mod) {
27
+ return (mod && mod.__esModule) ? mod : { "default": mod };
28
+ };
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.readConfigBaseBranch = readConfigBaseBranch;
31
+ exports.trySymbolicRef = trySymbolicRef;
32
+ exports.tryRemoteShow = tryRemoteShow;
33
+ exports.tryLocalBranch = tryLocalBranch;
34
+ exports.resolveBaseBranch = resolveBaseBranch;
35
+ exports.cmdGitBaseBranch = cmdGitBaseBranch;
36
+ const node_fs_1 = __importDefault(require("node:fs"));
37
+ const node_path_1 = __importDefault(require("node:path"));
38
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
39
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
40
+ /**
41
+ * Safely look up `git.base_branch` from the project's config.json.
42
+ * Returns the configured value (a non-empty, non-null string) or null.
43
+ */
44
+ function readConfigBaseBranch(planningDir, deps) {
45
+ const readFile = deps?.readFile ??
46
+ ((p) => { try {
47
+ return node_fs_1.default.readFileSync(p, 'utf8');
48
+ }
49
+ catch {
50
+ return null;
51
+ } });
52
+ const configPath = node_path_1.default.join(planningDir, 'config.json');
53
+ const raw = readFile(configPath);
54
+ if (!raw)
55
+ return null;
56
+ let cfg;
57
+ try {
58
+ cfg = JSON.parse(raw);
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg))
64
+ return null;
65
+ const top = cfg;
66
+ // Support both "git.base_branch" (nested) and "base_branch" (flat legacy)
67
+ const gitSection = top.git;
68
+ if (gitSection && typeof gitSection === 'object' && !Array.isArray(gitSection)) {
69
+ const nested = gitSection.base_branch;
70
+ if (typeof nested === 'string' && nested.trim())
71
+ return nested.trim();
72
+ }
73
+ const flat = top.base_branch;
74
+ if (typeof flat === 'string' && flat.trim())
75
+ return flat.trim();
76
+ return null;
77
+ }
78
+ /**
79
+ * Try `git symbolic-ref --short refs/remotes/origin/HEAD` (no network).
80
+ * Strips the `origin/` prefix to return just the branch name.
81
+ * Returns null if unset or on error/timeout.
82
+ */
83
+ function trySymbolicRef(cwd, execGit) {
84
+ try {
85
+ const r = execGit(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { cwd, timeout: 5_000 });
86
+ if (r.exitCode !== 0 || !r.stdout)
87
+ return null;
88
+ // Output is e.g. "origin/main" — strip the prefix
89
+ const branch = r.stdout.trim().replace(/^origin\//, '');
90
+ return branch || null;
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ /**
97
+ * Try `git remote show origin` to read the HEAD branch.
98
+ * This is authoritative when origin/HEAD is unset locally.
99
+ * Requires network access but succeeds in the common CI case where
100
+ * origin/HEAD was never set after `git init && git remote add origin`.
101
+ *
102
+ * Parses the line: `HEAD branch: <name>`
103
+ * Returns null on error, timeout, or if the output is malformed.
104
+ */
105
+ function tryRemoteShow(cwd, execGit) {
106
+ try {
107
+ const r = execGit(['remote', 'show', 'origin'], { cwd, timeout: 15_000 });
108
+ if (r.exitCode !== 0 || !r.stdout)
109
+ return null;
110
+ // The line looks like: " HEAD branch: master"
111
+ const m = r.stdout.match(/^\s*HEAD branch:\s*(\S+)\s*$/m);
112
+ if (!m)
113
+ return null;
114
+ const branch = m[1];
115
+ // git emits "(unknown)" when the remote is offline but the local cache
116
+ // resolved it; treat that as non-authoritative and fall through.
117
+ if (!branch || branch === '(unknown)')
118
+ return null;
119
+ return branch;
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ }
125
+ /**
126
+ * Detect local branch existence as a tie-breaker when no remote info is available.
127
+ *
128
+ * Rules:
129
+ * - "master" present AND "main" absent → "master"
130
+ * - "main" present → "main"
131
+ * - Neither → null (fall through to default)
132
+ *
133
+ * Returns null on error/timeout.
134
+ */
135
+ function tryLocalBranch(cwd, execGit) {
136
+ try {
137
+ const r = execGit(['branch', '--list', 'main', 'master'], { cwd, timeout: 5_000 });
138
+ if (r.exitCode !== 0 || !r.stdout)
139
+ return null;
140
+ // `git branch --list main master` outputs one line per matching branch
141
+ const lines = r.stdout.split('\n').map(l => l.trim().replace(/^\*\s*/, ''));
142
+ const hasMain = lines.includes('main');
143
+ const hasMaster = lines.includes('master');
144
+ if (hasMaster && !hasMain)
145
+ return 'master';
146
+ if (hasMain)
147
+ return 'main';
148
+ return null;
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ }
154
+ /**
155
+ * Resolve the default/base branch for the repository at `cwd`.
156
+ *
157
+ * Consults the full precedence ladder and always returns a non-empty string.
158
+ * Never throws.
159
+ */
160
+ function resolveBaseBranch(cwd, deps) {
161
+ const execGit = deps?.execGit ?? shell_command_projection_cjs_1.execGit;
162
+ // Derive .planning dir relative to cwd (mirrors planningDir() in core.cjs)
163
+ const planningDir = node_path_1.default.join(cwd, '.planning');
164
+ // 1. Config override
165
+ const configured = readConfigBaseBranch(planningDir, deps);
166
+ if (configured)
167
+ return configured;
168
+ // 2. symbolic-ref (fast, no network)
169
+ const symref = trySymbolicRef(cwd, execGit);
170
+ if (symref)
171
+ return symref;
172
+ // 3. git remote show origin (authoritative when origin/HEAD unset)
173
+ const remoteShow = tryRemoteShow(cwd, execGit);
174
+ if (remoteShow)
175
+ return remoteShow;
176
+ // 4. Local branch existence
177
+ const local = tryLocalBranch(cwd, execGit);
178
+ if (local)
179
+ return local;
180
+ // 5. Last-resort default
181
+ return 'main';
182
+ }
183
+ // ─── CLI entry point ──────────────────────────────────────────────────────────
184
+ /**
185
+ * CLI command: `gsd-tools git base-branch`
186
+ * Resolves the default branch and writes it to stdout (raw string, newline-terminated).
187
+ * Called by workflows via `gsd_run query git.base-branch`.
188
+ */
189
+ function cmdGitBaseBranch(cwd, _args, deps) {
190
+ const branch = resolveBaseBranch(cwd, deps);
191
+ const write = deps?.write ?? ((s) => process.stdout.write(s));
192
+ write(branch + '\n');
193
+ return branch;
194
+ }
@@ -1533,7 +1533,9 @@ function buildAgentSkillsBlock(config, agentType, projectRoot) {
1533
1533
  // It returns [] cheaply when no roots are configured, so the realpath cost only
1534
1534
  // occurs when the caller has actually set trusted_global_roots.
1535
1535
  const trustedGlobalRoots = (0, security_cjs_1.loadTrustedGlobalRoots)(config);
1536
- const validPaths = [];
1536
+ // Each entry is either a filesystem include ({ kind: 'include', ref, display }) or a
1537
+ // Skill-tool directive ({ kind: 'directive', name }) for plugin-provided namespaced skills.
1538
+ const validEntries = [];
1537
1539
  for (const skillPath of skillPaths) {
1538
1540
  if (typeof skillPath !== 'string')
1539
1541
  continue;
@@ -1543,10 +1545,25 @@ function buildAgentSkillsBlock(config, agentType, projectRoot) {
1543
1545
  process.stderr.write(`[agent-skills] WARNING: "global:" prefix with empty skill name — skipping\n`);
1544
1546
  continue;
1545
1547
  }
1546
- if (!/^[a-zA-Z0-9_-]+$/.test(skillName)) {
1548
+ // Accept: one or more [A-Za-z0-9_-]+ segments joined by single colons.
1549
+ // Rejects: empty segments (::), leading/trailing colon, dots, slashes, backslashes.
1550
+ if (!/^[A-Za-z0-9_-]+(:[A-Za-z0-9_-]+)*$/.test(skillName)) {
1547
1551
  process.stderr.write(`[agent-skills] WARNING: Invalid global skill name "${skillName}" — skipping\n`);
1548
1552
  continue;
1549
1553
  }
1554
+ const isNamespaced = skillName.includes(':');
1555
+ if (isNamespaced) {
1556
+ // Plugin-provided namespaced skill: no filesystem path exists locally.
1557
+ if (runtime === 'claude') {
1558
+ // Emit a natural-language Skill-tool directive (not a @-include).
1559
+ validEntries.push({ kind: 'directive', name: skillName });
1560
+ }
1561
+ else {
1562
+ process.stderr.write(`[agent-skills] WARNING: Plugin-namespaced skill "global:${skillName}" requires a Skill-tool-capable runtime (claude) — skipping on runtime "${runtime}"\n`);
1563
+ }
1564
+ continue;
1565
+ }
1566
+ // Non-namespaced bare name: attempt filesystem resolution as before.
1550
1567
  if (globalSkillsBase === null) {
1551
1568
  process.stderr.write(`[agent-skills] WARNING: Runtime "${runtime}" does not use a skills directory — "global:${skillName}" is not supported on this runtime\n`);
1552
1569
  continue;
@@ -1570,7 +1587,7 @@ function buildAgentSkillsBlock(config, agentType, projectRoot) {
1570
1587
  }
1571
1588
  process.stderr.write(`[agent-skills] NOTE: Global skill "${skillName}" accepted via trusted_global_roots (resolves outside the default skills dir)\n`);
1572
1589
  }
1573
- validPaths.push({ ref: `${globalSkillDir}/SKILL.md`, display: displayPath });
1590
+ validEntries.push({ kind: 'include', ref: `${globalSkillDir}/SKILL.md`, display: displayPath });
1574
1591
  continue;
1575
1592
  }
1576
1593
  const pathCheck = (0, security_cjs_1.validatePath)(skillPath, projectRoot);
@@ -1583,11 +1600,16 @@ function buildAgentSkillsBlock(config, agentType, projectRoot) {
1583
1600
  process.stderr.write(`[agent-skills] WARNING: Skill not found at "${skillPath}/SKILL.md" — skipping\n`);
1584
1601
  continue;
1585
1602
  }
1586
- validPaths.push({ ref: `${skillPath}/SKILL.md`, display: skillPath });
1603
+ validEntries.push({ kind: 'include', ref: `${skillPath}/SKILL.md`, display: skillPath });
1587
1604
  }
1588
- if (validPaths.length === 0)
1605
+ if (validEntries.length === 0)
1589
1606
  return '';
1590
- const lines = validPaths.map((p) => `- @${p.ref}`).join('\n');
1607
+ const lines = validEntries.map((entry) => {
1608
+ if (entry.kind === 'directive') {
1609
+ return `- Load the \`${entry.name}\` skill via the Skill tool before proceeding (plugin-provided).`;
1610
+ }
1611
+ return `- @${entry.ref}`;
1612
+ }).join('\n');
1591
1613
  return `<agent_skills>\nRead these user-configured skills:\n${lines}\n</agent_skills>`;
1592
1614
  }
1593
1615
  function cmdAgentSkills(cwd, agentType, raw, jsonMode) {
@@ -505,6 +505,60 @@ function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converte
505
505
  ensureExitCleanup();
506
506
  return stageDir;
507
507
  }
508
+ /**
509
+ * Stage a converted copy of the agents directory for a given runtime.
510
+ *
511
+ * Analogous to `stageCommandsForRuntimeFlat` but for agent `.md` files. Each
512
+ * source `.md` is passed through `converter` and written as a flat `${name}.md`
513
+ * file in the staging directory. Agent filenames are kept verbatim (no prefix
514
+ * added here — the prefix is already embedded in agent stems, e.g. `gsd-planner.md`).
515
+ *
516
+ * This is used by the descriptor-driven `dispatchKindEntry` when an `agents` kind
517
+ * entry carries a non-null converter (ADR-457 / #1173). When `converter` is null,
518
+ * `agentsKind` falls back to the existing raw-copy path (`stageAgentsForProfile`).
519
+ *
520
+ * For the `full` profile (`skills === '*'`), all `.md` files are staged.
521
+ * For tiered profiles, only agents whose full stem is in `resolvedProfile.agents`
522
+ * are staged (mirrors `stageAgentsForProfile` behaviour).
523
+ *
524
+ * @param srcAgentsDir source agents directory (e.g. agents/)
525
+ * @param resolvedProfile profile filter from resolveProfile()
526
+ * @param converter (content: string) → string pure per-file converter
527
+ */
528
+ function stageAgentsForRuntimeWithConverter(srcAgentsDir, resolvedProfile, converter) {
529
+ if (!node_fs_1.default.existsSync(srcAgentsDir))
530
+ return srcAgentsDir;
531
+ const stageDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'gsd-profile-runtime-agents-'));
532
+ try {
533
+ const entries = node_fs_1.default.readdirSync(srcAgentsDir, { withFileTypes: true });
534
+ for (const entry of entries) {
535
+ if (!entry.isFile())
536
+ continue;
537
+ if (!entry.name.endsWith('.md'))
538
+ continue;
539
+ // For tiered profiles, gate by agent stem (full filename without extension).
540
+ if (resolvedProfile.skills !== '*') {
541
+ const stem = entry.name.slice(0, -3);
542
+ if (!(resolvedProfile.agents instanceof Set && resolvedProfile.agents.has(stem))) {
543
+ continue;
544
+ }
545
+ }
546
+ const content = node_fs_1.default.readFileSync(node_path_1.default.join(srcAgentsDir, entry.name), 'utf8');
547
+ const converted = converter(content);
548
+ node_fs_1.default.writeFileSync(node_path_1.default.join(stageDir, entry.name), converted, 'utf8');
549
+ }
550
+ }
551
+ catch (err) {
552
+ try {
553
+ node_fs_1.default.rmSync(stageDir, { recursive: true, force: true });
554
+ }
555
+ catch { /* best-effort */ }
556
+ throw err;
557
+ }
558
+ STAGED_DIRS.add(stageDir);
559
+ ensureExitCleanup();
560
+ return stageDir;
561
+ }
508
562
  /**
509
563
  * Stage converted command files as flat `.md` files.
510
564
  *
@@ -723,6 +777,7 @@ module.exports = {
723
777
  mostRestrictiveProfile,
724
778
  stageSkillsForProfile,
725
779
  stageAgentsForProfile,
780
+ stageAgentsForRuntimeWithConverter,
726
781
  stageSkillsForRuntimeAsSkills,
727
782
  stageCommandsForRuntimeFlat,
728
783
  STAGED_DIRS,
@@ -38,6 +38,7 @@ exports.BUNDLED_GSD_HOOK_FILES = Object.freeze(new Set([
38
38
  'hooks/gsd-context-monitor.js',
39
39
  'hooks/gsd-cursor-post-tool.js',
40
40
  'hooks/gsd-cursor-session-start.js',
41
+ 'hooks/gsd-ensure-canonical-path.js',
41
42
  'hooks/gsd-graphify-update.sh',
42
43
  'hooks/gsd-phase-boundary.sh',
43
44
  'hooks/gsd-prompt-guard.js',
@@ -560,16 +560,29 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
560
560
  _dirName = `${prefix}${_newPhaseId}-${slug}`;
561
561
  }
562
562
  else {
563
- const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
564
- let maxPhase = 0;
563
+ // Collect all phase numbers visible in the current-milestone content.
564
+ // Three sources are scanned so that a phase in ANY representation
565
+ // (section header, roadmap bullet, or on-disk directory) is counted:
566
+ // 1) Section headers: ### Phase N: / ## Phase N: / #### Phase N:
567
+ const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
568
+ // 2) Roadmap bullet entries: - [ ] **Phase N: ...** (all checkbox variants)
569
+ // The lookahead accepts colon, decimal-dot, whitespace, bold-close asterisk,
570
+ // or end-of-line so titleless forms ("- [ ] **Phase 11**", "- [ ] Phase 11")
571
+ // are counted and cannot collide with a freshly-added phase. (#1229)
572
+ const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]*\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
573
+ const usedPhaseNums = new Set();
565
574
  let m;
566
- while ((m = phasePattern.exec(content)) !== null) {
575
+ while ((m = headerPattern.exec(content)) !== null) {
567
576
  const num = parseInt(m[1], 10);
568
- if (num === 999)
569
- continue;
570
- if (num > maxPhase)
571
- maxPhase = num;
577
+ if (num !== 999)
578
+ usedPhaseNums.add(num);
572
579
  }
580
+ while ((m = bulletPattern.exec(content)) !== null) {
581
+ const num = parseInt(m[1], 10);
582
+ if (num !== 999)
583
+ usedPhaseNums.add(num);
584
+ }
585
+ // 3) On-disk phase directories (e.g. phases/11-foo/ with no header yet)
573
586
  const phasesOnDisk = node_path_1.default.join(planningDir(cwd), 'phases');
574
587
  if (node_fs_1.default.existsSync(phasesOnDisk)) {
575
588
  const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
@@ -578,13 +591,16 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
578
591
  if (!match)
579
592
  continue;
580
593
  const num = parseInt(match[1], 10);
581
- if (num === 999)
582
- continue;
583
- if (num > maxPhase)
584
- maxPhase = num;
594
+ if (num !== 999)
595
+ usedPhaseNums.add(num);
585
596
  }
586
597
  }
587
- _newPhaseId = maxPhase + 1;
598
+ // phase.add appends after the highest *used* number. Collecting numbers from
599
+ // section headers, roadmap bullets, AND on-disk dirs above is what prevents the
600
+ // #1229 collision (a bullet-only Phase N is now counted), so max+1 cannot reuse
601
+ // an existing number.
602
+ const maxUsed = usedPhaseNums.size > 0 ? Math.max(...usedPhaseNums) : 0;
603
+ _newPhaseId = maxUsed + 1;
588
604
  const paddedNum = String(_newPhaseId).padStart(2, '0');
589
605
  _dirName = `${prefix}${paddedNum}-${slug}`;
590
606
  }
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ /**
3
+ * ADR-22 Drift-Guard Decision Module
4
+ *
5
+ * Implements the authority ladder and severity classification table from
6
+ * ADR-22 (docs/adr/0022-source-grounding-drift-guard.md).
7
+ *
8
+ * Design constraints:
9
+ * - Pure module: no I/O, no require() calls, no side effects.
10
+ * - All inputs are validated; unknown values throw a TypeError.
11
+ * - Consumed by the `gsd-tools drift-guard` CLI seam and by tests.
12
+ *
13
+ * Authority ladder (rung values determine MISSING severity):
14
+ * grep=0 intel=1 treesitter=2 lsp=3 scip=4
15
+ *
16
+ * Hard-block threshold: rung >= 3 (lsp, scip) — these adapters can prove
17
+ * absence, so MISSING is a definite error (severity HIGH, hardBlock true).
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.AUTHORITY_RUNGS = void 0;
21
+ exports.getEffectiveAuthority = getEffectiveAuthority;
22
+ exports.classifyDriftSeverity = classifyDriftSeverity;
23
+ /**
24
+ * Frozen map from authority name to its rung number.
25
+ *
26
+ * Rung determines whether a MISSING symbol triggers a hard block:
27
+ * rung >= 3 (lsp, scip) → hard block; rung < 3 → acknowledgement only.
28
+ */
29
+ exports.AUTHORITY_RUNGS = Object.freeze({
30
+ grep: 0,
31
+ intel: 1,
32
+ treesitter: 2,
33
+ lsp: 3,
34
+ scip: 4,
35
+ });
36
+ /** Rung at which MISSING transitions to hard-block (inclusive). */
37
+ const HARD_BLOCK_RUNG_THRESHOLD = 3;
38
+ const VALID_AUTHORITIES = new Set(Object.keys(exports.AUTHORITY_RUNGS));
39
+ const VALID_STATUSES = new Set(['VERIFIED', 'MISSING', 'AMBIGUOUS', 'UNCHECKABLE']);
40
+ /**
41
+ * Validate and return an authority value, normalising undefined to 'grep'.
42
+ *
43
+ * Throws TypeError for any non-null unknown string value so callers surface
44
+ * configuration errors at call time rather than silently defaulting.
45
+ *
46
+ * @param value - raw authority string from config or CLI arg
47
+ * @returns a validated Authority value
48
+ */
49
+ function validateAuthority(value) {
50
+ if (value === undefined || value === null || value === '') {
51
+ return 'grep';
52
+ }
53
+ if (!VALID_AUTHORITIES.has(value)) {
54
+ throw new TypeError(`Unknown authority: ${JSON.stringify(value)}. ` +
55
+ `Valid values: ${[...VALID_AUTHORITIES].join(', ')}`);
56
+ }
57
+ return value;
58
+ }
59
+ /**
60
+ * Return the effective authority after applying the ADR-22 auto-upgrade rule.
61
+ *
62
+ * Auto-upgrade rule: if the configured authority is 'grep' AND intel is
63
+ * enabled (`intelEnabled === true`), upgrade to 'intel'. All other authority
64
+ * values are returned unchanged regardless of intelEnabled.
65
+ *
66
+ * @param authority - configured authority (undefined → 'grep')
67
+ * @param intelEnabled - whether the intel capability is active in this project
68
+ * @returns the effective Authority after upgrade
69
+ * @throws TypeError if authority is not one of the five valid values
70
+ */
71
+ function getEffectiveAuthority(authority, intelEnabled) {
72
+ const validated = validateAuthority(authority);
73
+ if (validated === 'grep' && intelEnabled === true) {
74
+ return 'intel';
75
+ }
76
+ return validated;
77
+ }
78
+ /**
79
+ * Classify a symbol verification result into a drift severity and hard-block flag.
80
+ *
81
+ * ADR-22 decision table:
82
+ *
83
+ * | Status | Authority rung | severity | hardBlock |
84
+ * |------------- |--------------- |----------------------- |---------- |
85
+ * | VERIFIED | any | 'none' | false |
86
+ * | MISSING | rung >= 3 | 'HIGH' | true |
87
+ * | MISSING | rung 0-2 | 'needs-acknowledgement'| false |
88
+ * | AMBIGUOUS | any | 'MEDIUM' | false |
89
+ * | UNCHECKABLE | any | 'INFO' | false |
90
+ *
91
+ * @param opts.status - verdict from the source-grounding adapter
92
+ * @param opts.authority - the effective authority adapter used
93
+ * @returns { severity, hardBlock }
94
+ * @throws TypeError for unknown status or authority values
95
+ */
96
+ function classifyDriftSeverity({ status, authority, }) {
97
+ if (!VALID_STATUSES.has(status)) {
98
+ throw new TypeError(`Unknown status: ${JSON.stringify(status)}. ` +
99
+ `Valid values: ${[...VALID_STATUSES].join(', ')}`);
100
+ }
101
+ // authority validation (also catches unknown values)
102
+ const validatedAuthority = validateAuthority(authority);
103
+ const rung = exports.AUTHORITY_RUNGS[validatedAuthority];
104
+ switch (status) {
105
+ case 'VERIFIED':
106
+ return { severity: 'none', hardBlock: false };
107
+ case 'MISSING':
108
+ if (rung >= HARD_BLOCK_RUNG_THRESHOLD) {
109
+ return { severity: 'HIGH', hardBlock: true };
110
+ }
111
+ return { severity: 'needs-acknowledgement', hardBlock: false };
112
+ case 'AMBIGUOUS':
113
+ return { severity: 'MEDIUM', hardBlock: false };
114
+ case 'UNCHECKABLE':
115
+ return { severity: 'INFO', hardBlock: false };
116
+ }
117
+ }
@@ -30,10 +30,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
30
30
  return (mod && mod.__esModule) ? mod : { "default": mod };
31
31
  };
32
32
  Object.defineProperty(exports, "__esModule", { value: true });
33
- exports.VALID_STATUS = void 0;
33
+ exports.PROHIBITION_VALIDATORS = exports.VALID_STATUS = void 0;
34
34
  exports.validateRequirement = validateRequirement;
35
35
  exports.validateResolution = validateResolution;
36
36
  exports.analyzeCoverage = analyzeCoverage;
37
+ exports.validateProhibitionResolution = validateProhibitionResolution;
38
+ exports.projectProhibitions = projectProhibitions;
39
+ exports.dispositionForProhibition = dispositionForProhibition;
37
40
  exports.runProbeCli = runProbeCli;
38
41
  const node_fs_1 = __importDefault(require("node:fs"));
39
42
  /** The LOCKED set of valid lifecycle statuses (the re-cut: no covered/backstop). */
@@ -203,6 +206,119 @@ function analyzeCoverage(items, resolutions = [], validators) {
203
206
  }
204
207
  return { items: merged, coverage: { applicable, resolved, unresolved, byVerification } };
205
208
  }
209
+ /**
210
+ * The prohibition adapter's injected runtime validators (ADR-550 #5). There is no closed
211
+ * category taxonomy (recall is open-vocabulary values/safety/ethics prose), so `categories`
212
+ * is intentionally empty — `analyzeCoverage` is not the prohibition entry point and the
213
+ * round-trip schema layer does not gate on category. The verification tiers are
214
+ * `test | judgment` (ADR-550 D7a); both require only a present `resolution`/`reason` per their
215
+ * lifecycle (a resolved prohibition's checkable content is the `statement`, validated by the
216
+ * schema layer, not a `resolution` string), so `requiredFieldsByVerification` is the minimal
217
+ * fail-closed set: a dismissed item still needs its reason (enforced by `validateResolution`).
218
+ */
219
+ exports.PROHIBITION_VALIDATORS = {
220
+ categories: [],
221
+ verification: ['test', 'judgment'],
222
+ // A resolved prohibition's checkable content is the `statement` (schema-layer validated), NOT a
223
+ // `resolution` string — the canonical fixtures and the reference doc's worked examples all carry
224
+ // `resolution: null`. So the per-tier required set is empty: `resolved` still requires a present
225
+ // verification tier (enforced in validateResolution) and `dismissed` still requires a reason
226
+ // (enforced unconditionally), but neither tier requires a `resolution`. This matches the corpus
227
+ // the docs-fixtures parity test pins; the validators.test.cjs regression keeps them aligned.
228
+ requiredFieldsByVerification: { test: [], judgment: [] },
229
+ };
230
+ /** Validate a prohibition resolution against the prohibition verification vocabulary. */
231
+ function validateProhibitionResolution(resolution) {
232
+ return validateResolution(resolution, exports.PROHIBITION_VALIDATORS);
233
+ }
234
+ /**
235
+ * Deterministically project resolved prohibition items into the `must_haves.prohibitions:`
236
+ * list shape (the SPEC<->plan projection; ADR-550 Decision 5c). This is a FUNCTION the parity
237
+ * assertion round-trips, never a prompt: the same input always yields the same output, and the
238
+ * output is the exact re-readable block shape `parseMustHavesBlock(content, 'prohibitions')`
239
+ * returns — `{ statement, status, verification }` plus `reason` only when present (a dismissed
240
+ * item's audit trail). `resolution`/`requirement_id`/`category` are recall-stage bookkeeping
241
+ * and are intentionally NOT projected into the plan block (which is keyed on the must-NOT
242
+ * statement, not the source requirement). A non-array input projects to `[]` (fail-soft on the
243
+ * empty/zero-prohibition case), never a throw.
244
+ */
245
+ function projectProhibitions(items) {
246
+ if (!Array.isArray(items))
247
+ return [];
248
+ const out = [];
249
+ for (const item of items) {
250
+ if (item == null || typeof item !== 'object')
251
+ continue;
252
+ const p = item;
253
+ const statement = typeof p.statement === 'string' ? p.statement : '';
254
+ const entry = {
255
+ statement,
256
+ status: typeof p.status === 'string' ? p.status : 'unresolved',
257
+ };
258
+ if (p.verification != null)
259
+ entry.verification = String(p.verification);
260
+ if (p.reason != null && String(p.reason).trim())
261
+ entry.reason = String(p.reason);
262
+ out.push(entry);
263
+ }
264
+ return out;
265
+ }
266
+ /**
267
+ * Deterministic verify-time disposition for a single prohibition — the FAIL-CLOSED default
268
+ * (ADR-550 Decision 5d, the safety half of the 2026-06-12 "B-with-guard" maintainer decision).
269
+ *
270
+ * This is the cheap safety guarantee: a well-formed prohibition that reaches verify-phase with NO
271
+ * wired enforcement evidence can NEVER be a silent pass. It is `{ status: 'unverified', flagged:
272
+ * true }` — never `green` — exactly like an unresolved judgment item. The HEAVY half (a real
273
+ * fail-first negative-test enforcement mechanism that, given evidence, would flip a test-tier item
274
+ * to green) is OUT of #644 scope and defers to a follow-up PR: #644's corpus is entirely
275
+ * judgment-tier, so wiring a contrived test-tier consumer here would be the delete-bad-tests /
276
+ * gold-plating failure mode. Until that follow-up lands, ANY prohibition without enforcement
277
+ * evidence — test- or judgment-tier — disposes as flagged-unverified.
278
+ *
279
+ * The function is pure: same input always yields the same disposition (no LLM judgment, ADR-550
280
+ * D5). The LLM-judge soft-gate for judgment-tier items is a verify-phase PROSE concern (the
281
+ * verifier records a non-authoritative verdict + the unverified-prohibition flag); this helper
282
+ * only owns the deterministic fail-closed default that the plan-01-01 CI safety assertion pins.
283
+ */
284
+ function dispositionForProhibition(prohibition, context = {}) {
285
+ const p = (prohibition ?? {});
286
+ const tier = p.verification === 'test' || p.verification === 'judgment' ? p.verification : null;
287
+ const evidence = Array.isArray(context.enforcementEvidence) ? context.enforcementEvidence : [];
288
+ const hasEnforcement = evidence.length > 0;
289
+ // FAIL CLOSED: no wired enforcement evidence -> flagged unverified, never green. This holds for
290
+ // every tier today (the real enforcement mechanism that could flip a test-tier item to green is
291
+ // deferred to a follow-up PR). The guard the safety assertion proves: an unwired item can never
292
+ // be silently skipped.
293
+ if (!hasEnforcement) {
294
+ return {
295
+ status: 'unverified',
296
+ flagged: true,
297
+ tier,
298
+ reason: tier === 'test'
299
+ ? 'test-tier prohibition has no wired enforcement evidence — flagged unverified (fail-closed; real negative-test enforcement deferred to a follow-up PR, ADR-550 D5d)'
300
+ : 'prohibition has no enforcement evidence — flagged unverified (fail-closed; never a silent pass, ADR-550 D5d)',
301
+ };
302
+ }
303
+ // D4 GUARD: a judgment-tier (or unknown-tier) prohibition is NEVER a silent green from this
304
+ // deterministic helper — it always routes to human/LLM judgment review (ADR-550 D4; verify-phase.md).
305
+ // Only a test-tier item with wired enforcement evidence may go green, and even that is the deferred
306
+ // heavy half until the real negative-test enforcement mechanism lands (no #644 caller passes evidence).
307
+ if (tier === 'test') {
308
+ return {
309
+ status: 'green',
310
+ flagged: false,
311
+ tier,
312
+ reason: 'test-tier prohibition has wired enforcement evidence',
313
+ };
314
+ }
315
+ return {
316
+ status: 'unverified',
317
+ flagged: true,
318
+ tier,
319
+ reason: 'judgment-tier prohibition routes to judgment review — never a silent green (ADR-550 D4)',
320
+ };
321
+ }
206
322
  /**
207
323
  * Read the requirements file (and optional resolutions file), run the adapter's `analyze`,
208
324
  * and write the report as pretty JSON + newline. With no requirements path, writes the usage