@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.
- package/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +711 -0
- package/agents/gsd-advisor-researcher.md +2 -0
- package/agents/gsd-ai-researcher.md +1 -1
- package/agents/gsd-assumptions-analyzer.md +2 -0
- package/agents/gsd-code-fixer.md +2 -0
- package/agents/gsd-code-reviewer.md +2 -0
- package/agents/gsd-codebase-mapper.md +2 -0
- package/agents/gsd-debugger.md +2 -0
- package/agents/gsd-doc-writer.md +2 -0
- package/agents/gsd-eval-auditor.md +2 -0
- package/agents/gsd-executor.md +9 -6
- package/agents/gsd-integration-checker.md +2 -0
- package/agents/gsd-nyquist-auditor.md +2 -0
- package/agents/gsd-phase-researcher.md +2 -0
- package/agents/gsd-plan-checker.md +2 -0
- package/agents/gsd-planner.md +2 -0
- package/agents/gsd-project-researcher.md +2 -0
- package/agents/gsd-research-synthesizer.md +2 -0
- package/agents/gsd-roadmapper.md +2 -0
- package/agents/gsd-security-auditor.md +2 -0
- package/agents/gsd-ui-auditor.md +2 -0
- package/agents/gsd-ui-checker.md +2 -0
- package/agents/gsd-ui-researcher.md +2 -0
- package/agents/gsd-verifier.md +4 -2
- package/bin/install.js +118 -1
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +18 -7
- package/gsd-core/bin/lib/capability-loader.cjs +27 -9
- package/gsd-core/bin/lib/capability-registry.cjs +51 -49
- package/gsd-core/bin/lib/capability-source.cjs +22 -7
- package/gsd-core/bin/lib/capability-validator.cjs +24 -2
- package/gsd-core/bin/lib/commands.cjs +2 -1
- package/gsd-core/bin/lib/frontmatter.cjs +53 -6
- package/gsd-core/bin/lib/handshake-serialized.cjs +70 -0
- package/gsd-core/bin/lib/host-integration-sdk.cjs +53 -0
- package/gsd-core/bin/lib/host-integration.cjs +61 -0
- package/gsd-core/bin/lib/init.cjs +34 -6
- package/gsd-core/bin/lib/milestone.cjs +49 -10
- package/gsd-core/bin/lib/phase-id.cjs +18 -0
- package/gsd-core/bin/lib/phase.cjs +37 -27
- package/gsd-core/bin/lib/phases-command-router.cjs +4 -3
- package/gsd-core/bin/lib/probe-core.cjs +44 -4
- package/gsd-core/bin/lib/roadmap-command-router.cjs +3 -2
- package/gsd-core/bin/lib/roadmap-parser.cjs +21 -11
- package/gsd-core/bin/lib/roadmap.cjs +28 -20
- package/gsd-core/bin/lib/state-transition.cjs +15 -0
- package/gsd-core/bin/lib/state.cjs +27 -8
- package/gsd-core/bin/lib/validate.cjs +2 -1
- package/gsd-core/bin/lib/verify.cjs +6 -4
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +12 -2
- package/gsd-core/bin/lib/workstream-inventory.cjs +28 -0
- package/gsd-core/bin/shared/model-catalog.json +8 -8
- package/gsd-core/references/agent-skills-bootstrap.md +60 -0
- package/gsd-core/references/model-profiles.md +27 -0
- package/gsd-core/workflows/autonomous.md +22 -24
- package/gsd-core/workflows/complete-milestone.md +6 -10
- package/gsd-core/workflows/execute-phase.md +1 -1
- package/gsd-core/workflows/forensics.md +3 -3
- package/gsd-core/workflows/help/modes/full.md +1 -1
- package/gsd-core/workflows/milestone-summary.md +3 -3
- package/gsd-core/workflows/new-milestone.md +6 -0
- package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +42 -0
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +102 -0
- package/gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md +23 -0
- package/gsd-core/workflows/plan-phase.md +3 -158
- package/gsd-core/workflows/review.md +7 -2
- package/gsd-core/workflows/settings-advanced.md +10 -10
- package/gsd-core/workflows/verify-work.md +1 -2
- package/package.json +3 -1
- package/scripts/run-tests.cjs +51 -1
- package/scripts/sync-manifest-versions.cjs +66 -14
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1292
|
-
//
|
|
1293
|
-
//
|
|
1294
|
-
//
|
|
1295
|
-
//
|
|
1296
|
-
//
|
|
1297
|
-
//
|
|
1298
|
-
// the
|
|
1299
|
-
|
|
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
|
|
8
|
-
*
|
|
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
|
-
//
|
|
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(', ')}`,
|