@opengsd/gsd-core 1.5.0-rc.5 → 1.5.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.
Files changed (36) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/LICENSE +1 -1
  3. package/agents/gsd-executor.md +22 -0
  4. package/bin/install.js +53 -99
  5. package/commands/gsd/autonomous.md +1 -1
  6. package/commands/gsd/execute-phase.md +1 -1
  7. package/commands/gsd/plan-phase.md +1 -1
  8. package/gemini-extension.json +1 -1
  9. package/gsd-core/bin/gsd-tools.cjs +36 -5
  10. package/gsd-core/bin/lib/decisions.cjs +19 -1
  11. package/gsd-core/bin/lib/phase-id.cjs +1 -1
  12. package/gsd-core/bin/lib/phase.cjs +41 -6
  13. package/gsd-core/bin/lib/probe-core.cjs +7 -0
  14. package/gsd-core/bin/lib/prohibition-enforcement.cjs +201 -26
  15. package/gsd-core/bin/lib/roadmap-parser.cjs +25 -20
  16. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +4 -1
  17. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +24 -3
  18. package/gsd-core/bin/lib/state.cjs +64 -15
  19. package/gsd-core/bin/lib/teams-status.cjs +74 -0
  20. package/gsd-core/bin/lib/uat.cjs +56 -0
  21. package/gsd-core/bin/lib/worktree-safety.cjs +7 -1
  22. package/gsd-core/references/prohibition-probe.md +48 -19
  23. package/gsd-core/references/worktree-branch-check.md +11 -5
  24. package/gsd-core/workflows/docs-update.md +23 -31
  25. package/gsd-core/workflows/execute-phase.md +2 -1
  26. package/gsd-core/workflows/map-codebase.md +8 -10
  27. package/gsd-core/workflows/plan-phase.md +6 -0
  28. package/gsd-core/workflows/quick.md +23 -2
  29. package/gsd-core/workflows/spec-phase.md +11 -6
  30. package/gsd-core/workflows/verify-phase.md +3 -3
  31. package/hooks/dist/gsd-worktree-path-guard.js +34 -18
  32. package/hooks/gsd-worktree-path-guard.js +34 -18
  33. package/package.json +2 -2
  34. package/scripts/ci-prepare-test-scope.cjs +56 -14
  35. package/scripts/diff-touches-shipped-paths.cjs +5 -11
  36. package/scripts/run-tests.cjs +1 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gsd-core",
3
3
  "displayName": "GSD Core",
4
- "version": "1.5.0-rc.5",
4
+ "version": "1.5.0",
5
5
  "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
6
6
  "author": {
7
7
  "name": "open-gsd",
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Lex Christopherson
3
+ Copyright (c) 2026 Open GSD
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -103,6 +103,24 @@ PLAN_START_EPOCH=$(date +%s)
103
103
  ```
104
104
  </step>
105
105
 
106
+ <worktree_metadata_capture>
107
+ If running inside a git worktree, capture authoritative worktree identity before
108
+ any task commit changes HEAD. The execute-phase orchestrator consumes this from
109
+ your final `<worktree_metadata>` return block to build the wave cleanup manifest
110
+ without relying on runtime harness metadata (#1297).
111
+
112
+ ```bash
113
+ GSD_WORKTREE_PATH=""
114
+ GSD_WORKTREE_BRANCH=""
115
+ GSD_WORKTREE_EXPECTED_BASE=""
116
+ if [ -f .git ]; then
117
+ GSD_WORKTREE_PATH=$(git rev-parse --show-toplevel)
118
+ GSD_WORKTREE_BRANCH=$(git rev-parse --abbrev-ref HEAD)
119
+ GSD_WORKTREE_EXPECTED_BASE=$(git rev-parse HEAD)
120
+ fi
121
+ ```
122
+ </worktree_metadata_capture>
123
+
106
124
  <step name="determine_execution_pattern">
107
125
  ```bash
108
126
  grep -n "type=\"checkpoint" [plan-path]
@@ -761,6 +779,10 @@ into the user's project history.
761
779
  **Tasks:** {completed}/{total}
762
780
  **SUMMARY:** {path to SUMMARY.md}
763
781
 
782
+ <worktree_metadata>
783
+ {"agent_id":"{phase}-{plan}","worktree_path":"${GSD_WORKTREE_PATH:-}","branch":"${GSD_WORKTREE_BRANCH:-}","expected_base":"${GSD_WORKTREE_EXPECTED_BASE:-}"}
784
+ </worktree_metadata>
785
+
764
786
  **Commits:**
765
787
  - {hash}: {message}
766
788
  - {hash}: {message}
package/bin/install.js CHANGED
@@ -1796,6 +1796,10 @@ function skillFrontmatterName(skillDirName) {
1796
1796
  return skillDirName;
1797
1797
  }
1798
1798
 
1799
+ function normalizeClaudeSkillEffort(effort) {
1800
+ return effort === 'xhigh' ? 'max' : effort;
1801
+ }
1802
+
1799
1803
  /**
1800
1804
  * Qwen Code skills accept an optional numeric `priority` frontmatter field.
1801
1805
  * Per the Qwen skills spec (qwen-code/docs/users/features/skills.md, verified
@@ -1892,7 +1896,7 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
1892
1896
  // token-budget tier). Fields are Claude-specific; unknown frontmatter
1893
1897
  // fields are silently ignored by other runtimes (backward-compatible).
1894
1898
  if (context) fm += `context: ${context}\n`;
1895
- if (effort) fm += `effort: ${effort}\n`;
1899
+ if (effort) fm += `effort: ${normalizeClaudeSkillEffort(effort)}\n`;
1896
1900
  if (toolsBlock) fm += toolsBlock;
1897
1901
  fm += '---';
1898
1902
 
@@ -3194,110 +3198,55 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
3194
3198
  }
3195
3199
 
3196
3200
  /**
3197
- * Generate the agents/openai.yaml TUI chip metadata content for a Codex skill.
3198
- *
3199
- * This file is written alongside SKILL.md as <skill-dir>/agents/openai.yaml.
3200
- * Codex loads it as a SkillMetadataFile (codex-rs/core-skills/src/loader.rs),
3201
- * making the skill discoverable in the /skills TUI popup with a display name
3202
- * and short description. If the file is absent, Codex silently skips it (fails open).
3203
- *
3204
- * Schema (interface section):
3205
- * display_name: short human-readable skill name (strip gsd- prefix)
3206
- * short_description: 1-2 sentence description for TUI chip, ≤180 chars
3207
- *
3208
- * @param {string} skillName - Full skill name e.g. "gsd-plan-phase"
3209
- * @param {string} shortDescription - Description text (already truncated by caller)
3210
- * @returns {string} YAML content for agents/openai.yaml
3211
- */
3212
- function generateCodexSkillMetadataYaml(skillName, shortDescription) {
3213
- // Display name: strip "gsd-" prefix and convert hyphens to spaces for readability.
3214
- const displayName = skillName.replace(/^gsd-/, '').replace(/-/g, ' ');
3215
- // yamlQuote (= JSON.stringify) handles all YAML-unsafe chars: backslashes,
3216
- // quotes, newlines, control characters, and Unicode escapes.
3217
- return [
3218
- 'interface:',
3219
- ` display_name: ${yamlQuote(displayName)}`,
3220
- ` short_description: ${yamlQuote(shortDescription)}`,
3221
- '',
3222
- ].join('\n');
3223
- }
3224
-
3225
- /**
3226
- * Write agents/openai.yaml TUI chip metadata for each gsd-* skill directory.
3227
- *
3228
- * Called after layout-driven skill install for Codex. Iterates every gsd-*
3229
- * skill directory in skillsDir, reads the SKILL.md frontmatter to extract the
3230
- * short-description already emitted by convertClaudeCommandToCodexSkill, then
3231
- * writes <skill-dir>/agents/openai.yaml using generateCodexSkillMetadataYaml.
3201
+ * Remove stale agents/openai.yaml sidecar files from GSD-managed Codex skill dirs.
3232
3202
  *
3233
- * Fails open: individual skill directories that cannot be processed are silently
3234
- * skipped so a single malformed SKILL.md cannot block the whole install.
3203
+ * Prior to #1326, GSD's Codex install path wrote an agents/openai.yaml file
3204
+ * alongside each gsd-* SKILL.md. Recent Codex builds index BOTH SKILL.md and
3205
+ * the sidecar, causing each GSD skill to appear twice in autocomplete. This
3206
+ * function removes those stale sidecars and — if the agents/ subdirectory is
3207
+ * now empty — prunes it too.
3235
3208
  *
3236
- * User-owned skill directories (e.g. gsd-dev-preferences) are explicitly
3237
- * skipped so existing user-authored agents/openai.yaml files are never
3238
- * overwritten. These dirs are listed in the same USER_OWNED_SKILL_DIRS
3239
- * constant used by installOpencodeFamilySkills.
3240
- *
3241
- * The YAML-quoted description value is unescaped before embedding so that
3242
- * YAML escape sequences (e.g. \" in a double-quoted scalar) become the
3243
- * literal characters they represent rather than being double-escaped in the
3244
- * output.
3209
+ * Behaviour:
3210
+ * - Returns immediately if skillsDir does not exist (fails open).
3211
+ * - Only touches directories whose names start with "gsd-".
3212
+ * - Skips user-owned dirs (gsd-dev-preferences) — their agents/ content is
3213
+ * never modified, mirroring the same USER_OWNED_SKILL_DIRS guard used by
3214
+ * installOpencodeFamilySkills.
3215
+ * - For each managed gsd-* dir, if agents/openai.yaml exists, deletes it.
3216
+ * - If agents/ is now empty, removes the directory; if it still contains
3217
+ * other files (e.g. user-added content), leaves it in place.
3218
+ * - Non-gsd-* dirs and their agents/ content are never touched.
3219
+ * - Individual failures are caught and swallowed so a single bad dir cannot
3220
+ * block the install (fail-open, matching the original design).
3245
3221
  *
3246
3222
  * @param {string} skillsDir - Path to the skills/ directory (e.g. ~/.codex/skills)
3247
3223
  */
3248
- function writeCodexSkillMetadataFiles(skillsDir) {
3224
+ function cleanupCodexSkillMetadataSidecars(skillsDir) {
3249
3225
  if (!fs.existsSync(skillsDir)) return;
3250
3226
  // Mirror the user-owned list from installOpencodeFamilySkills (#2973).
3251
3227
  // We MUST skip these dirs — their contents are user-generated and must
3252
- // never be overwritten by GSD's install path.
3228
+ // never be modified by GSD's install path.
3253
3229
  const _userOwnedSkillDirs = new Set(['gsd-dev-preferences']);
3254
3230
  for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
3255
3231
  if (!entry.isDirectory() || !entry.name.startsWith('gsd-')) continue;
3256
3232
  if (_userOwnedSkillDirs.has(entry.name)) continue; // preserve user content
3257
- const skillDir = path.join(skillsDir, entry.name);
3258
- const skillMdPath = path.join(skillDir, 'SKILL.md');
3233
+ const agentsSubdir = path.join(skillsDir, entry.name, 'agents');
3234
+ const sidecarPath = path.join(agentsSubdir, 'openai.yaml');
3259
3235
  try {
3260
- const content = fs.readFileSync(skillMdPath, 'utf8');
3261
- const { frontmatter } = extractFrontmatterAndBody(content);
3262
- // Prefer the short-description field emitted by convertClaudeCommandToCodexSkill;
3263
- // fall back to description, then a synthetic label from the skill name.
3264
- let shortDesc = '';
3265
- if (frontmatter) {
3266
- // SKILL.md uses YAML frontmatter with a nested metadata.short-description key.
3267
- // extractFrontmatterField handles only top-level keys; parse the metadata block
3268
- // by looking for " short-description:" directly.
3269
- const metaMatch = frontmatter.match(/^[ \t]*metadata\s*:\s*\n((?:[ \t]+.*\n?)*)/m);
3270
- if (metaMatch) {
3271
- const metaBlock = metaMatch[1];
3272
- const sdMatch = metaBlock.match(/^[ \t]+short-description\s*:\s*(.+)$/m);
3273
- if (sdMatch) {
3274
- // Unescape YAML double-quoted scalar escapes before embedding.
3275
- // convertClaudeCommandToCodexSkill always emits a double-quoted
3276
- // value (via yamlQuote) so only double-quote unescaping is needed.
3277
- let raw = sdMatch[1].trim();
3278
- if (raw.startsWith('"') && raw.endsWith('"')) {
3279
- // Strip outer double-quotes and decode \" → " and \\ → \
3280
- raw = raw.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
3281
- } else {
3282
- // Single-quoted or unquoted: strip surrounding quotes/whitespace
3283
- raw = raw.replace(/^["']|["']$/g, '');
3284
- }
3285
- shortDesc = raw;
3286
- }
3287
- }
3288
- if (!shortDesc) {
3289
- shortDesc = extractFrontmatterField(frontmatter, 'description') || '';
3290
- }
3236
+ // Symlink guard: if agents/ is a symlink pointing outside the skills tree,
3237
+ // deleting through it could escape the tree. Skip this dir entirely.
3238
+ let agentsStat;
3239
+ try { agentsStat = fs.lstatSync(agentsSubdir); } catch (_e) { continue; }
3240
+ if (agentsStat.isSymbolicLink()) continue;
3241
+ if (fs.existsSync(sidecarPath)) {
3242
+ fs.rmSync(sidecarPath);
3243
+ }
3244
+ // Prune the agents/ dir only if it is now empty (leave it if other files remain).
3245
+ if (fs.existsSync(agentsSubdir) && fs.readdirSync(agentsSubdir).length === 0) {
3246
+ fs.rmdirSync(agentsSubdir);
3291
3247
  }
3292
- if (!shortDesc) {
3293
- shortDesc = `Run GSD workflow ${entry.name}.`;
3294
- }
3295
- const yamlContent = generateCodexSkillMetadataYaml(entry.name, shortDesc);
3296
- const agentsSubdir = path.join(skillDir, 'agents');
3297
- fs.mkdirSync(agentsSubdir, { recursive: true });
3298
- fs.writeFileSync(path.join(agentsSubdir, 'openai.yaml'), yamlContent);
3299
3248
  } catch (_err) {
3300
- // Fail open — missing or unreadable SKILL.md must not block the install.
3249
+ // Fail open — a single bad dir must not block the install.
3301
3250
  }
3302
3251
  }
3303
3252
  }
@@ -6843,6 +6792,12 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false) {
6843
6792
  content = content.replace(/~\/\.claude\//g, pathPrefix);
6844
6793
  content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
6845
6794
  content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
6795
+ // Bare forms (no trailing slash) — use (?![\w-]) instead of \b so that
6796
+ // .claude-plugin / .claudeignore are NOT corrupted (the \b word-boundary
6797
+ // fires between 'e' and '-', which rewrites .claude-plugin → .cursor-plugin).
6798
+ content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix);
6799
+ content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix);
6800
+ content = content.replace(/\.\/\.claude(?![\w-])/g, `./${dirName}`);
6846
6801
  content = content.replace(/~\/\.cursor\//g, pathPrefix);
6847
6802
  content = processAttribution(content, getCommitAttribution(runtime));
6848
6803
  break;
@@ -9837,14 +9792,14 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9837
9792
  const scope = isGlobal ? 'global' : 'local';
9838
9793
  installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile);
9839
9794
 
9840
- // #774 — Codex only: write agents/openai.yaml TUI chip metadata alongside each
9841
- // installed skill so the /skills popup shows name + description for each gsd-* skill.
9842
- // The SkillMetadataFile is loaded by codex-rs/core-skills/src/loader.rs from
9843
- // <skill-dir>/agents/openai.yaml; absence is silently tolerated (fails open).
9844
- // We parse the SKILL.md frontmatter to extract short-description already emitted
9845
- // by convertClaudeCommandToCodexSkill and use it as the TUI chip description.
9795
+ // #1326 — Codex only: remove stale agents/openai.yaml sidecars from managed
9796
+ // gsd-* skill dirs. Prior installs wrote these files so Codex would show a
9797
+ // display name and description in the /skills TUI popup. Recent Codex builds
9798
+ // index BOTH SKILL.md and the sidecar, causing each GSD skill to appear twice
9799
+ // in autocomplete. Cleaning them up fixes the duplication; SKILL.md alone is
9800
+ // sufficient for Codex discovery. User-owned dirs are never touched.
9846
9801
  if (isCodex) {
9847
- writeCodexSkillMetadataFiles(path.join(targetDir, 'skills'));
9802
+ cleanupCodexSkillMetadataSidecars(path.join(targetDir, 'skills'));
9848
9803
  }
9849
9804
 
9850
9805
  // Hermes only: write DESCRIPTION.md for the gsd/ category after layout install
@@ -12126,8 +12081,7 @@ module.exports = {
12126
12081
  convertClaudeToGeminiAgent,
12127
12082
  convertClaudeAgentToCodexAgent,
12128
12083
  generateCodexAgentToml,
12129
- generateCodexSkillMetadataYaml,
12130
- writeCodexSkillMetadataFiles,
12084
+ cleanupCodexSkillMetadataSidecars,
12131
12085
  generateCodexConfigBlock,
12132
12086
  stripGsdFromCodexConfig,
12133
12087
  migrateCodexHooksMapFormat,
@@ -2,7 +2,7 @@
2
2
  name: gsd:autonomous
3
3
  description: Run all remaining phases autonomously — discuss→plan→execute per phase
4
4
  argument-hint: "[--from N] [--to N] [--only N] [--interactive] [--converge]"
5
- effort: xhigh
5
+ effort: max
6
6
  allowed-tools:
7
7
  - Read
8
8
  - Write
@@ -2,7 +2,7 @@
2
2
  name: gsd:execute-phase
3
3
  description: Execute all plans in a phase with wave-based parallelization
4
4
  argument-hint: "<phase-number> [--wave N] [--gaps-only] [--interactive] [--tdd]"
5
- effort: xhigh
5
+ effort: max
6
6
  allowed-tools:
7
7
  - Read
8
8
  - Write
@@ -2,7 +2,7 @@
2
2
  name: gsd:plan-phase
3
3
  description: Create detailed phase plan (PLAN.md) with verification loop
4
4
  argument-hint: "[phase] [--auto] [--research] [--skip-research] [--research-phase <N>] [--view] [--gaps] [--skip-verify] [--prd <file>] [--ingest <path-or-glob>] [--ingest-format <auto|nygard|madr|narrative>] [--reviews] [--text] [--tdd] [--mvp]"
5
- effort: xhigh
5
+ effort: max
6
6
  allowed-tools:
7
7
  - Read
8
8
  - Write
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-core",
3
- "version": "1.5.0-rc.5",
3
+ "version": "1.5.0",
4
4
  "description": "GSD Core — a meta-prompting, context engineering, and spec-driven development system for AI coding agents. Loads gsd's operating context into every Gemini CLI session.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }
@@ -1519,6 +1519,17 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
1519
1519
  break;
1520
1520
  }
1521
1521
 
1522
+ // ─── teams-status ──────────────────────────────────────────────────────
1523
+ // Read-only detector for claude-code's experimental agent-teams feature.
1524
+ // issue #1355: stop gsd-core hanging silently under claude-code agent-teams.
1525
+ // No capability registration needed — this is a diagnostic query command,
1526
+ // not a feature capability.
1527
+ case 'teams-status': {
1528
+ const teamsStatus = require('./lib/teams-status.cjs');
1529
+ teamsStatus.cmdTeamsStatus(cwd, { active: args.includes('--active') });
1530
+ break;
1531
+ }
1532
+
1522
1533
  // ─── detect-custom-files ───────────────────────────────────────────────
1523
1534
  // CJS-native: no SDK counterpart exists in the command registry.
1524
1535
  // detect-custom-files reads a gsd-file-manifest.json against the
@@ -1566,18 +1577,30 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
1566
1577
 
1567
1578
  const manifestKeys = new Set(Object.keys(manifest.files || {}));
1568
1579
 
1569
- // GSD-managed directories to scan for user-added files.
1570
- // These are the directories the installer wipes on update.
1571
- const GSD_MANAGED_DIRS = [
1580
+ // GSD-managed directories to scan for user-added files. Whole-owned
1581
+ // roots are wiped recursively; shared runtime roots are pruned by the
1582
+ // same gsd-* top-level prefix used by install.js _removeGsdEntries.
1583
+ const GSD_WHOLE_MANAGED_DIRS = [
1572
1584
  'gsd-core',
1573
- 'agents',
1574
1585
  path.join('commands', 'gsd'),
1586
+ ];
1587
+ const GSD_PREFIX_MANAGED_DIRS = [
1588
+ 'agents',
1575
1589
  'hooks',
1576
1590
  'skills',
1577
1591
  ];
1578
1592
 
1579
1593
  function collectCustomFiles(dir, baseDir, manifestKeys, out) {
1580
1594
  if (!fs.existsSync(dir)) return;
1595
+ const stat = fs.statSync(dir);
1596
+ if (stat.isFile()) {
1597
+ const relPath = path.relative(baseDir, dir).replace(/\\/g, '/');
1598
+ if (!manifestKeys.has(relPath)) {
1599
+ out.push(relPath);
1600
+ }
1601
+ return;
1602
+ }
1603
+ if (!stat.isDirectory()) return;
1581
1604
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1582
1605
  const fullPath = path.join(dir, entry.name);
1583
1606
  if (entry.isDirectory()) {
@@ -1593,11 +1616,19 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
1593
1616
  }
1594
1617
 
1595
1618
  const customFiles = [];
1596
- for (const managedDir of GSD_MANAGED_DIRS) {
1619
+ for (const managedDir of GSD_WHOLE_MANAGED_DIRS) {
1597
1620
  const absDir = path.join(resolvedConfigDir, managedDir);
1598
1621
  if (!fs.existsSync(absDir)) continue;
1599
1622
  collectCustomFiles(absDir, resolvedConfigDir, manifestKeys, customFiles);
1600
1623
  }
1624
+ for (const managedDir of GSD_PREFIX_MANAGED_DIRS) {
1625
+ const absDir = path.join(resolvedConfigDir, managedDir);
1626
+ if (!fs.existsSync(absDir)) continue;
1627
+ for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) {
1628
+ if (!entry.name.startsWith('gsd-')) continue;
1629
+ collectCustomFiles(path.join(absDir, entry.name), resolvedConfigDir, manifestKeys, customFiles);
1630
+ }
1631
+ }
1601
1632
 
1602
1633
  const out = {
1603
1634
  custom_files: customFiles,
@@ -61,7 +61,13 @@ function parseDecisions(content) {
61
61
  // in addition to numeric-only IDs (D-42). The first character after `D-` must
62
62
  // be alphanumeric, so malformed shapes like `D--foo` or `D-_bar` are rejected.
63
63
  // CJS callers consume {id, text} and ignore the optional extras.
64
- const bulletRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?\s*:\*\*\s*(.*)$/;
64
+ // #1343: `[^:*]*` replaces the old `\s*` before `:**` so that a freeform run
65
+ // such as `(parenthetical)`, an em-dash, or other prose between the optional
66
+ // bracket-tag group and the closing `:**` is tolerated rather than silently
67
+ // dropping the whole decision. `[^:*]*` subsumes plain whitespace and stops
68
+ // correctly at `:**`. Capture groups 1 (id), 2 (bracket tags), 3 (text) are
69
+ // unchanged.
70
+ const bulletRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^:*]*:\*\*\s*(.*)$/;
65
71
  let current = null;
66
72
  const flush = () => {
67
73
  if (current) {
@@ -102,6 +108,18 @@ function parseDecisions(content) {
102
108
  current = { id, text: bulletMatch[3], category, tags, trackable };
103
109
  continue;
104
110
  }
111
+ // Parse-miss guard (#1343): a line that looks like a `D-NN` decision bullet
112
+ // but failed `bulletRe` (e.g. a `:` or `*` inside the pre-colon run) must NOT
113
+ // be silently dropped — a narrowed trackable set lets a blocking coverage gate
114
+ // report a false pass. Surface it loudly instead.
115
+ if (/^\s*-\s+\*\*D-/.test(line)) {
116
+ // A malformed D-bullet still starts a (failed) new decision, so it ends the
117
+ // previous one — flush before warning so a following continuation line cannot
118
+ // be mis-appended to the prior valid decision.
119
+ flush();
120
+ console.warn(`parseDecisions: ignored unparseable decision bullet: ${trimmed}`);
121
+ continue;
122
+ }
105
123
  // Continuation line for current decision (indented with space OR tab,
106
124
  // non-bullet, non-empty) — tab indentation must work too (review F12).
107
125
  if (current && trimmed !== '' && !trimmed.startsWith('-') && /^[ \t]/.test(line)) {
@@ -173,7 +173,7 @@ function extractPhaseToken(dirName) {
173
173
  const tokenSegments = [];
174
174
  for (let i = 0; i < segments.length; i++) {
175
175
  const seg = segments[i];
176
- if (/^\d/.test(seg)) {
176
+ if (/^\d/.test(seg) || (i === 0 && /^[A-Za-z]{1,3}\d/.test(seg))) {
177
177
  tokenSegments.push(seg);
178
178
  }
179
179
  else {
@@ -1040,6 +1040,22 @@ function writePlanningFileSet(writes) {
1040
1040
  throw err;
1041
1041
  }
1042
1042
  }
1043
+ function phaseDisplayNameFromRoadmap(roadmapContent, phaseNum) {
1044
+ if (!roadmapContent || !phaseNum)
1045
+ return null;
1046
+ const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
1047
+ const heading = roadmapContent.match(new RegExp(`^#{2,4}\\s*Phase\\s+${phaseEscaped}\\s*:\\s*([^\\n]+)`, 'im'));
1048
+ if (!heading)
1049
+ return null;
1050
+ const name = heading[1].replace(/\(INSERTED\)/i, '').trim();
1051
+ return name || null;
1052
+ }
1053
+ function phaseDisplayNameFromSlug(slug) {
1054
+ if (!slug)
1055
+ return null;
1056
+ const name = slug.replace(/-/g, ' ').trim();
1057
+ return name || null;
1058
+ }
1043
1059
  function cmdPhaseComplete(cwd, phaseNum, raw) {
1044
1060
  if (!phaseNum) {
1045
1061
  error('phase number required for phase complete');
@@ -1287,6 +1303,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1287
1303
  const originalStateContent = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
1288
1304
  let stateContent = originalStateContent;
1289
1305
  const phaseValue = nextPhaseNum || phaseNum;
1306
+ const nextPhaseDisplayName = phaseDisplayNameFromRoadmap(roadmapContent, nextPhaseNum) ??
1307
+ phaseDisplayNameFromSlug(nextPhaseName);
1290
1308
  const existingPhaseField = stateExtractField(stateContent, 'Current Phase') ||
1291
1309
  stateExtractField(stateContent, 'Phase');
1292
1310
  let newPhaseValue = String(phaseValue);
@@ -1295,22 +1313,39 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1295
1313
  const nameMatch = existingPhaseField.match(/\(([^)]+)\)/);
1296
1314
  if (totalMatch) {
1297
1315
  const total = totalMatch[1];
1298
- const nameStr = nextPhaseName
1299
- ? ` (${nextPhaseName.replace(/-/g, ' ')})`
1316
+ const nameStr = nextPhaseDisplayName
1317
+ ? ` (${nextPhaseDisplayName})`
1300
1318
  : nameMatch
1301
1319
  ? ` (${nameMatch[1]})`
1302
1320
  : '';
1303
1321
  newPhaseValue = `${phaseValue} of ${total}${nameStr}`;
1304
1322
  }
1323
+ else if (nextPhaseDisplayName) {
1324
+ newPhaseValue = `${phaseValue} — ${nextPhaseDisplayName}`;
1325
+ }
1305
1326
  }
1306
1327
  stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Phase', 'Phase', newPhaseValue);
1307
- if (nextPhaseName) {
1308
- stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Phase Name', null, nextPhaseName.replace(/-/g, ' '));
1328
+ if (nextPhaseDisplayName) {
1329
+ stateContent =
1330
+ stateReplaceField(stateContent, 'Current Phase Name', nextPhaseDisplayName) ||
1331
+ stateContent;
1309
1332
  }
1310
1333
  stateContent = stateReplaceFieldWithFallback(stateContent, 'Status', null, isLastPhase ? 'Milestone complete' : 'Ready to plan');
1311
1334
  stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Plan', 'Plan', 'Not started');
1312
- stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity', 'Last activity', today);
1313
- stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity Description', null, `Phase ${phaseNum} complete${nextPhaseNum ? `, transitioned to Phase ${nextPhaseNum}` : ''}`);
1335
+ const lastActivityDescription = `Phase ${phaseNum} complete${nextPhaseNum ? `, transitioned to Phase ${nextPhaseNum}` : ''}`;
1336
+ if (/^Last activity:/m.test(stateContent)) {
1337
+ stateContent =
1338
+ stateReplaceField(stateContent, 'Last activity', `${today} — ${lastActivityDescription}`) ||
1339
+ stateContent;
1340
+ }
1341
+ else {
1342
+ stateContent =
1343
+ stateReplaceField(stateContent, 'Last Activity', today) ||
1344
+ stateContent;
1345
+ }
1346
+ stateContent =
1347
+ stateReplaceField(stateContent, 'Last Activity Description', lastActivityDescription) ||
1348
+ stateContent;
1314
1349
  const completedRaw = stateExtractField(stateContent, 'Completed Phases');
1315
1350
  if (completedRaw !== null) {
1316
1351
  let newCompleted = parseInt(completedRaw, 10);
@@ -281,6 +281,13 @@ function projectProhibitions(items) {
281
281
  if (kind === 'lint-rule' && typeof p.check_rule === 'string' && p.check_rule.trim() !== '') {
282
282
  entry.check_rule = String(p.check_rule);
283
283
  }
284
+ // `check_violation_fixture` (#1346) rides BOTH kinds — it's what the #1279 prover machine-proves
285
+ // fail-first against. Emit ONLY a non-empty fixture (a blank one projects absent so green still
286
+ // hard-gates downstream — never a partial green); meaningless without the descriptor, so it lives
287
+ // inside this well-formed-descriptor branch.
288
+ if (typeof p.check_violation_fixture === 'string' && p.check_violation_fixture.trim() !== '') {
289
+ entry.check_violation_fixture = String(p.check_violation_fixture);
290
+ }
284
291
  }
285
292
  out.push(entry);
286
293
  }