@opengsd/gsd-core 1.7.0-rc.1 → 1.7.0-rc.2

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 (74) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +711 -0
  4. package/agents/gsd-advisor-researcher.md +2 -0
  5. package/agents/gsd-ai-researcher.md +1 -1
  6. package/agents/gsd-assumptions-analyzer.md +2 -0
  7. package/agents/gsd-code-fixer.md +2 -0
  8. package/agents/gsd-code-reviewer.md +2 -0
  9. package/agents/gsd-codebase-mapper.md +2 -0
  10. package/agents/gsd-debugger.md +2 -0
  11. package/agents/gsd-doc-writer.md +2 -0
  12. package/agents/gsd-eval-auditor.md +2 -0
  13. package/agents/gsd-executor.md +9 -6
  14. package/agents/gsd-integration-checker.md +2 -0
  15. package/agents/gsd-nyquist-auditor.md +2 -0
  16. package/agents/gsd-phase-researcher.md +2 -0
  17. package/agents/gsd-plan-checker.md +2 -0
  18. package/agents/gsd-planner.md +2 -0
  19. package/agents/gsd-project-researcher.md +2 -0
  20. package/agents/gsd-research-synthesizer.md +2 -0
  21. package/agents/gsd-roadmapper.md +2 -0
  22. package/agents/gsd-security-auditor.md +2 -0
  23. package/agents/gsd-ui-auditor.md +2 -0
  24. package/agents/gsd-ui-checker.md +2 -0
  25. package/agents/gsd-ui-researcher.md +2 -0
  26. package/agents/gsd-verifier.md +4 -2
  27. package/bin/install.js +118 -1
  28. package/gemini-extension.json +1 -1
  29. package/gsd-core/bin/gsd-tools.cjs +18 -7
  30. package/gsd-core/bin/lib/capability-loader.cjs +27 -9
  31. package/gsd-core/bin/lib/capability-registry.cjs +51 -49
  32. package/gsd-core/bin/lib/capability-source.cjs +22 -7
  33. package/gsd-core/bin/lib/capability-validator.cjs +24 -2
  34. package/gsd-core/bin/lib/commands.cjs +2 -1
  35. package/gsd-core/bin/lib/frontmatter.cjs +53 -6
  36. package/gsd-core/bin/lib/handshake-serialized.cjs +70 -0
  37. package/gsd-core/bin/lib/host-integration-sdk.cjs +53 -0
  38. package/gsd-core/bin/lib/host-integration.cjs +61 -0
  39. package/gsd-core/bin/lib/init.cjs +34 -6
  40. package/gsd-core/bin/lib/milestone.cjs +49 -10
  41. package/gsd-core/bin/lib/phase-id.cjs +18 -0
  42. package/gsd-core/bin/lib/phase.cjs +37 -27
  43. package/gsd-core/bin/lib/phases-command-router.cjs +4 -3
  44. package/gsd-core/bin/lib/probe-core.cjs +44 -4
  45. package/gsd-core/bin/lib/roadmap-command-router.cjs +3 -2
  46. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -11
  47. package/gsd-core/bin/lib/roadmap.cjs +28 -20
  48. package/gsd-core/bin/lib/state-transition.cjs +15 -0
  49. package/gsd-core/bin/lib/state.cjs +27 -8
  50. package/gsd-core/bin/lib/validate.cjs +2 -1
  51. package/gsd-core/bin/lib/verify.cjs +6 -4
  52. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +12 -2
  53. package/gsd-core/bin/lib/workstream-inventory.cjs +28 -0
  54. package/gsd-core/bin/shared/model-catalog.json +8 -8
  55. package/gsd-core/references/agent-skills-bootstrap.md +60 -0
  56. package/gsd-core/references/model-profiles.md +27 -0
  57. package/gsd-core/workflows/autonomous.md +22 -24
  58. package/gsd-core/workflows/complete-milestone.md +6 -10
  59. package/gsd-core/workflows/execute-phase.md +1 -1
  60. package/gsd-core/workflows/forensics.md +3 -3
  61. package/gsd-core/workflows/help/modes/full.md +1 -1
  62. package/gsd-core/workflows/milestone-summary.md +3 -3
  63. package/gsd-core/workflows/new-milestone.md +6 -0
  64. package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +42 -0
  65. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +102 -0
  66. package/gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md +23 -0
  67. package/gsd-core/workflows/plan-phase.md +3 -158
  68. package/gsd-core/workflows/review.md +7 -2
  69. package/gsd-core/workflows/settings-advanced.md +10 -10
  70. package/gsd-core/workflows/verify-work.md +1 -2
  71. package/package.json +3 -1
  72. package/scripts/run-tests.cjs +51 -1
  73. package/scripts/sync-manifest-versions.cjs +66 -14
  74. package/skills/gsd-review/SKILL.md +6 -0
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Serialized (out-of-process) capability-exchange handshake (ADR-1239 Phase E / #1683).
3
+ *
4
+ * Phase 1's `negotiateHostCapabilities` is IN-PROCESS (a host descriptor merged
5
+ * directly into the engine). Out-of-process SDK hosts (pi, VS Code) cannot share
6
+ * object references with the engine — they exchange a SERIALIZED capability set
7
+ * over a wire boundary (an MCP-style `initialize`). This module is the wire form
8
+ * of that handshake, kept CONSISTENT with the in-process negotiation: a request
9
+ * built + serialized here MUST yield the same NegotiationResult the in-process
10
+ * call produces for the same axes (asserted in tests/handshake-serialized.test.cjs).
11
+ *
12
+ * Wire shape (JSON — no object refs, safe across a process/IPC boundary):
13
+ *
14
+ * request = { protocolVersion: number, axes: Partial<HostIntegrationAxes> }
15
+ * response = NegotiationResult = { protocolVersion, effective, points, warnings }
16
+ *
17
+ * The engine side delegates to negotiateHostCapabilities; the host side builds
18
+ * the request from a descriptor. Both round-trip through JSON so the exchange is
19
+ * strictly serializable (a non-JSON-safe value would break the wire contract).
20
+ *
21
+ * Pure + additive: no I/O, no global state. The companion MCP server (Phase 4)
22
+ * or an SDK host binds this to a real transport.
23
+ */
24
+ 'use strict';
25
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
26
+ const hostIntegration = require("./host-integration.cjs");
27
+ /** The wire method name for the initialize-style exchange. */
28
+ const HANDSHAKE_METHOD = 'gsd/host-initialize';
29
+ /**
30
+ * Host side: build a JSON-serializable handshake request from a host descriptor.
31
+ * Accepts either `{ protocolVersion?, axes }` or a bare axes object.
32
+ * Defaults protocolVersion to the engine's current PROTOCOL_VERSION.
33
+ */
34
+ function buildHandshakeRequest(hostDescriptor) {
35
+ if (!hostDescriptor || typeof hostDescriptor !== 'object') {
36
+ throw new TypeError('buildHandshakeRequest: host descriptor (object) is required');
37
+ }
38
+ const desc = hostDescriptor;
39
+ const hasAxes = Object.prototype.hasOwnProperty.call(desc, 'axes');
40
+ const axes = (hasAxes ? desc.axes : desc);
41
+ if (!axes || typeof axes !== 'object') {
42
+ throw new TypeError('buildHandshakeRequest: descriptor.axes (object) is required');
43
+ }
44
+ const protocolVersion = typeof desc.protocolVersion === 'number' && Number.isFinite(desc.protocolVersion)
45
+ ? desc.protocolVersion
46
+ : hostIntegration.PROTOCOL_VERSION;
47
+ // Force a wire round-trip so a non-JSON-safe descriptor fails HERE, not later.
48
+ return JSON.parse(JSON.stringify({ protocolVersion, axes }));
49
+ }
50
+ /**
51
+ * Engine side: handle a serialized handshake request → a JSON-serializable
52
+ * NegotiationResult. Delegates to negotiateHostCapabilities, so the result is
53
+ * identical to the in-process negotiation for the same axes.
54
+ */
55
+ function handleHandshakeRequest(request, engine = hostIntegration.DEFAULT_ENGINE) {
56
+ if (!request || typeof request !== 'object') {
57
+ throw new TypeError('handleHandshakeRequest: request (object) is required');
58
+ }
59
+ const req = request;
60
+ const axes = (req.axes && typeof req.axes === 'object' ? req.axes : {});
61
+ const protocolVersion = req.protocolVersion;
62
+ const result = hostIntegration.negotiateHostCapabilities({ ...axes, ...(typeof protocolVersion === 'number' && Number.isFinite(protocolVersion) ? { protocolVersion } : {}) }, engine);
63
+ // Force a wire round-trip: the response must be strictly JSON-serializable.
64
+ return JSON.parse(JSON.stringify(result));
65
+ }
66
+ module.exports = {
67
+ HANDSHAKE_METHOD,
68
+ buildHandshakeRequest,
69
+ handleHandshakeRequest,
70
+ };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Host-Integration SDK — the published public surface (ADR-1239 Phase E / #1683).
3
+ *
4
+ * External host-plugin authors import ONLY from this entry. It IS the contract:
5
+ * everything it re-exports is public + versioned (PROTOCOL_VERSION governs the
6
+ * set); everything else in gsd-core is internal. An SDK smoke test
7
+ * (tests/sdk-smoke.test.cjs) builds a new host-plugin against this surface only
8
+ * — proving an external author can wire a host without reading gsd-core internals.
9
+ *
10
+ * Surface: the negotiated schema + classification, the five adapters
11
+ * (declarative/imperative/model/hook/state), and the serialized handshake.
12
+ * Frozen so the public shape cannot be mutated by consumers.
13
+ */
14
+ 'use strict';
15
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
16
+ const hostIntegration = require("./host-integration.cjs");
17
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
18
+ const adapterDeclarative = require("./adapter-declarative.cjs");
19
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
20
+ const adapterImperative = require("./adapter-imperative.cjs");
21
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
22
+ const modelAdapter = require("./model-adapter.cjs");
23
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
24
+ const hookBus = require("./hook-bus.cjs");
25
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
26
+ const stateIo = require("./state-io.cjs");
27
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
28
+ const handshake = require("./handshake-serialized.cjs");
29
+ const SDK = Object.freeze({
30
+ // ── Schema + protocol version ────────────────────────────────────────────
31
+ PROTOCOL_VERSION: hostIntegration.PROTOCOL_VERSION,
32
+ HOST_INTEGRATION_AXES: hostIntegration.HOST_INTEGRATION_AXES,
33
+ INTERFACE_POINTS: hostIntegration.INTERFACE_POINTS,
34
+ PROFILE_BASELINES: hostIntegration.PROFILE_BASELINES,
35
+ // ── Negotiation + classification ─────────────────────────────────────────
36
+ negotiateHostCapabilities: hostIntegration.negotiateHostCapabilities,
37
+ profileOf: hostIntegration.profileOf,
38
+ degradationFor: hostIntegration.degradationFor,
39
+ hookEventSurfaceFor: hostIntegration.hookEventSurfaceFor,
40
+ extensionEventSurfaceFor: hostIntegration.extensionEventSurfaceFor,
41
+ shouldFlattenDispatch: hostIntegration.shouldFlattenDispatch,
42
+ // ── Embedding + engine adapters ──────────────────────────────────────────
43
+ createDeclarativeAdapter: adapterDeclarative.createDeclarativeAdapter,
44
+ createImperativeAdapter: adapterImperative.createImperativeAdapter,
45
+ createModelAdapter: modelAdapter.createModelAdapter,
46
+ createHookBus: hookBus.createHookBus,
47
+ createStateIO: stateIo.createStateIO,
48
+ // ── Serialized handshake (out-of-process SDK hosts: pi / VS Code) ─────────
49
+ HANDSHAKE_METHOD: handshake.HANDSHAKE_METHOD,
50
+ buildHandshakeRequest: handshake.buildHandshakeRequest,
51
+ handleHandshakeRequest: handshake.handleHandshakeRequest,
52
+ });
53
+ module.exports = SDK;
@@ -394,6 +394,63 @@ function shouldFlattenDispatch(dispatch) {
394
394
  const canBackground = dispatch.background === true && dispatch.backgroundDispatch === true;
395
395
  return !canBackground;
396
396
  }
397
+ // ---------------------------------------------------------------------------
398
+ // Managed-hook event surface per hookEvents dialect (ADR-1239 / ADR-1016)
399
+ // ---------------------------------------------------------------------------
400
+ // Host-fireable MANAGED-hook events per `hookEvents` dialect. `hookEvents` is the
401
+ // managed-hook dialect — the event names GSD writes into a DECLARATIVE host's
402
+ // settings.json (claude = SessionStart/PreToolUse/…; gemini = BeforeTool/AfterTool).
403
+ // This is DISTINCT from the extension-system event surface (below): a host's
404
+ // plugin/extension API fires a different, plugin-owned event set. The two must
405
+ // not be conflated (ADR-1239 amendment / #1943 — the former 'opencode-subset'
406
+ // `hookEvents` value was this conflation; it is now `extensionEvents: opencode`).
407
+ const HOOK_EVENT_SURFACES = Object.freeze({
408
+ claude: Object.freeze(['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'SessionEnd', 'PreCompact']),
409
+ gemini: Object.freeze(['SessionStart', 'BeforeTool', 'AfterTool', 'SessionEnd']),
410
+ });
411
+ /**
412
+ * Resolve the managed-hook event surface for a `hookEvents` dialect.
413
+ * Returns null for unknown/missing dialects (fail-closed). Pure, never throws.
414
+ */
415
+ function hookEventSurfaceFor(hookEvents) {
416
+ if (typeof hookEvents !== 'string')
417
+ return null;
418
+ return HOOK_EVENT_SURFACES[hookEvents] || null;
419
+ }
420
+ // ---------------------------------------------------------------------------
421
+ // Extension-system event surface (ADR-1239 amendment / #1943)
422
+ // ---------------------------------------------------------------------------
423
+ // The events a host's PLUGIN/EXTENSION API exposes — for imperative-embedding
424
+ // hosts that load GSD as a plugin. This is a SEPARATE vocabulary + descriptor
425
+ // field (`extensionEvents`) from `hookEvents`: hookEvents = the managed-hook
426
+ // dialect (declarative hosts' settings.json); extensionEvents = the plugin-owned
427
+ // event subset (imperative hosts' extension API). They are not the same thing.
428
+ //
429
+ // Values are documentation-sourced (ADR-1239 §research): OpenCode ~25 plugin
430
+ // events (session/tool/file/permission); pi ~30 fine-grained extension events;
431
+ // 'none' = the host exposes no extension surface and the engine owns the bus
432
+ // (VS Code). Declarative hosts (no plugin API) do not set `extensionEvents`.
433
+ const EXTENSION_EVENT_SURFACES = Object.freeze({
434
+ opencode: Object.freeze([
435
+ 'session.created', 'session.idle', 'experimental.session.compacting',
436
+ 'tool.execute.before', 'tool.execute.after', 'file.edited',
437
+ ]),
438
+ pi: Object.freeze(['tool_call']),
439
+ none: Object.freeze([]),
440
+ });
441
+ /**
442
+ * Resolve the extension-system event surface for an `extensionEvents` dialect.
443
+ * Returns null for unknown/missing dialects (fail-closed). Pure, never throws.
444
+ *
445
+ * A non-null result is what makes an `extensionEvents` value a CONSUMED value
446
+ * rather than reserved vocab. For 'opencode' it carries NO workflow-phase events
447
+ * — the engine owns phase sequencing internally on such hosts (ADR-1239 §OpenCode).
448
+ */
449
+ function extensionEventSurfaceFor(extensionEvents) {
450
+ if (typeof extensionEvents !== 'string')
451
+ return null;
452
+ return EXTENSION_EVENT_SURFACES[extensionEvents] || null;
453
+ }
397
454
  module.exports = {
398
455
  PROTOCOL_VERSION,
399
456
  UNDOCUMENTED,
@@ -401,8 +458,12 @@ module.exports = {
401
458
  INTERFACE_POINTS,
402
459
  PROFILE_BASELINES,
403
460
  DEFAULT_ENGINE,
461
+ HOOK_EVENT_SURFACES,
462
+ EXTENSION_EVENT_SURFACES,
404
463
  degradationFor,
405
464
  profileOf,
406
465
  negotiateHostCapabilities,
407
466
  shouldFlattenDispatch,
467
+ hookEventSurfaceFor,
468
+ extensionEventSurfaceFor,
408
469
  };
@@ -60,9 +60,9 @@ const { resolveModelInternal, resolveGranularityInternal, assertValidGranularity
60
60
  const { findPhaseInternal } = phaseLocator;
61
61
  const { getRoadmapPhaseInternal, getMilestoneInfo, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, } = roadmapParser;
62
62
  const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils;
63
- const { normalizePhaseName, phaseTokenMatches } = phaseId;
63
+ const { normalizePhaseName, phaseTokenMatches, stripProjectCodePrefix } = phaseId;
64
64
  const { pruneOrphanedWorktrees } = worktreeSafety;
65
- const { planningPaths, planningDir, planningRoot, findContextMdIn, } = planningWorkspace;
65
+ const { planningPaths, planningDir, planningRoot, getActiveWorkstream, findContextMdIn, } = planningWorkspace;
66
66
  const { determinePhaseStatus } = commandsMod;
67
67
  const { extractFrontmatter } = frontmatterMod;
68
68
  const { readVerificationStatus } = verificationMod;
@@ -919,9 +919,12 @@ function cmdInitMilestoneOp(cwd, raw) {
919
919
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
920
920
  const roadmapRaw = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
921
921
  const currentSection = extractCurrentMilestone(roadmapRaw, cwd);
922
- const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi;
922
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
923
+ const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:/gi;
923
924
  let m;
924
925
  while ((m = phasePattern.exec(currentSection)) !== null) {
926
+ if (/^999(?:\.|$)/.test(m[1]))
927
+ continue;
925
928
  roadmapPhaseNumbers.push(m[1]);
926
929
  }
927
930
  }
@@ -938,7 +941,7 @@ function cmdInitMilestoneOp(cwd, raw) {
938
941
  for (const e of entries) {
939
942
  if (!e.isDirectory())
940
943
  continue;
941
- const m = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/);
944
+ const m = stripProjectCodePrefix(e.name).match(/^(\d+[A-Z]?(?:\.\d+)*)/);
942
945
  if (!m)
943
946
  continue;
944
947
  diskPhaseDirs.set(canonicalizePhase(m[1]), e.name);
@@ -1071,7 +1074,8 @@ function cmdInitManager(cwd, raw) {
1071
1074
  while ((_cbMatch = _cbPattern.exec(content)) !== null) {
1072
1075
  _checkboxStates.set(_cbMatch[2], _cbMatch[1].toLowerCase() === 'x');
1073
1076
  }
1074
- const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
1077
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1078
+ const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
1075
1079
  const phases = [];
1076
1080
  let match;
1077
1081
  while ((match = phasePattern.exec(content)) !== null) {
@@ -1355,6 +1359,29 @@ function cmdInitProgress(cwd, raw) {
1355
1359
  const config = loadConfig(cwd);
1356
1360
  const milestone = getMilestoneInfo(cwd);
1357
1361
  const _slashRuntime = (0, runtime_slash_cjs_1.resolveRuntime)(cwd);
1362
+ // #1912: fail safe in workstream mode with no active workstream. With no active
1363
+ // workstream and no --ws, planningDir(cwd) resolves to root .planning — silently
1364
+ // reporting a stale root milestone. Require an explicit workstream instead.
1365
+ // Mirror planningDir's resolution (GSD_WORKSTREAM env > stored active pointer) so
1366
+ // an explicit --ws (which sets GSD_WORKSTREAM) satisfies the check.
1367
+ const _wsRoot = node_path_1.default.join(planningRoot(cwd), 'workstreams');
1368
+ let _availableWorkstreams = [];
1369
+ try {
1370
+ _availableWorkstreams = node_fs_1.default
1371
+ .readdirSync(_wsRoot, { withFileTypes: true })
1372
+ .filter((e) => e.isDirectory())
1373
+ .map((e) => e.name)
1374
+ .sort();
1375
+ }
1376
+ catch {
1377
+ /* no workstreams dir → flat mode */
1378
+ }
1379
+ const _resolvedWorkstream = process.env['GSD_WORKSTREAM'] || getActiveWorkstream(cwd);
1380
+ if (_availableWorkstreams.length > 0 && !_resolvedWorkstream) {
1381
+ error(`init.progress requires a workstream in workstream mode — no active workstream is set, so root STATE.md (likely stale) would be reported. ` +
1382
+ `Pass --ws <name> or run ${(0, runtime_slash_cjs_1.formatGsdSlash)('workstream set', _slashRuntime)} first. ` +
1383
+ `Available workstreams: ${_availableWorkstreams.join(', ')}`);
1384
+ }
1358
1385
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
1359
1386
  const phases = [];
1360
1387
  let currentPhase = null;
@@ -1364,7 +1391,8 @@ function cmdInitProgress(cwd, raw) {
1364
1391
  const roadmapCheckboxStates = new Map();
1365
1392
  try {
1366
1393
  const roadmapContent = extractCurrentMilestone(node_fs_1.default.readFileSync(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'), cwd);
1367
- const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
1394
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1395
+ const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
1368
1396
  let hm;
1369
1397
  while ((hm = headingPattern.exec(roadmapContent)) !== null) {
1370
1398
  roadmapPhaseNums.add(hm[1]);
@@ -29,7 +29,7 @@ const phaseIdMod = require("./phase-id.cjs");
29
29
  const { escapeRegex, normalizePhaseName, phaseTokenMatches } = phaseIdMod;
30
30
  // eslint-disable-next-line @typescript-eslint/no-require-imports
31
31
  const roadmapParserMod = require("./roadmap-parser.cjs");
32
- const { getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod;
32
+ const { getMilestonePhaseFilter, extractCurrentMilestone, getMilestoneInfo } = roadmapParserMod;
33
33
  // eslint-disable-next-line @typescript-eslint/no-require-imports
34
34
  const coreUtilsMod = require("./core-utils.cjs");
35
35
  const { extractOneLinerFromBody } = coreUtilsMod;
@@ -112,8 +112,13 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
112
112
  const roadmapPath = planningPaths(cwd).roadmap;
113
113
  const reqPath = planningPaths(cwd).requirements;
114
114
  const statePath = planningPaths(cwd).state;
115
- const milestonesPath = node_path_1.default.join(cwd, '.planning', 'MILESTONES.md');
116
- const archiveDir = node_path_1.default.join(cwd, '.planning', 'milestones');
115
+ // #1911: derive the archive base from the workstream-aware planning root so
116
+ // `milestone complete --ws` archives into the workstream, not root. planningPaths(cwd).planning
117
+ // resolves to the workstream base when GSD_WORKSTREAM is set and to root .planning otherwise
118
+ // (flat mode is a no-op).
119
+ const planningBase = planningPaths(cwd).planning;
120
+ const milestonesPath = node_path_1.default.join(planningBase, 'MILESTONES.md');
121
+ const archiveDir = node_path_1.default.join(planningBase, 'milestones');
117
122
  const phasesDir = planningPaths(cwd).phases;
118
123
  const today = new Date().toISOString().split('T')[0];
119
124
  const milestoneName = options.name || version;
@@ -151,7 +156,8 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
151
156
  if (stateVersion && stateVersion === version) {
152
157
  const roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
153
158
  const scopedContent = extractCurrentMilestone(roadmapContent, cwd);
154
- const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
159
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
160
+ const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
155
161
  const noDirectoryPhases = [];
156
162
  let pm;
157
163
  const phaseDirEntries = (() => {
@@ -261,7 +267,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
261
267
  (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(archiveDir, `${version}-REQUIREMENTS.md`), archiveHeader + reqContent);
262
268
  }
263
269
  // Archive audit file if exists
264
- const auditFile = node_path_1.default.join(cwd, '.planning', `${version}-MILESTONE-AUDIT.md`);
270
+ const auditFile = node_path_1.default.join(planningBase, `${version}-MILESTONE-AUDIT.md`);
265
271
  if (node_fs_1.default.existsSync(auditFile)) {
266
272
  (0, shell_command_projection_cjs_1.retryRenameSync)(auditFile, node_path_1.default.join(archiveDir, `${version}-MILESTONE-AUDIT.md`));
267
273
  }
@@ -309,7 +315,8 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
309
315
  }
310
316
  // Archive phase directories if requested
311
317
  let phasesArchived = false;
312
- if (options.archivePhases) {
318
+ // #1871: archive phase dirs by default on milestone complete (opt out via --no-archive-phases).
319
+ if (options.archivePhases !== false) {
313
320
  try {
314
321
  const phaseArchiveDir = node_path_1.default.join(archiveDir, `${version}-phases`);
315
322
  (0, shell_command_projection_cjs_1.platformEnsureDir)(phaseArchiveDir);
@@ -398,10 +405,8 @@ function cmdPhasesClear(cwd, raw, args) {
398
405
  }
399
406
  }
400
407
  try {
401
- for (const entry of dirs) {
402
- node_fs_1.default.rmSync(node_path_1.default.join(phasesDir, entry.name), { recursive: true, force: true });
403
- cleared++;
404
- }
408
+ // #1871: archive phase directories instead of destroying them (shared helper).
409
+ cleared = archivePhaseDirectories(cwd, phasesDir, dirs).archived;
405
410
  }
406
411
  catch (e) {
407
412
  const message = e instanceof Error ? e.message : String(e);
@@ -410,6 +415,40 @@ function cmdPhasesClear(cwd, raw, args) {
410
415
  }
411
416
  output({ cleared }, raw, `${cleared} phase director${cleared === 1 ? 'y' : 'ies'} cleared`);
412
417
  }
418
+ /**
419
+ * #1871: move each non-999 phase directory under `phasesDir` into
420
+ * `milestones/<version>-phases/` (collision-safe; version from getMilestoneInfo,
421
+ * timestamp fallback). Shared by `phases clear` (archive-then-remove) and the
422
+ * internal milestone.complete phase archival so phase history survives a
423
+ * milestone switch instead of being hard-deleted.
424
+ */
425
+ function archivePhaseDirectories(cwd, phasesDir, dirs) {
426
+ let archiveVersion = null;
427
+ try {
428
+ archiveVersion = getMilestoneInfo(cwd).version ?? null;
429
+ }
430
+ catch {
431
+ /* ROADMAP/STATE unreadable — fall back to a dated label */
432
+ }
433
+ if (!archiveVersion) {
434
+ archiveVersion = `archived-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 8)}`;
435
+ }
436
+ const archivePhasesDir = node_path_1.default.join(planningPaths(cwd).planning, 'milestones', `${archiveVersion}-phases`);
437
+ (0, shell_command_projection_cjs_1.platformEnsureDir)(archivePhasesDir);
438
+ let archived = 0;
439
+ for (const entry of dirs) {
440
+ const src = node_path_1.default.join(phasesDir, entry.name);
441
+ // Collision-safe: if a same-named archive entry exists (re-run), suffix it.
442
+ let dest = node_path_1.default.join(archivePhasesDir, entry.name);
443
+ let n = 1;
444
+ while (node_fs_1.default.existsSync(dest)) {
445
+ dest = node_path_1.default.join(archivePhasesDir, `${entry.name}.${n++}`);
446
+ }
447
+ (0, shell_command_projection_cjs_1.retryRenameSync)(src, dest);
448
+ archived++;
449
+ }
450
+ return { archiveDir: archivePhasesDir, archived };
451
+ }
413
452
  module.exports = {
414
453
  cmdRequirementsMarkComplete,
415
454
  cmdMilestoneComplete,
@@ -20,6 +20,23 @@ const PROJECT_CODE_PREFIX_STRIP_RE = /^[A-Z][A-Z0-9_]*-(?=\d)/;
20
20
  const PROJECT_CODE_PREFIX_STRIP_RE_I = /^[A-Z][A-Z0-9_]*-(?=\d)/i;
21
21
  const PROJECT_CODE_PREFIX_CAPTURE_RE_I = /^([A-Z][A-Z0-9_]*)-(\d.*)/i;
22
22
  const OPTIONAL_PROJECT_CODE_PREFIX_SOURCE = '(?:[A-Z][A-Z0-9_]*-)?';
23
+ // #1729: phase headers may carry a parenthetical tag between the number and the
24
+ // colon, e.g. `### Phase 26 (Cluster B): Title`. This optional, non-capturing
25
+ // fragment is injected at every phase-header regex call site (immediately after
26
+ // the phase-number token, before the colon/space delimiter) so the resolver
27
+ // tolerates the tag — mirroring how `[...]` is already tolerated before `Phase`.
28
+ // `[^)\n]*` keeps the match single-line (headers are one line) to avoid
29
+ // over-consuming across a malformed multi-line document. Injected at the call
30
+ // site (not baked into phaseMarkdownRegexSource) so it applies uniformly to
31
+ // both the numeric and project-code-exact escaped sources, and so the decimal
32
+ // sub-phase patterns can place it after the `.N` segment.
33
+ //
34
+ // Enumeration/parse call sites that read phase headers from a regex *literal*
35
+ // (rather than a `new RegExp` built from an interpolated phase number) cannot
36
+ // reference this constant; they inline its literal-regex mirror instead —
37
+ // `(?:\s*\([^)\n]*\))?` — kept character-for-character equivalent to this
38
+ // source. Both forms must change together; see the #1729 regression test.
39
+ const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]*\\))?';
23
40
  function stripProjectCodePrefix(value, caseInsensitive = true) {
24
41
  const input = String(value);
25
42
  const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE;
@@ -215,6 +232,7 @@ function phaseTokenMatches(dirName, normalized) {
215
232
  module.exports = {
216
233
  escapeRegex,
217
234
  OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
235
+ OPTIONAL_PHASE_TAG_SOURCE,
218
236
  stripProjectCodePrefix,
219
237
  normalizePhaseName,
220
238
  getMilestoneFromPhaseId,
@@ -32,7 +32,7 @@ const coreUtilsMod = require("./core-utils.cjs");
32
32
  const { toPosixPath, generateSlugInternal, readSubdirectories } = coreUtilsMod;
33
33
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
34
34
  const phaseIdMod = require("./phase-id.cjs");
35
- const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, phaseTokenMatches, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, } = phaseIdMod;
35
+ const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, phaseTokenMatches, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, } = phaseIdMod;
36
36
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
37
37
  const phaseLocatorMod = require("./phase-locator.cjs");
38
38
  const { findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorMod;
@@ -181,7 +181,7 @@ function cmdPhaseNextDecimal(cwd, basePhase, raw) {
181
181
  if (node_fs_1.default.existsSync(roadmapPath)) {
182
182
  try {
183
183
  const roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
184
- const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalized)}\\.(\\d+)\\s*:`, 'gi');
184
+ const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalized)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'gi');
185
185
  let pm;
186
186
  while ((pm = phasePattern.exec(roadmapContent)) !== null) {
187
187
  decimalSet.add(parseInt(pm[1], 10));
@@ -221,7 +221,7 @@ function getRoadmapModeForPhase(cwd, phaseNum) {
221
221
  const milestoneContent = extractCurrentMilestone(rawContent, cwd);
222
222
  const fullContent = stripShippedMilestones(rawContent);
223
223
  const escapedPhase = phaseMarkdownRegexSource(phaseNum);
224
- const phaseHeader = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}\\s*:`, 'i');
224
+ const phaseHeader = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'i');
225
225
  for (const content of [milestoneContent, fullContent]) {
226
226
  const headerMatch = content.match(phaseHeader);
227
227
  if (!headerMatch || headerMatch.index === undefined)
@@ -578,7 +578,8 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
578
578
  // Three sources are scanned so that a phase in ANY representation
579
579
  // (section header, roadmap bullet, or on-disk directory) is counted:
580
580
  // 1) Section headers: ### Phase N: / ## Phase N: / #### Phase N:
581
- const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
581
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
582
+ const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]*\))?:/gi;
582
583
  // 2) Roadmap bullet entries: - [ ] **Phase N: ...** (all checkbox variants)
583
584
  // The lookahead accepts colon, decimal-dot, whitespace, bold-close asterisk,
584
585
  // or end-of-line so titleless forms ("- [ ] **Phase 11**", "- [ ] Phase 11")
@@ -662,7 +663,8 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
662
663
  const content = extractCurrentMilestone(rawContent, cwd);
663
664
  let maxPhase = 0;
664
665
  if (config.phase_naming !== 'custom') {
665
- const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
666
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
667
+ const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]*\))?:/gi;
666
668
  let m;
667
669
  while ((m = phasePattern.exec(content)) !== null) {
668
670
  const num = parseInt(m[1], 10);
@@ -740,14 +742,14 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
740
742
  const content = extractCurrentMilestone(rawContent, cwd);
741
743
  const normalizedAfter = normalizePhaseName(afterPhase);
742
744
  const afterPhaseEscaped = phaseMarkdownRegexSource(normalizedAfter);
743
- const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+${afterPhaseEscaped}:`, 'i');
745
+ const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:`, 'i');
744
746
  const headingMatch = targetPattern.test(content);
745
- const bulletPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}[:\\s]`, 'i');
747
+ const bulletPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
746
748
  const anyHeadingPattern = /#{2,4}\s*Phase\s+\d/i;
747
749
  const roadmapHasHeadingPhases = anyHeadingPattern.test(content);
748
750
  const isBulletStyle = !headingMatch && bulletPattern.test(content) && !roadmapHasHeadingPhases;
749
751
  if (!headingMatch && !isBulletStyle) {
750
- const checklistPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}[:\\s]`, 'i');
752
+ const checklistPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
751
753
  if (checklistPattern.test(content)) {
752
754
  error(`Phase ${afterPhase} exists in roadmap summary but is missing a detail section (### Phase ${afterPhase}: ...).`);
753
755
  }
@@ -769,7 +771,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
769
771
  catch {
770
772
  /* intentionally empty */
771
773
  }
772
- const rmPhasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalizedBase)}\\.(\\d+)\\s*:`, 'gi');
774
+ const rmPhasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalizedBase)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'gi');
773
775
  let rmMatch;
774
776
  while ((rmMatch = rmPhasePattern.exec(rawContent)) !== null) {
775
777
  decimalSet.add(parseInt(rmMatch[1], 10));
@@ -785,13 +787,13 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
785
787
  (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(dirPath, '.gitkeep'), '');
786
788
  let updatedContent;
787
789
  if (isBulletStyle) {
788
- const boldBulletPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${afterPhaseEscaped}:`, 'i');
790
+ const boldBulletPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:`, 'i');
789
791
  const useBold = boldBulletPattern.test(content);
790
792
  const phaseLabel = useBold
791
793
  ? `**Phase ${_decimalPhase}: ${description}**`
792
794
  : `Phase ${_decimalPhase}: ${description}`;
793
795
  const bulletEntry = `\n- [ ] ${phaseLabel}`;
794
- const targetBulletPattern = new RegExp(`(-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}[:\\s][^\\n]*)`, 'i');
796
+ const targetBulletPattern = new RegExp(`(-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i');
795
797
  const bulletMatchResult = rawContent.match(targetBulletPattern);
796
798
  if (!bulletMatchResult) {
797
799
  error(`Could not find Phase ${afterPhase} bullet line`);
@@ -811,7 +813,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
811
813
  }
812
814
  else {
813
815
  const phaseEntry = `\n### Phase ${_decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${_decimalPhase} to break down)\n`;
814
- const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
816
+ const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:[^\\n]*\\n)`, 'i');
815
817
  const headerMatch = rawContent.match(headerPattern);
816
818
  if (!headerMatch) {
817
819
  error(`Could not find Phase ${afterPhase} header`);
@@ -940,11 +942,13 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
940
942
  withPlanningLock(cwd, () => {
941
943
  let content = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
942
944
  const escaped = escapeRegex(targetPhase);
943
- content = content.replace(new RegExp(`\\n?(?<h>#{2,4})\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n\\k<h>(?!#)\\s+Phase\\s+[^\\n:]+\\s*:|$)`, 'i'), '');
944
- content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}[:\\s][^\\n]*`, 'gi'), '');
945
+ content = content.replace(new RegExp(`\\n?(?<h>#{2,4})\\s*Phase\\s+${escaped}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:[\\s\\S]*?(?=\\n\\k<h>(?!#)\\s+Phase\\s+[^\\n:]+\\s*:|$)`, 'i'), '');
946
+ content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*`, 'gi'), '');
945
947
  content = content.replace(new RegExp(`\\n?\\|\\s*${escaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi'), '');
946
948
  if (!isDecimal) {
947
- content = content.replace(/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)(\s*:)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`);
949
+ // #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
950
+ // is re-emitted verbatim — a tagged later phase still gets renumbered.
951
+ content = content.replace(/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)((?:\s*\([^)\n]*\))?\s*:)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`);
948
952
  content = content.replace(/(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
949
953
  content = content.replace(/(\|\s*)(\d+)(\.\s)/g, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
950
954
  content = content.replace(/(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)?-(?:PLAN|SUMMARY)\.md)|(?![0-9-]))/g, (_match, phaseNum, planNum) => `${decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`);
@@ -1047,7 +1051,7 @@ function phaseDisplayNameFromRoadmap(roadmapContent, phaseNum) {
1047
1051
  if (!roadmapContent || !phaseNum)
1048
1052
  return null;
1049
1053
  const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
1050
- const heading = roadmapContent.match(new RegExp(`^#{2,4}\\s*Phase\\s+${phaseEscaped}\\s*:\\s*([^\\n]+)`, 'im'));
1054
+ const heading = roadmapContent.match(new RegExp(`^#{2,4}\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:\\s*([^\\n]+)`, 'im'));
1051
1055
  if (!heading)
1052
1056
  return null;
1053
1057
  const name = heading[1].replace(/\(INSERTED\)/i, '').trim();
@@ -1128,7 +1132,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1128
1132
  const originalRoadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1129
1133
  roadmapContent = originalRoadmapContent;
1130
1134
  const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
1131
- const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`, 'i');
1135
+ const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i');
1132
1136
  roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
1133
1137
  const tableRowPattern = new RegExp(`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`, 'im');
1134
1138
  roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
@@ -1170,7 +1174,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1170
1174
  if (node_fs_1.default.existsSync(reqPath)) {
1171
1175
  const phaseEsc = phaseMarkdownRegexSource(phaseNum);
1172
1176
  const currentMilestoneRoadmap = extractCurrentMilestone(roadmapContent, cwd);
1173
- const phaseSectionMatch = currentMilestoneRoadmap.match(new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEsc}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`, 'i'));
1177
+ const phaseSectionMatch = currentMilestoneRoadmap.match(new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEsc}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`, 'i'));
1174
1178
  const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : '';
1175
1179
  const reqMatch = sectionText.match(/\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]+)/i);
1176
1180
  const originalReqContent = node_fs_1.default.readFileSync(reqPath, 'utf-8');
@@ -1288,15 +1292,21 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1288
1292
  try {
1289
1293
  const roadmapForPhases = extractCurrentMilestone(roadmapContent, cwd);
1290
1294
  // #1591: match BOTH heading-style phases (`### Phase N:`) AND
1291
- // checkbox-list items (`- [ ] Phase N:` / `- [x] Phase N:`). When
1292
- // the active milestone's checklist is `- [ ]` items inside a
1293
- // <details> block (and the next phase has no directory yet, so the
1294
- // disk-based resolver finds nothing), this roadmap-enumeration
1295
- // fallback is the only path that can find the next phase. The prior
1296
- // heading-only pattern missed checkbox items → is_last_phase=true on
1297
- // a mid-milestone phase. The marker alternation is the only change;
1298
- // the number/name captures are unchanged.
1299
- const phasePattern = /(?:#{2,4}|-\s*\[[ xX]\])\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
1295
+ // checkbox-list items, INCLUDING the canonical bold form the roadmap
1296
+ // template emits (`- [ ] **Phase N: Name**`). When the active
1297
+ // milestone's checklist is `- [ ]` items inside a <details> block
1298
+ // (and the next phase has no directory yet, so the disk-based
1299
+ // resolver finds nothing), this roadmap-enumeration fallback is the
1300
+ // only path that can find the next phase. The prior heading-only
1301
+ // pattern missed checkbox items, and a checkbox-only broadening still
1302
+ // missed the bold template rows → is_last_phase=true on a mid-milestone
1303
+ // phase. Allow optional `**`/`__` emphasis after the marker and stop
1304
+ // the name capture at emphasis so bold names slug cleanly; the number
1305
+ // capture is unchanged.
1306
+ // #1729: `(?:\s*\([^)\n]*\))?` after the number tolerates a pre-colon
1307
+ // ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE) so
1308
+ // `### Phase N (Cluster B): X` resolves. Captures are unchanged.
1309
+ const phasePattern = /(?:#{2,4}|-\s*\[[ xX]\])\s*(?:\*\*|__)?\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n*]+)/gi;
1300
1310
  let pm;
1301
1311
  while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
1302
1312
  if (comparePhaseNum(pm[1], phaseNum) > 0) {
@@ -4,8 +4,8 @@
4
4
  * Keeps gsd-tools.cjs thin while preserving current CJS semantics.
5
5
  *
6
6
  * Unsupported in this router (treated as unknown):
7
- * - archive: `phases archive` is excluded from the subcommands list so it
8
- * falls through to the unknown-subcommand error path.
7
+ * - archive: `phases archive` is excluded (#2684) — it is an internal
8
+ * subcommand that milestone.complete forwards to, not a public surface.
9
9
  *
10
10
  * ADR-457 build-at-publish: the hand-written bin/lib/phases-command-router.cjs
11
11
  * collapsed to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
@@ -19,7 +19,8 @@ const { routeCjsCommandFamily } = cjsCommandRouterAdapter;
19
19
  function routePhasesCommand({ phase, milestone, args, cwd, raw, error }) {
20
20
  routeCjsCommandFamily({
21
21
  args,
22
- // Exclude 'archive' so it hits the unknownMessage path.
22
+ // #2684: `phases archive` is deliberately excluded — it is an internal
23
+ // subcommand that milestone.complete forwards to, not a public surface.
23
24
  subcommands: command_aliases_cjs_1.PHASES_SUBCOMMANDS.filter((s) => s !== 'archive'),
24
25
  error,
25
26
  unknownMessage: (_subcommand, available) => `Unknown phases subcommand. Available: ${available.join(', ')}`,