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

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 (68) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-verifier.md +1 -0
  3. package/bin/gsd-mcp-server.js +31 -0
  4. package/bin/install.js +293 -1145
  5. package/commands/gsd/review.md +6 -0
  6. package/gemini-extension.json +1 -1
  7. package/gsd-core/bin/gsd-tools.cjs +116 -1
  8. package/gsd-core/bin/lib/adapter-declarative.cjs +35 -0
  9. package/gsd-core/bin/lib/adapter-imperative.cjs +52 -0
  10. package/gsd-core/bin/lib/assumption-delta.cjs +231 -0
  11. package/gsd-core/bin/lib/capability-lifecycle.cjs +7 -7
  12. package/gsd-core/bin/lib/capability-loader.cjs +18 -0
  13. package/gsd-core/bin/lib/capability-lock.cjs +2 -2
  14. package/gsd-core/bin/lib/capability-registry.cjs +889 -82
  15. package/gsd-core/bin/lib/capability-source.cjs +4 -4
  16. package/gsd-core/bin/lib/capability-validator.cjs +198 -0
  17. package/gsd-core/bin/lib/cli-skew-check.cjs +44 -0
  18. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  19. package/gsd-core/bin/lib/config.cjs +27 -0
  20. package/gsd-core/bin/lib/embedding-adapter.cjs +27 -0
  21. package/gsd-core/bin/lib/external-descriptor-trust.cjs +70 -0
  22. package/gsd-core/bin/lib/hook-bus.cjs +81 -0
  23. package/gsd-core/bin/lib/host-integration.cjs +408 -0
  24. package/gsd-core/bin/lib/init.cjs +1 -1
  25. package/gsd-core/bin/lib/install-engine.cjs +755 -0
  26. package/gsd-core/bin/lib/install-profiles.cjs +35 -4
  27. package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
  28. package/gsd-core/bin/lib/mcp-server.cjs +194 -0
  29. package/gsd-core/bin/lib/milestone.cjs +27 -30
  30. package/gsd-core/bin/lib/model-adapter.cjs +50 -0
  31. package/gsd-core/bin/lib/phase.cjs +41 -72
  32. package/gsd-core/bin/lib/planning-workspace.cjs +1 -1
  33. package/gsd-core/bin/lib/probe-core.cjs +91 -1
  34. package/gsd-core/bin/lib/review-reviewer-selection.cjs +129 -13
  35. package/gsd-core/bin/lib/roadmap-upgrade.cjs +3 -2
  36. package/gsd-core/bin/lib/roadmap.cjs +17 -3
  37. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +65 -9
  38. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +54 -4
  39. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +5 -2
  40. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1 -1
  41. package/gsd-core/bin/lib/runtime-name-policy.cjs +160 -30
  42. package/gsd-core/bin/lib/shell-command-projection.cjs +37 -1
  43. package/gsd-core/bin/lib/stale-bake-guard.cjs +254 -0
  44. package/gsd-core/bin/lib/state-command-router.cjs +4 -0
  45. package/gsd-core/bin/lib/state-io.cjs +55 -0
  46. package/gsd-core/bin/lib/state-transition.cjs +1588 -0
  47. package/gsd-core/bin/lib/state.cjs +306 -681
  48. package/gsd-core/bin/lib/surface.cjs +4 -1
  49. package/gsd-core/bin/lib/workstream.cjs +4 -4
  50. package/gsd-core/bin/shared/config-schema.manifest.json +9 -0
  51. package/gsd-core/references/honest-verifier.md +105 -0
  52. package/gsd-core/references/reviewer-instances.md +99 -0
  53. package/gsd-core/workflows/autonomous.md +9 -9
  54. package/gsd-core/workflows/manager.md +15 -15
  55. package/gsd-core/workflows/plan-phase.md +1 -1
  56. package/gsd-core/workflows/review.md +26 -0
  57. package/gsd-core/workflows/thread.md +4 -4
  58. package/gsd-core/workflows/verify-phase.md +11 -4
  59. package/hooks/dist/gsd-graphify-update.sh +7 -1
  60. package/hooks/gsd-graphify-update.sh +7 -1
  61. package/package.json +4 -4
  62. package/scripts/ci-test-scope.cjs +38 -9
  63. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  64. package/scripts/lint-regression-test-names.allowlist.json +3 -0
  65. package/scripts/lint-test-file-count.allowlist.json +19 -5
  66. package/scripts/mutation-matrix.cjs +45 -3
  67. package/scripts/prompt-injection-scan.sh +8 -0
  68. package/scripts/lint-windows-test-portability.cjs +0 -178
@@ -14,6 +14,9 @@ const node_fs_1 = __importDefault(require("node:fs"));
14
14
  const node_path_1 = __importDefault(require("node:path"));
15
15
  const node_os_1 = __importDefault(require("node:os"));
16
16
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
17
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
18
+ const conversionModule = require("./runtime-artifact-conversion.cjs");
19
+ const { applyAgentPathRewrites: _applyAgentPathRewrites, processAttribution: _processAttribution, normalizeAgentBodyForRuntime: _normalizeAgentBodyForRuntime, readGsdCommandNames: _readGsdCommandNames, } = conversionModule;
17
20
  // ---------------------------------------------------------------------------
18
21
  // Profile definitions
19
22
  // ---------------------------------------------------------------------------
@@ -521,19 +524,33 @@ function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converte
521
524
  * For tiered profiles, only agents whose full stem is in `resolvedProfile.agents`
522
525
  * are staged (mirrors `stageAgentsForProfile` behaviour).
523
526
  *
527
+ * ADR-1235 §1: when `agentCtx` is provided, the per-file order matches the inline
528
+ * agent loop in bin/install.js exactly:
529
+ * 1. applyAgentPathRewrites (4 base ~/.claude/ regexes; skipped for copilot/antigravity)
530
+ * 2. processAttribution (Co-Authored-By policy)
531
+ * 3. converter (runtime-specific frontmatter/body transform)
532
+ * 4. normalizeAgentBodyForRuntime (colon→hyphen refs; no-op for trivial group)
533
+ * When `agentCtx` is absent, only the converter is applied (backward-compat for
534
+ * the feat-1173 synthetic-descriptor tests and the copilot/antigravity paths
535
+ * that handle cross-cutting inside their converters).
536
+ *
524
537
  * @param srcAgentsDir source agents directory (e.g. agents/)
525
538
  * @param resolvedProfile profile filter from resolveProfile()
526
539
  * @param converter (content: string, isGlobal?: boolean) → string per-file
527
540
  * converter; scope-aware converters (copilot/antigravity)
528
541
  * read isGlobal, single-arg converters ignore it (#1173)
529
542
  * @param isGlobal install scope passed through to the converter
543
+ * @param agentCtx optional cross-cutting context (ADR-1235 §1); when absent,
544
+ * only the converter is applied (backward compat)
530
545
  */
531
- function stageAgentsForRuntimeWithConverter(srcAgentsDir, resolvedProfile, converter, isGlobal = false) {
546
+ function stageAgentsForRuntimeWithConverter(srcAgentsDir, resolvedProfile, converter, isGlobal = false, agentCtx) {
532
547
  if (!node_fs_1.default.existsSync(srcAgentsDir))
533
548
  return srcAgentsDir;
534
549
  const stageDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'gsd-profile-runtime-agents-'));
535
550
  try {
536
551
  const entries = node_fs_1.default.readdirSync(srcAgentsDir, { withFileTypes: true });
552
+ // Resolve cmdNames once per staging call (not per file) for performance.
553
+ const cmdNames = agentCtx ? _readGsdCommandNames() : [];
537
554
  for (const entry of entries) {
538
555
  if (!entry.isFile())
539
556
  continue;
@@ -546,9 +563,23 @@ function stageAgentsForRuntimeWithConverter(srcAgentsDir, resolvedProfile, conve
546
563
  continue;
547
564
  }
548
565
  }
549
- const content = node_fs_1.default.readFileSync(node_path_1.default.join(srcAgentsDir, entry.name), 'utf8');
550
- const converted = converter(content, isGlobal);
551
- node_fs_1.default.writeFileSync(node_path_1.default.join(stageDir, entry.name), converted, 'utf8');
566
+ let content = node_fs_1.default.readFileSync(node_path_1.default.join(srcAgentsDir, entry.name), 'utf8');
567
+ if (agentCtx) {
568
+ // ADR-1235 §1: pre-converter cross-cutting (matches inline loop order exactly)
569
+ // Step 1: path rewrites (4 base ~/.claude/ regexes; skipped for copilot/antigravity)
570
+ content = _applyAgentPathRewrites(content, agentCtx.runtime, agentCtx.pathPrefix);
571
+ // Step 2: attribution
572
+ content = _processAttribution(content, agentCtx.attribution);
573
+ // Step 3: converter (runtime-specific frontmatter/body transform)
574
+ content = converter(content, isGlobal);
575
+ // Step 4: normalize colon→hyphen refs (no-op for trivial group)
576
+ content = _normalizeAgentBodyForRuntime(content, agentCtx.runtime, cmdNames);
577
+ }
578
+ else {
579
+ // Backward-compat: only apply the converter (no cross-cutting)
580
+ content = converter(content, isGlobal);
581
+ }
582
+ node_fs_1.default.writeFileSync(node_path_1.default.join(stageDir, entry.name), content, 'utf8');
552
583
  }
553
584
  }
554
585
  catch (err) {
@@ -88,7 +88,7 @@ function atomicWriteInstallState(configDir, content) {
88
88
  const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
89
89
  try {
90
90
  node_fs_1.default.writeFileSync(tmpPath, content, 'utf8');
91
- node_fs_1.default.renameSync(tmpPath, filePath);
91
+ (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, filePath);
92
92
  }
93
93
  catch (error) {
94
94
  try {
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Companion MCP server (ADR-1239 Phase C-2, #1681 slice 3a).
3
+ *
4
+ * A minimal stdio JSON-RPC 2.0 server exposing two of the six interface points
5
+ * so any MCP-consuming host (Claude/Codex/OpenCode/VS Code/Gemini/Cursor/Cline/
6
+ * Hermes) can drive GSD with NO bespoke plugin:
7
+ *
8
+ * - point 1 (command): tool `gsd_invoke_command` → the command-routing hub
9
+ * (`createHub`/`dispatch`, src/command-routing-hub.cts).
10
+ * - point 5 (state IO): tools `gsd_read_state` / `gsd_write_state` → the
11
+ * Phase 3 `stateIO` seam (src/state-io.cts, filesystem default).
12
+ *
13
+ * No new runtime dependency — the JSON-RPC stdio loop is hand-rolled (the repo
14
+ * ships only claude-agent-sdk + ws; adding an MCP SDK is a separate packaging
15
+ * decision). The protocol logic (`handleMessage`) is PURE and fully testable;
16
+ * `runServer` is a thin line-delimited-JSON loop over injectable streams.
17
+ *
18
+ * Bin entry / packaging / manifest-version-sync is slice 3b — this module is
19
+ * the additive, importable server surface a host (or the bin shim) drives.
20
+ */
21
+ 'use strict';
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.SERVER_NAME = exports.PROTOCOL_VERSION = void 0;
24
+ exports.handleMessage = handleMessage;
25
+ exports.runServer = runServer;
26
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
27
+ const commandRoutingHub = require("./command-routing-hub.cjs");
28
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
29
+ const stateIo = require("./state-io.cjs");
30
+ exports.PROTOCOL_VERSION = '2024-11-05';
31
+ exports.SERVER_NAME = 'gsd-core';
32
+ const SERVER_VERSION = '1.7.0';
33
+ // JSON-RPC 2.0 error codes.
34
+ const PARSE_ERROR = -32700;
35
+ const INVALID_REQUEST = -32600;
36
+ const METHOD_NOT_FOUND = -32601;
37
+ const INVALID_PARAMS = -32602;
38
+ const INTERNAL_ERROR = -32603;
39
+ const TOOLS = [
40
+ {
41
+ name: 'gsd_invoke_command',
42
+ description: 'Invoke a GSD command via the command-routing hub (interface point 1).',
43
+ inputSchema: {
44
+ type: 'object',
45
+ properties: {
46
+ family: { type: 'string', description: 'Command family (e.g. "query", "state", "phase").' },
47
+ subcommand: { type: 'string', description: 'Subcommand name.' },
48
+ args: { type: 'array', items: {}, description: 'Positional args.' },
49
+ },
50
+ required: ['family', 'subcommand'],
51
+ },
52
+ },
53
+ {
54
+ name: 'gsd_read_state',
55
+ description: 'Read a .planning state file (interface point 5).',
56
+ inputSchema: {
57
+ type: 'object',
58
+ properties: { path: { type: 'string', description: 'Absolute path under .planning/.' } },
59
+ required: ['path'],
60
+ },
61
+ },
62
+ {
63
+ name: 'gsd_write_state',
64
+ description: 'Write a .planning state file (interface point 5).',
65
+ inputSchema: {
66
+ type: 'object',
67
+ properties: {
68
+ path: { type: 'string', description: 'Absolute path under .planning/.' },
69
+ content: { type: 'string', description: 'File content.' },
70
+ },
71
+ required: ['path', 'content'],
72
+ },
73
+ },
74
+ ];
75
+ function errorResponse(id, code, message, data) {
76
+ const err = { code, message };
77
+ if (data !== undefined)
78
+ err.data = data;
79
+ return { jsonrpc: '2.0', id, error: err };
80
+ }
81
+ function okResponse(id, result) {
82
+ return { jsonrpc: '2.0', id, result };
83
+ }
84
+ function asString(v) {
85
+ return typeof v === 'string' ? v : null;
86
+ }
87
+ function callTool(name, args, ctx) {
88
+ const a = (args && typeof args === 'object' ? args : {});
89
+ const cwd = asString(ctx.cwd) || process.cwd();
90
+ try {
91
+ if (name === 'gsd_invoke_command') {
92
+ const family = asString(a.family);
93
+ const subcommand = asString(a.subcommand);
94
+ if (!family || !subcommand) {
95
+ return { isError: true, content: [{ type: 'text', text: 'gsd_invoke_command requires string "family" and "subcommand".' }] };
96
+ }
97
+ const hub = commandRoutingHub.createHub();
98
+ const res = hub.dispatch({ family, subcommand, args: Array.isArray(a.args) ? a.args : [], cwd, raw: undefined });
99
+ return { content: [{ type: 'text', text: JSON.stringify(res) }] };
100
+ }
101
+ if (name === 'gsd_read_state') {
102
+ const p = asString(a.path);
103
+ if (!p)
104
+ return { isError: true, content: [{ type: 'text', text: 'gsd_read_state requires string "path".' }] };
105
+ const io = stateIo.createStateIO({ io: 'filesystem' });
106
+ return { content: [{ type: 'text', text: io.read(p) }] };
107
+ }
108
+ if (name === 'gsd_write_state') {
109
+ const p = asString(a.path);
110
+ const content = asString(a.content);
111
+ if (!p || content === null)
112
+ return { isError: true, content: [{ type: 'text', text: 'gsd_write_state requires string "path" and "content".' }] };
113
+ const io = stateIo.createStateIO({ io: 'filesystem' });
114
+ io.write(p, content);
115
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, path: p }) }] };
116
+ }
117
+ return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
118
+ }
119
+ catch (e) {
120
+ return { isError: true, content: [{ type: 'text', text: `Tool error: ${e instanceof Error ? e.message : String(e)}` }] };
121
+ }
122
+ }
123
+ /**
124
+ * Pure JSON-RPC handler. Takes a parsed request object + context, returns a
125
+ * JSON-RPC response object (or null for JSON-RPC notifications — no id).
126
+ */
127
+ function handleMessage(request, ctx = {}) {
128
+ if (!request || typeof request !== 'object') {
129
+ return errorResponse(null, INVALID_REQUEST, 'Invalid Request: not an object.');
130
+ }
131
+ const id = request.id;
132
+ // Notification (no id) → no response per JSON-RPC.
133
+ const isNotification = id === undefined || id === null;
134
+ const method = typeof request.method === 'string' ? request.method : '';
135
+ let result;
136
+ switch (method) {
137
+ case 'initialize':
138
+ result = {
139
+ protocolVersion: exports.PROTOCOL_VERSION,
140
+ capabilities: { tools: {} },
141
+ serverInfo: { name: exports.SERVER_NAME, version: SERVER_VERSION },
142
+ };
143
+ break;
144
+ case 'tools/list':
145
+ result = { tools: TOOLS };
146
+ break;
147
+ case 'tools/call': {
148
+ const params = (request.params && typeof request.params === 'object' ? request.params : {});
149
+ const toolName = asString(params.name);
150
+ if (!toolName)
151
+ return errorResponse(id, INVALID_PARAMS, 'tools/call requires string "name".');
152
+ result = callTool(toolName, params.arguments, ctx);
153
+ break;
154
+ }
155
+ default:
156
+ if (isNotification)
157
+ return null;
158
+ return errorResponse(id, METHOD_NOT_FOUND, `Method not found: ${method || '(empty)'}.`);
159
+ }
160
+ if (isNotification)
161
+ return null;
162
+ return okResponse(id, result);
163
+ }
164
+ /**
165
+ * Thin stdio loop over injectable streams. Reads line-delimited JSON-RPC from
166
+ * `input`, writes responses (one JSON object + newline) to `output`. Stops when
167
+ * input ends. Errors in handleMessage are caught and emitted as JSON-RPC error
168
+ * responses (the loop never crashes).
169
+ */
170
+ async function runServer({ input, output, ctx = {}, }) {
171
+ for await (const chunk of input) {
172
+ const lines = chunk.toString('utf-8').split(/\r?\n/);
173
+ for (const line of lines) {
174
+ if (!line.trim())
175
+ continue;
176
+ let parsed;
177
+ try {
178
+ parsed = JSON.parse(line);
179
+ }
180
+ catch {
181
+ output.write(JSON.stringify(errorResponse(null, PARSE_ERROR, 'Parse error.')) + '\n');
182
+ continue;
183
+ }
184
+ try {
185
+ const response = handleMessage(parsed, ctx);
186
+ if (response)
187
+ output.write(JSON.stringify(response) + '\n');
188
+ }
189
+ catch (e) {
190
+ output.write(JSON.stringify(errorResponse(null, INTERNAL_ERROR, e instanceof Error ? e.message : 'Internal error.')) + '\n');
191
+ }
192
+ }
193
+ }
194
+ }
@@ -19,6 +19,8 @@ const frontmatterMod = require("./frontmatter.cjs");
19
19
  const stateMod = require("./state.cjs");
20
20
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
21
21
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
22
+ const clock_cjs_1 = require("./clock.cjs");
23
+ const state_transition_cjs_1 = require("./state-transition.cjs");
22
24
  // eslint-disable-next-line @typescript-eslint/no-require-imports
23
25
  const ioMod = require("./io.cjs");
24
26
  const { output, error } = ioMod;
@@ -33,7 +35,7 @@ const coreUtilsMod = require("./core-utils.cjs");
33
35
  const { extractOneLinerFromBody } = coreUtilsMod;
34
36
  const { planningPaths } = planningWorkspace;
35
37
  const { extractFrontmatter } = frontmatterMod;
36
- const { writeStateMd, stateReplaceFieldWithFallback } = stateMod;
38
+ const { writeStateMd } = stateMod;
37
39
  function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
38
40
  if (!reqIdsRaw || reqIdsRaw.length === 0) {
39
41
  error('requirement IDs required. Usage: requirements mark-complete REQ-01,REQ-02 or REQ-01 REQ-02');
@@ -165,6 +167,14 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
165
167
  })();
166
168
  while ((pm = phasePattern.exec(scopedContent)) !== null) {
167
169
  const phaseNum = pm[1];
170
+ // Phase 0 (pre-milestone) and Phase 999 (backlog) are sentinels, not
171
+ // real phases — they legitimately have no directory and must not block
172
+ // milestone completion. Mirrors the engine-wide sentinel convention
173
+ // (phase-id getMilestoneFromPhaseId, roadmap-command-router SENTINELS,
174
+ // the #1445 /^999/ progress filters). (#1580)
175
+ const major = parseInt(phaseNum, 10);
176
+ if (major === 0 || major === 999)
177
+ continue;
168
178
  const normalized = normalizePhaseName(phaseNum);
169
179
  // A phase has disk_status: 'no_directory' when no phase directory
170
180
  // with a matching token exists on disk. Use the same phaseTokenMatches
@@ -253,7 +263,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
253
263
  // Archive audit file if exists
254
264
  const auditFile = node_path_1.default.join(cwd, '.planning', `${version}-MILESTONE-AUDIT.md`);
255
265
  if (node_fs_1.default.existsSync(auditFile)) {
256
- node_fs_1.default.renameSync(auditFile, node_path_1.default.join(archiveDir, `${version}-MILESTONE-AUDIT.md`));
266
+ (0, shell_command_projection_cjs_1.retryRenameSync)(auditFile, node_path_1.default.join(archiveDir, `${version}-MILESTONE-AUDIT.md`));
257
267
  }
258
268
  // Create/append MILESTONES.md entry
259
269
  const accomplishmentsList = accomplishments.map((a) => `- ${a}`).join('\n');
@@ -281,34 +291,21 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
281
291
  else {
282
292
  (0, shell_command_projection_cjs_1.platformWriteSync)(milestonesPath, `# Milestones\n\n${milestoneEntry}`);
283
293
  }
284
- // Update STATE.md — keep frontmatter/body semantically aligned after closure
294
+ // Update STATE.md — keep frontmatter/body semantically aligned after closure.
295
+ // ADR-1769 Phase 5: dispatches to the STATE.md Transition Module. The closure
296
+ // write (Status, Last Activity, Last Activity Description, Current Position
297
+ // reset, Operator Next Steps reset) is the pure `milestoneCompleteCore` in
298
+ // src/state-transition.cts, backed by the field-classification table. The
299
+ // runtime-specific next-milestone slash command is resolved here and injected
300
+ // via the intent so the core stays pure. writeStateMd still owns the lock and
301
+ // the steady-state syncStateFrontmatter post-sync.
285
302
  if (node_fs_1.default.existsSync(statePath)) {
286
- let stateContent = node_fs_1.default.readFileSync(statePath, 'utf-8');
287
- stateContent = stateReplaceFieldWithFallback(stateContent, 'Status', null, `${version} milestone complete`);
288
- stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity', 'Last activity', today);
289
- stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity Description', null, `${version} milestone completed and archived`);
290
- // Reset Current Position narrative so resume/progress flows do not keep
291
- // pointing at closed-phase execution instructions.
292
- const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; // allow-adhoc-markdown: pre-seam section write-modify in milestone.cts; pending collectSection migration #1372
293
- const closedPositionBody = `\nPhase: Milestone ${version} complete\n` +
294
- `Plan: —\n` +
295
- `Status: Awaiting next milestone\n` +
296
- `Last activity: ${today} — Milestone ${version} completed and archived\n\n`;
297
- if (positionPattern.test(stateContent)) {
298
- stateContent = stateContent.replace(positionPattern, (_m, header) => `${header}${closedPositionBody}`);
299
- }
300
- else {
301
- stateContent = `${stateContent.trimEnd()}\n\n## Current Position\n${closedPositionBody}`;
302
- }
303
- // Normalize operator-next-step tails that can become stale after close.
304
- const operatorPattern = /(##\s*Operator Next Steps\s*\n)([\s\S]*?)(?=\n##|$)/i; // allow-adhoc-markdown: pre-seam section write-modify in milestone.cts; pending collectSection migration #1372
305
- if (operatorPattern.test(stateContent)) {
306
- stateContent = stateContent.replace(operatorPattern, `$1\n- Start the next milestone with ${(0, runtime_slash_cjs_1.formatGsdSlash)('new-milestone', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))}\n\n`);
307
- }
308
- else {
309
- stateContent = `${stateContent.trimEnd()}\n\n## Operator Next Steps\n\n- Start the next milestone with ${(0, runtime_slash_cjs_1.formatGsdSlash)('new-milestone', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))}\n`;
310
- }
311
- writeStateMd(statePath, stateContent, cwd);
303
+ const result = (0, state_transition_cjs_1.transitionCore)(node_fs_1.default.readFileSync(statePath, 'utf-8'), {
304
+ kind: 'milestoneComplete',
305
+ version,
306
+ nextMilestoneCommand: (0, runtime_slash_cjs_1.formatGsdSlash)('new-milestone', (0, runtime_slash_cjs_1.resolveRuntime)(cwd)),
307
+ }, { clock: clock_cjs_1.realClock, progressProvider: () => null });
308
+ writeStateMd(statePath, result.content, cwd);
312
309
  }
313
310
  // Archive phase directories if requested
314
311
  let phasesArchived = false;
@@ -322,7 +319,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
322
319
  for (const dir of phaseDirNames) {
323
320
  if (!isDirInMilestone(dir))
324
321
  continue;
325
- node_fs_1.default.renameSync(node_path_1.default.join(phasesDir, dir), node_path_1.default.join(phaseArchiveDir, dir));
322
+ (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, dir), node_path_1.default.join(phaseArchiveDir, dir));
326
323
  archivedCount++;
327
324
  }
328
325
  phasesArchived = archivedCount > 0;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Model adapter seam (ADR-1239 Phase C-1, AC3 / #1680).
3
+ *
4
+ * Two model-layer adapters selected by the negotiated `modelMode` axis
5
+ * (host-integration.cts):
6
+ *
7
+ * - `passive` — GSD can only inject prompts / a per-agent `model` field (the
8
+ * CLI runtimes: claude/gemini/codex/opencode/cursor/…). Formalizes today's
9
+ * tier routing from src/model-resolver.cts: `resolveModel` delegates
10
+ * straight to `resolveModelForTier`, so passive reproduces current behavior
11
+ * byte-for-behavior.
12
+ * - `active` — the host exposes a provider `sendRequest` (VS Code `vscode.lm`,
13
+ * pi providers). GSD calls the model through the host. Ships here as a SEAM:
14
+ * a host-supplied `sendRequest` slot, fail-closed until a real consumer
15
+ * binds it (Phase 5 / #1682).
16
+ *
17
+ * Minimal (per ADR-1239 open wire-shape question): one factory, two shapes
18
+ * discriminated by `mode`. Concrete provider protocol (request/response shape)
19
+ * is fixed when a real active host lands in Phase 5.
20
+ */
21
+ 'use strict';
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.createModelAdapter = createModelAdapter;
24
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
25
+ const modelResolver = require("./model-resolver.cjs");
26
+ function createModelAdapter({ modelMode }, options = {}) {
27
+ if (modelMode !== 'passive' && modelMode !== 'active') {
28
+ throw new TypeError(`createModelAdapter: modelMode must be 'passive' | 'active' (got ${JSON.stringify(modelMode)})`);
29
+ }
30
+ if (modelMode === 'passive') {
31
+ return Object.freeze({
32
+ mode: 'passive',
33
+ resolveModel({ cwd, agentType, attempt }) {
34
+ return modelResolver.resolveModelForTier(cwd, agentType, attempt);
35
+ },
36
+ });
37
+ }
38
+ // active: bind the host's sendRequest, fail-closed if absent.
39
+ const sendRequest = options.sendRequest;
40
+ return Object.freeze({
41
+ mode: 'active',
42
+ sendRequest(req) {
43
+ if (typeof sendRequest !== 'function') {
44
+ throw new Error('ActiveModelAdapter.sendRequest: no host provider bound — the active model seam ' +
45
+ 'requires a sendRequest primitive from the host (Phase 5 wires a concrete provider).');
46
+ }
47
+ return sendRequest(req);
48
+ },
49
+ });
50
+ }
@@ -47,8 +47,8 @@ const frontmatterMod = require("./frontmatter.cjs");
47
47
  const stateMod = require("./state.cjs");
48
48
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
49
49
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
50
- const phase_lifecycle_cjs_1 = require("./phase-lifecycle.cjs");
51
50
  const clock_cjs_1 = require("./clock.cjs");
51
+ const state_transition_cjs_1 = require("./state-transition.cjs");
52
52
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- uat-predicate.cjs is an export= CommonJS module
53
53
  const uatPredicate = require("./uat-predicate.cjs");
54
54
  const { evaluateUatPassed } = uatPredicate;
@@ -57,7 +57,7 @@ const verificationMod = require("./verification.cjs");
57
57
  const { readVerificationStatus } = verificationMod;
58
58
  const { planningDir, withPlanningLock } = planningWorkspace;
59
59
  const { extractFrontmatter } = frontmatterMod;
60
- const { readModifyWriteStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, syncStateFrontmatter, withStateLock, updatePerformanceMetricsSection, } = stateMod;
60
+ const { readModifyWriteStateMd, stateExtractField, stateReplaceField, syncStateFrontmatter, withStateLock, updatePerformanceMetricsSection, } = stateMod;
61
61
  // #2893 — strict canonical filter: `{padded_phase}-{NN}-PLAN.md` or `PLAN.md`.
62
62
  const isCanonicalPlanFile = (f) => f.endsWith('-PLAN.md') || f === 'PLAN.md';
63
63
  // Any .md file with PLAN anywhere in the basename — diagnostic net
@@ -860,12 +860,12 @@ function renameDecimalPhases(phasesDir, baseInt, removedDecimal) {
860
860
  const oldPhaseId = `${baseInt}.${item.oldDecimal}`;
861
861
  const newPhaseId = `${baseInt}.${newDecimal}`;
862
862
  const newDirName = `${item.prefix}.${newDecimal}-${item.slug}`;
863
- node_fs_1.default.renameSync(node_path_1.default.join(phasesDir, item.dir), node_path_1.default.join(phasesDir, newDirName));
863
+ (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, item.dir), node_path_1.default.join(phasesDir, newDirName));
864
864
  renamedDirs.push({ from: item.dir, to: newDirName });
865
865
  for (const f of node_fs_1.default.readdirSync(node_path_1.default.join(phasesDir, newDirName))) {
866
866
  if (f.includes(oldPhaseId)) {
867
867
  const newFileName = f.replace(oldPhaseId, newPhaseId);
868
- node_fs_1.default.renameSync(node_path_1.default.join(phasesDir, newDirName, f), node_path_1.default.join(phasesDir, newDirName, newFileName));
868
+ (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, newDirName, f), node_path_1.default.join(phasesDir, newDirName, newFileName));
869
869
  renamedFiles.push({ from: f, to: newFileName });
870
870
  }
871
871
  }
@@ -903,12 +903,12 @@ function renameIntegerPhases(phasesDir, removedInt) {
903
903
  const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`;
904
904
  const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`;
905
905
  const newDirName = `${newPrefix}-${item.slug}`;
906
- node_fs_1.default.renameSync(node_path_1.default.join(phasesDir, item.dir), node_path_1.default.join(phasesDir, newDirName));
906
+ (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, item.dir), node_path_1.default.join(phasesDir, newDirName));
907
907
  renamedDirs.push({ from: item.dir, to: newDirName });
908
908
  for (const f of node_fs_1.default.readdirSync(node_path_1.default.join(phasesDir, newDirName))) {
909
909
  if (f.startsWith(oldPrefix)) {
910
910
  const newFileName = newPrefix + f.slice(oldPrefix.length);
911
- node_fs_1.default.renameSync(node_path_1.default.join(phasesDir, newDirName, f), node_path_1.default.join(phasesDir, newDirName, newFileName));
911
+ (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, newDirName, f), node_path_1.default.join(phasesDir, newDirName, newFileName));
912
912
  renamedFiles.push({ from: f, to: newFileName });
913
913
  }
914
914
  }
@@ -1287,7 +1287,16 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1287
1287
  if (isLastPhase && roadmapContent !== null) {
1288
1288
  try {
1289
1289
  const roadmapForPhases = extractCurrentMilestone(roadmapContent, cwd);
1290
- const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
1290
+ // #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;
1291
1300
  let pm;
1292
1301
  while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
1293
1302
  if (comparePhaseNum(pm[1], phaseNum) > 0) {
@@ -1309,73 +1318,33 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1309
1318
  if (node_fs_1.default.existsSync(statePath)) {
1310
1319
  const originalStateContent = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
1311
1320
  let stateContent = originalStateContent;
1312
- const phaseValue = nextPhaseNum || phaseNum;
1321
+ // ADR-1769 Phase 3: the STATE.md field-update policy (Current Phase
1322
+ // shape/name, Status, Current Plan, Last Activity + Description, and
1323
+ // the Completed/Total Phases + Progress percent block) now dispatches
1324
+ // to the STATE.md Transition Module. The ~90-line inline RMW callback
1325
+ // that lived here is the pure `completePhaseCore` in
1326
+ // src/state-transition.cts, backed by the field-classification table.
1327
+ // `updatePerformanceMetricsSection` + `syncStateFrontmatter` stay in
1328
+ // this adapter: they are section-table / disk-scan concerns, not
1329
+ // classified fields, and `syncStateFrontmatter` is the post-sync this
1330
+ // transaction needs (it does NOT go through readModifyWriteStateMd
1331
+ // because STATE.md is committed atomically with ROADMAP/REQUIREMENTS).
1313
1332
  const nextPhaseDisplayName = phaseDisplayNameFromRoadmap(roadmapContent, nextPhaseNum) ??
1314
1333
  phaseDisplayNameFromSlug(nextPhaseName);
1315
- const existingPhaseField = stateExtractField(stateContent, 'Current Phase') ||
1316
- stateExtractField(stateContent, 'Phase');
1317
- let newPhaseValue = String(phaseValue);
1318
- if (existingPhaseField) {
1319
- const totalMatch = existingPhaseField.match(/of\s+(\d+)/);
1320
- const nameMatch = existingPhaseField.match(/\(([^)]+)\)/);
1321
- if (totalMatch) {
1322
- const total = totalMatch[1];
1323
- const nameStr = nextPhaseDisplayName
1324
- ? ` (${nextPhaseDisplayName})`
1325
- : nameMatch
1326
- ? ` (${nameMatch[1]})`
1327
- : '';
1328
- newPhaseValue = `${phaseValue} of ${total}${nameStr}`;
1329
- }
1330
- else if (nextPhaseDisplayName) {
1331
- newPhaseValue = `${phaseValue} — ${nextPhaseDisplayName}`;
1332
- }
1333
- }
1334
- stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Phase', 'Phase', newPhaseValue);
1335
- if (nextPhaseDisplayName) {
1336
- stateContent =
1337
- stateReplaceField(stateContent, 'Current Phase Name', nextPhaseDisplayName) ||
1338
- stateContent;
1339
- }
1340
- stateContent = stateReplaceFieldWithFallback(stateContent, 'Status', null, isLastPhase ? 'Milestone complete' : 'Ready to plan');
1341
- stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Plan', 'Plan', 'Not started');
1342
- const lastActivityDescription = `Phase ${phaseNum} complete${nextPhaseNum ? `, transitioned to Phase ${nextPhaseNum}` : ''}`;
1343
- if (/^Last activity:/m.test(stateContent)) {
1344
- stateContent =
1345
- stateReplaceField(stateContent, 'Last activity', `${today} — ${lastActivityDescription}`) ||
1346
- stateContent;
1347
- }
1348
- else {
1349
- stateContent =
1350
- stateReplaceField(stateContent, 'Last Activity', today) ||
1351
- stateContent;
1352
- }
1353
- stateContent =
1354
- stateReplaceField(stateContent, 'Last Activity Description', lastActivityDescription) ||
1355
- stateContent;
1356
- const completedRaw = stateExtractField(stateContent, 'Completed Phases');
1357
- if (completedRaw !== null) {
1358
- let newCompleted = parseInt(completedRaw, 10);
1359
- let derivedTotalPhases = null;
1360
- if (roadmapContent !== null) {
1361
- const derived = (0, phase_lifecycle_cjs_1.deriveProgressFromRoadmap)(roadmapContent);
1362
- if (derived.completedPhases !== null)
1363
- newCompleted = derived.completedPhases;
1364
- if (derived.totalPhases !== null)
1365
- derivedTotalPhases = derived.totalPhases;
1366
- }
1367
- stateContent =
1368
- stateReplaceField(stateContent, 'Completed Phases', String(newCompleted)) ||
1369
- stateContent;
1370
- const totalRaw = stateExtractField(stateContent, 'Total Phases');
1371
- const totalPhases = derivedTotalPhases || (totalRaw ? parseInt(totalRaw, 10) : null);
1372
- if (totalPhases && totalPhases > 0) {
1373
- const newPercent = (0, phase_lifecycle_cjs_1.clampPercent)(newCompleted, totalPhases);
1374
- stateContent =
1375
- stateReplaceField(stateContent, 'Progress', `${newPercent}%`) || stateContent;
1376
- stateContent = stateContent.replace(/(percent:\s*)\d+/, `$1${newPercent}`);
1377
- }
1378
- }
1334
+ const completeResult = (0, state_transition_cjs_1.transitionCore)(stateContent, {
1335
+ kind: 'completePhase',
1336
+ phaseNum,
1337
+ nextPhaseNum,
1338
+ nextPhaseName: nextPhaseDisplayName,
1339
+ isLastPhase,
1340
+ planCount,
1341
+ summaryCount,
1342
+ }, {
1343
+ clock: clock_cjs_1.realClock,
1344
+ progressProvider: () => null, // completePhase derives progress from the roadmap, not disk
1345
+ roadmapProvider: () => roadmapContent,
1346
+ });
1347
+ stateContent = completeResult.content;
1379
1348
  stateContent = updatePerformanceMetricsSection(stateContent, cwd, phaseNum, planCount, summaryCount);
1380
1349
  stateContent = syncStateFrontmatter(stateContent, cwd);
1381
1350
  writes.push({ filePath: statePath, before: originalStateContent, after: stateContent });
@@ -260,7 +260,7 @@ function withPlanningLock(cwd, fn, clock) {
260
260
  const stolen = lockPath + '.stale-' + process.pid + '-' + clock.now() + '-' + (_planningStealSeq++);
261
261
  let renamed = false;
262
262
  try {
263
- node_fs_1.default.renameSync(lockPath, stolen);
263
+ (0, shell_command_projection_cjs_1.retryRenameSync)(lockPath, stolen);
264
264
  renamed = true;
265
265
  }
266
266
  catch { /* another racer won */ }