gsd-pi 2.82.0-dev.725028083 → 2.82.0-dev.ed17d078d

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 (62) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/gsd/auto/orchestrator.js +113 -6
  3. package/dist/resources/extensions/gsd/auto.js +121 -30
  4. package/dist/resources/extensions/gsd/md-importer.js +1 -1
  5. package/dist/resources/extensions/gsd/migrate/command.js +5 -0
  6. package/dist/resources/extensions/gsd/migrate/preview.js +9 -0
  7. package/dist/resources/extensions/gsd/migrate/transformer.js +51 -4
  8. package/dist/resources/extensions/gsd/migrate/writer.js +11 -1
  9. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  10. package/dist/web/standalone/.next/BUILD_ID +1 -1
  11. package/dist/web/standalone/.next/app-path-routes-manifest.json +17 -17
  12. package/dist/web/standalone/.next/build-manifest.json +2 -2
  13. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  14. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  15. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  16. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  17. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  18. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  23. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/index.html +1 -1
  31. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app-paths-manifest.json +17 -17
  38. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  39. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  40. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  41. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  42. package/package.json +1 -1
  43. package/packages/pi-tui/dist/tui.d.ts.map +1 -1
  44. package/packages/pi-tui/dist/tui.js +5 -0
  45. package/packages/pi-tui/dist/tui.js.map +1 -1
  46. package/packages/pi-tui/src/tui.ts +6 -0
  47. package/packages/pi-tui/tsconfig.tsbuildinfo +1 -1
  48. package/src/resources/extensions/gsd/auto/contracts.ts +46 -11
  49. package/src/resources/extensions/gsd/auto/orchestrator.ts +118 -6
  50. package/src/resources/extensions/gsd/auto.ts +129 -31
  51. package/src/resources/extensions/gsd/md-importer.ts +1 -1
  52. package/src/resources/extensions/gsd/migrate/command.ts +5 -0
  53. package/src/resources/extensions/gsd/migrate/preview.ts +10 -0
  54. package/src/resources/extensions/gsd/migrate/transformer.ts +58 -4
  55. package/src/resources/extensions/gsd/migrate/writer.ts +14 -1
  56. package/src/resources/extensions/gsd/tests/auto-orchestrator.test.ts +408 -4
  57. package/src/resources/extensions/gsd/tests/auto-runtime-state.test.ts +4 -4
  58. package/src/resources/extensions/gsd/tests/integration/migrate-command.test.ts +48 -3
  59. package/src/resources/extensions/gsd/tests/migrate-transformer.test.ts +5 -1
  60. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +6 -1
  61. /package/dist/web/standalone/.next/static/{KDRTXR-22LPCsa80X9dey → YEvjuT-fsFfYQhDSWtueS}/_buildManifest.js +0 -0
  62. /package/dist/web/standalone/.next/static/{KDRTXR-22LPCsa80X9dey → YEvjuT-fsFfYQhDSWtueS}/_ssgManifest.js +0 -0
@@ -1 +1 @@
1
- 5c6d4acc2e1d8c2b
1
+ 497e8db0cb78af90
@@ -3,6 +3,15 @@
3
3
  function now() {
4
4
  return Date.now();
5
5
  }
6
+ /**
7
+ * Size of the dispatch-decision ring buffer used by the Auto Orchestration
8
+ * module's stuck-loop detector. When the same `${unitType}:${unitId}` key
9
+ * fills the window, advance() blocks with `action: "stop"`.
10
+ *
11
+ * Mirrors the legacy `STUCK_WINDOW_SIZE` in auto/phases.ts so behaviour is
12
+ * preserved across the eventual cutover (issue #5791).
13
+ */
14
+ export const STUCK_WINDOW_SIZE = 6;
6
15
  export class AutoOrchestrator {
7
16
  status = {
8
17
  phase: "idle",
@@ -10,11 +19,13 @@ export class AutoOrchestrator {
10
19
  };
11
20
  deps;
12
21
  lastAdvanceKey = null;
22
+ dispatchKeyWindow = [];
13
23
  constructor(deps) {
14
24
  this.deps = deps;
15
25
  }
16
26
  async start(_sessionContext) {
17
27
  this.lastAdvanceKey = null;
28
+ this.dispatchKeyWindow = [];
18
29
  this.status.phase = "running";
19
30
  this.bumpTransition();
20
31
  await this.deps.runtime.journalTransition({ name: "start" });
@@ -24,18 +35,70 @@ export class AutoOrchestrator {
24
35
  async advance() {
25
36
  try {
26
37
  await this.deps.runtime.ensureLockOwnership();
38
+ const staleMsg = this.deps.health.checkResourcesStale();
39
+ if (staleMsg) {
40
+ await this.deps.uokGate.emit({
41
+ gateId: "resource-version-guard",
42
+ gateType: "policy",
43
+ outcome: "fail",
44
+ failureClass: "policy",
45
+ rationale: "resource version guard blocked dispatch",
46
+ findings: staleMsg,
47
+ });
48
+ const blocked = { kind: "blocked", reason: staleMsg, action: "stop" };
49
+ await this.deps.runtime.journalTransition({ name: "advance-blocked", reason: blocked.reason });
50
+ await this.deps.health.postAdvanceRecord(blocked);
51
+ return blocked;
52
+ }
53
+ await this.deps.uokGate.emit({
54
+ gateId: "resource-version-guard",
55
+ gateType: "policy",
56
+ outcome: "pass",
57
+ failureClass: "none",
58
+ rationale: "resource version guard passed",
59
+ });
27
60
  const gate = await this.deps.health.preAdvanceGate();
28
- if (!gate.allow) {
29
- const blocked = { kind: "blocked", reason: gate.reason ?? "health gate blocked" };
61
+ if (gate.kind === "fail") {
62
+ await this.deps.uokGate.emit({
63
+ gateId: "pre-dispatch-health-gate",
64
+ gateType: "execution",
65
+ outcome: "manual-attention",
66
+ failureClass: "manual-attention",
67
+ rationale: "pre-dispatch health gate blocked dispatch",
68
+ findings: gate.reason,
69
+ });
70
+ const blocked = { kind: "blocked", reason: gate.reason, action: "pause" };
30
71
  await this.deps.runtime.journalTransition({ name: "advance-blocked", reason: blocked.reason });
31
72
  await this.deps.health.postAdvanceRecord(blocked);
32
73
  return blocked;
33
74
  }
75
+ if (gate.kind === "threw") {
76
+ await this.deps.uokGate.emit({
77
+ gateId: "pre-dispatch-health-gate",
78
+ gateType: "execution",
79
+ outcome: "manual-attention",
80
+ failureClass: "manual-attention",
81
+ rationale: "pre-dispatch health gate threw unexpectedly",
82
+ findings: String(gate.error),
83
+ });
84
+ // intentional fall-through: matches runPreDispatch behaviour
85
+ }
86
+ else {
87
+ await this.deps.uokGate.emit({
88
+ gateId: "pre-dispatch-health-gate",
89
+ gateType: "execution",
90
+ outcome: "pass",
91
+ failureClass: "none",
92
+ rationale: "pre-dispatch health gate passed",
93
+ findings: gate.fixesApplied?.join(", ") ?? "",
94
+ });
95
+ }
34
96
  const reconciliation = await this.deps.stateReconciliation.reconcileBeforeDispatch();
35
97
  if (!reconciliation.ok || !reconciliation.stateSnapshot) {
36
98
  const blocked = {
37
99
  kind: "blocked",
38
- reason: reconciliation.reason,
100
+ reason: reconciliation.reason ?? "state reconciliation produced no snapshot",
101
+ action: "pause",
39
102
  stateSnapshot: reconciliation.stateSnapshot,
40
103
  };
41
104
  await this.deps.runtime.journalTransition({ name: "advance-blocked", reason: blocked.reason });
@@ -48,14 +111,49 @@ export class AutoOrchestrator {
48
111
  this.status.phase = "stopped";
49
112
  this.status.activeUnit = undefined;
50
113
  this.lastAdvanceKey = null;
114
+ this.dispatchKeyWindow = [];
51
115
  this.bumpTransition();
52
116
  await this.deps.runtime.journalTransition({ name: "advance-stopped", reason: stopped.reason });
53
117
  await this.deps.health.postAdvanceRecord(stopped);
54
118
  return stopped;
55
119
  }
56
120
  const nextKey = `${decision.unitType}:${decision.unitId}`;
57
- if (this.lastAdvanceKey === nextKey) {
58
- const blocked = { kind: "blocked", reason: "idempotent advance: unit already active" };
121
+ // Record every dispatch decision in the ring buffer before pre-flight
122
+ // checks so the stuck-loop detector observes the full decision history
123
+ // (including decisions that idempotency would otherwise short-circuit).
124
+ // The ring is capped at STUCK_WINDOW_SIZE and evicts oldest-first.
125
+ this.dispatchKeyWindow.push(nextKey);
126
+ if (this.dispatchKeyWindow.length > STUCK_WINDOW_SIZE) {
127
+ this.dispatchKeyWindow.shift();
128
+ }
129
+ // Idempotency: same key as immediately previous successful advance.
130
+ // This is the soft, fast-path block kept from #5786. It only fires when
131
+ // the ring is NOT yet saturated for this key — once the ring is full of
132
+ // `nextKey`, the stuck-loop verdict takes precedence (see below). Both
133
+ // checks coexist: idempotency for the common immediate-repeat case,
134
+ // stuck-loop for the saturated-window case.
135
+ const matchingCount = this.dispatchKeyWindow.filter((k) => k === nextKey).length;
136
+ if (this.lastAdvanceKey === nextKey && matchingCount < STUCK_WINDOW_SIZE) {
137
+ const blocked = { kind: "blocked", reason: "idempotent advance: unit already active", action: "stop" };
138
+ await this.deps.runtime.journalTransition({
139
+ name: "advance-blocked",
140
+ reason: blocked.reason,
141
+ unitType: decision.unitType,
142
+ unitId: decision.unitId,
143
+ });
144
+ await this.deps.health.postAdvanceRecord(blocked);
145
+ return blocked;
146
+ }
147
+ // Stuck-loop detection: when the ring is saturated with copies of
148
+ // `nextKey` (count >= STUCK_WINDOW_SIZE), the orchestrator has been
149
+ // picking the same unit across the whole window and must hard-stop with
150
+ // a diagnosable reason.
151
+ if (matchingCount >= STUCK_WINDOW_SIZE) {
152
+ const blocked = {
153
+ kind: "blocked",
154
+ reason: `stuck-loop: ${nextKey} picked ${matchingCount} times`,
155
+ action: "stop",
156
+ };
59
157
  await this.deps.runtime.journalTransition({
60
158
  name: "advance-blocked",
61
159
  reason: blocked.reason,
@@ -70,6 +168,7 @@ export class AutoOrchestrator {
70
168
  const blocked = {
71
169
  kind: "blocked",
72
170
  reason: contract.reason,
171
+ action: "pause",
73
172
  stateSnapshot: reconciliation.stateSnapshot,
74
173
  };
75
174
  await this.deps.runtime.journalTransition({
@@ -86,6 +185,7 @@ export class AutoOrchestrator {
86
185
  const blocked = {
87
186
  kind: "blocked",
88
187
  reason: worktree.reason,
188
+ action: "pause",
89
189
  stateSnapshot: reconciliation.stateSnapshot,
90
190
  };
91
191
  await this.deps.runtime.journalTransition({
@@ -108,7 +208,11 @@ export class AutoOrchestrator {
108
208
  unitId: decision.unitId,
109
209
  });
110
210
  await this.deps.worktree.syncAfterUnit(decision.unitType, decision.unitId);
111
- const advanced = { kind: "advanced", stateSnapshot: reconciliation.stateSnapshot };
211
+ const advanced = {
212
+ kind: "advanced",
213
+ unit: { unitType: decision.unitType, unitId: decision.unitId },
214
+ stateSnapshot: reconciliation.stateSnapshot,
215
+ };
112
216
  await this.deps.health.postAdvanceRecord(advanced);
113
217
  return advanced;
114
218
  }
@@ -134,6 +238,7 @@ export class AutoOrchestrator {
134
238
  }
135
239
  if (result.kind === "stopped") {
136
240
  this.lastAdvanceKey = null;
241
+ this.dispatchKeyWindow = [];
137
242
  this.status.activeUnit = undefined;
138
243
  }
139
244
  this.bumpTransition();
@@ -158,6 +263,7 @@ export class AutoOrchestrator {
158
263
  }
159
264
  async resume() {
160
265
  this.lastAdvanceKey = null;
266
+ this.dispatchKeyWindow = [];
161
267
  this.status.phase = "running";
162
268
  this.bumpTransition();
163
269
  await this.deps.runtime.journalTransition({ name: "resume" });
@@ -172,6 +278,7 @@ export class AutoOrchestrator {
172
278
  this.status.phase = "stopped";
173
279
  this.status.activeUnit = undefined;
174
280
  this.lastAdvanceKey = null;
281
+ this.dispatchKeyWindow = [];
175
282
  this.bumpTransition();
176
283
  await this.deps.runtime.journalTransition({ name: "stop", reason });
177
284
  await this.deps.notifications.notifyLifecycle({ name: "stop", detail: reason });
@@ -92,6 +92,7 @@ import { compileUnitToolContract } from "./tool-contract.js";
92
92
  import { createWorktreeSafetyModule } from "./worktree-safety.js";
93
93
  import { resolveManifest } from "./unit-context-manifest.js";
94
94
  import { classifyFailure } from "./recovery-classification.js";
95
+ import { supportsStructuredQuestions } from "./workflow-mcp.js";
95
96
  import { WorktreeLifecycle, } from "./worktree-lifecycle.js";
96
97
  import { WorktreeStateProjection } from "./worktree-state-projection.js";
97
98
  import { reorderForCaching } from "./prompt-ordering.js";
@@ -1385,6 +1386,66 @@ export function buildWorktreeLifecycleDeps() {
1385
1386
  function buildLifecycle() {
1386
1387
  return new WorktreeLifecycle(s, buildWorktreeLifecycleDeps());
1387
1388
  }
1389
+ /**
1390
+ * Build the production `DispatchAdapter` used by `createWiredAutoOrchestrationModule`.
1391
+ *
1392
+ * Exported so tests can verify parity with `runDispatch`'s `resolveDispatch` call —
1393
+ * the wired adapter must derive `structuredQuestionsAvailable`, `sessionContextWindow`,
1394
+ * `sessionProvider`, and `modelRegistry` the same way phases.ts:runDispatch does.
1395
+ */
1396
+ export function createWiredDispatchAdapter(ctx, pi, dispatchBasePath) {
1397
+ return {
1398
+ async decideNextUnit(input) {
1399
+ const state = input.stateSnapshot;
1400
+ const active = state.activeMilestone;
1401
+ if (!active)
1402
+ return null;
1403
+ const prefs = loadEffectiveGSDPreferences(dispatchBasePath)?.preferences;
1404
+ // Derive session-derived dispatch inputs the same way phases.ts:runDispatch does
1405
+ // (#5789). Prefer caller-supplied values when present so test harnesses and
1406
+ // alternative wirings can inject deterministic snapshots; otherwise pull from
1407
+ // the captured pi/ctx references.
1408
+ const sessionProvider = input.sessionProvider ?? ctx.model?.provider;
1409
+ const sessionContextWindow = input.sessionContextWindow ?? ctx.model?.contextWindow;
1410
+ const modelRegistry = input.modelRegistry ?? ctx.modelRegistry;
1411
+ const authMode = sessionProvider && typeof ctx.modelRegistry?.getProviderAuthMode === "function"
1412
+ ? ctx.modelRegistry.getProviderAuthMode(sessionProvider)
1413
+ : undefined;
1414
+ const activeTools = typeof pi.getActiveTools === "function" ? pi.getActiveTools() : [];
1415
+ // Mirrors runDispatch: deep-planning keeps approval gates in plain chat
1416
+ // because structured questions can be cancelled outside the chat turn on
1417
+ // some transports.
1418
+ const structuredQuestionsAvailable = input.structuredQuestionsAvailable ??
1419
+ (prefs?.planning_depth === "deep"
1420
+ ? "false"
1421
+ : supportsStructuredQuestions(activeTools, {
1422
+ authMode,
1423
+ baseUrl: ctx.model?.baseUrl,
1424
+ })
1425
+ ? "true"
1426
+ : "false");
1427
+ const action = await resolveDispatch({
1428
+ basePath: dispatchBasePath,
1429
+ mid: active.id,
1430
+ midTitle: active.title,
1431
+ state,
1432
+ prefs,
1433
+ structuredQuestionsAvailable,
1434
+ sessionContextWindow,
1435
+ sessionProvider,
1436
+ modelRegistry,
1437
+ });
1438
+ if (action.action !== "dispatch")
1439
+ return null;
1440
+ return {
1441
+ unitType: action.unitType,
1442
+ unitId: action.unitId,
1443
+ reason: action.matchedRule ?? "dispatch",
1444
+ preconditions: [],
1445
+ };
1446
+ },
1447
+ };
1448
+ }
1388
1449
  /**
1389
1450
  * Thin entry glue for the new Auto Orchestration module.
1390
1451
  *
@@ -1392,7 +1453,7 @@ function buildLifecycle() {
1392
1453
  * no behavior changes to the existing auto loop. It provides a concrete seam
1393
1454
  * the next refactor steps can adopt incrementally.
1394
1455
  */
1395
- export function createWiredAutoOrchestrationModule(ctx, _pi, dispatchBasePath, runtimeBasePath = resolveProjectRoot(dispatchBasePath)) {
1456
+ export function createWiredAutoOrchestrationModule(ctx, pi, dispatchBasePath, runtimeBasePath = resolveProjectRoot(dispatchBasePath)) {
1396
1457
  const flowId = `auto-orchestrator-${Date.now()}`;
1397
1458
  let seq = 0;
1398
1459
  const deps = {
@@ -1416,30 +1477,7 @@ export function createWiredAutoOrchestrationModule(ctx, _pi, dispatchBasePath, r
1416
1477
  };
1417
1478
  },
1418
1479
  },
1419
- dispatch: {
1420
- async decideNextUnit(input) {
1421
- const state = input.stateSnapshot;
1422
- const active = state.activeMilestone;
1423
- if (!active)
1424
- return null;
1425
- const prefs = loadEffectiveGSDPreferences(dispatchBasePath)?.preferences;
1426
- const action = await resolveDispatch({
1427
- basePath: dispatchBasePath,
1428
- mid: active.id,
1429
- midTitle: active.title,
1430
- state,
1431
- prefs,
1432
- });
1433
- if (action.action !== "dispatch")
1434
- return null;
1435
- return {
1436
- unitType: action.unitType,
1437
- unitId: action.unitId,
1438
- reason: action.matchedRule ?? "dispatch",
1439
- preconditions: [],
1440
- };
1441
- },
1442
- },
1480
+ dispatch: createWiredDispatchAdapter(ctx, pi, dispatchBasePath),
1443
1481
  recovery: {
1444
1482
  async classifyAndRecover(input) {
1445
1483
  const recovery = classifyFailure(input);
@@ -1488,12 +1526,26 @@ export function createWiredAutoOrchestrationModule(ctx, _pi, dispatchBasePath, r
1488
1526
  async cleanupOnStop() { },
1489
1527
  },
1490
1528
  health: {
1529
+ checkResourcesStale() {
1530
+ return checkResourcesStale(s.resourceVersionOnStart);
1531
+ },
1491
1532
  async preAdvanceGate() {
1492
- const gate = await preDispatchHealthGate(dispatchBasePath);
1493
- return {
1494
- allow: gate.proceed,
1495
- reason: gate.reason,
1496
- };
1533
+ try {
1534
+ const gate = await preDispatchHealthGate(dispatchBasePath);
1535
+ if (gate.proceed) {
1536
+ return {
1537
+ kind: "pass",
1538
+ fixesApplied: gate.fixesApplied,
1539
+ };
1540
+ }
1541
+ return {
1542
+ kind: "fail",
1543
+ reason: gate.reason ?? "Pre-dispatch health check failed — run /gsd doctor for details.",
1544
+ };
1545
+ }
1546
+ catch (error) {
1547
+ return { kind: "threw", error };
1548
+ }
1497
1549
  },
1498
1550
  async postAdvanceRecord(result) {
1499
1551
  if (result.kind === "error") {
@@ -1561,6 +1613,45 @@ export function createWiredAutoOrchestrationModule(ctx, _pi, dispatchBasePath, r
1561
1613
  }
1562
1614
  },
1563
1615
  },
1616
+ uokGate: {
1617
+ async emit(input) {
1618
+ const prefs = loadEffectiveGSDPreferences(dispatchBasePath)?.preferences;
1619
+ const uokFlags = resolveUokFlags(prefs);
1620
+ if (!uokFlags.gates)
1621
+ return;
1622
+ const milestoneId = input.milestoneId ?? s.currentMilestoneId ?? undefined;
1623
+ try {
1624
+ const { UokGateRunner } = await import("./uok/gate-runner.js");
1625
+ const runner = new UokGateRunner();
1626
+ runner.register({
1627
+ id: input.gateId,
1628
+ type: input.gateType,
1629
+ execute: async () => ({
1630
+ outcome: input.outcome,
1631
+ failureClass: input.failureClass,
1632
+ rationale: input.rationale,
1633
+ findings: input.findings ?? "",
1634
+ }),
1635
+ });
1636
+ await runner.run(input.gateId, {
1637
+ basePath: dispatchBasePath,
1638
+ traceId: `pre-dispatch:${flowId}`,
1639
+ turnId: `orch-${seq}`,
1640
+ milestoneId,
1641
+ unitType: "pre-dispatch",
1642
+ unitId: `orch-${seq}`,
1643
+ });
1644
+ }
1645
+ catch (err) {
1646
+ logWarning("engine", `uok gate emit failed: ${getErrorMessage(err)}`, {
1647
+ file: "auto.ts",
1648
+ gateId: input.gateId,
1649
+ gateType: input.gateType,
1650
+ ...(milestoneId ? { milestoneId } : {}),
1651
+ });
1652
+ }
1653
+ },
1654
+ },
1564
1655
  };
1565
1656
  return createAutoOrchestrator(deps);
1566
1657
  }
@@ -256,7 +256,7 @@ function importRequirements(gsdDir) {
256
256
  }
257
257
  // ─── Hierarchy Artifact Walker ─────────────────────────────────────────────
258
258
  /** Artifact suffixes to look for at each hierarchy level */
259
- const MILESTONE_SUFFIXES = ['ROADMAP', 'CONTEXT', 'RESEARCH', 'ASSESSMENT'];
259
+ const MILESTONE_SUFFIXES = ['ROADMAP', 'CONTEXT', 'RESEARCH', 'ASSESSMENT', 'SUMMARY', 'VALIDATION'];
260
260
  const SLICE_SUFFIXES = ['PLAN', 'SUMMARY', 'RESEARCH', 'CONTEXT', 'ASSESSMENT', 'UAT'];
261
261
  const TASK_SUFFIXES = ['PLAN', 'SUMMARY', 'CONTINUE', 'CONTEXT', 'RESEARCH'];
262
262
  /**
@@ -21,6 +21,9 @@ import { migrateFromMarkdown } from "../md-importer.js";
21
21
  import { invalidateStateCache } from "../state.js";
22
22
  function assertMigrationImportMatchesPreview(imported, preview) {
23
23
  const mismatches = [];
24
+ if (imported.decisions !== preview.decisions.total) {
25
+ mismatches.push(`decisions ${imported.decisions}/${preview.decisions.total}`);
26
+ }
24
27
  if (imported.hierarchy.milestones !== preview.milestoneCount) {
25
28
  mismatches.push(`milestones ${imported.hierarchy.milestones}/${preview.milestoneCount}`);
26
29
  }
@@ -55,6 +58,7 @@ export async function importWrittenMigrationToDb(basePath, preview) {
55
58
  /** Format preview stats for embedding in the review prompt. */
56
59
  function formatPreviewStats(preview) {
57
60
  const lines = [
61
+ `- Decisions: ${preview.decisions.total}`,
58
62
  `- Milestones: ${preview.milestoneCount}`,
59
63
  `- Slices: ${preview.totalSlices} (${preview.doneSlices} done — ${preview.sliceCompletionPct}%)`,
60
64
  `- Tasks: ${preview.totalTasks} (${preview.doneTasks} done — ${preview.taskCompletionPct}%)`,
@@ -124,6 +128,7 @@ export async function handleMigrate(args, ctx, pi) {
124
128
  const preview = generatePreview(project);
125
129
  // ── Build preview text ─────────────────────────────────────────────────────
126
130
  const lines = [
131
+ `Decisions: ${preview.decisions.total}`,
127
132
  `Milestones: ${preview.milestoneCount}`,
128
133
  `Slices: ${preview.totalSlices} (${preview.doneSlices} done — ${preview.sliceCompletionPct}%)`,
129
134
  `Tasks: ${preview.totalTasks} (${preview.doneTasks} done — ${preview.taskCompletionPct}%)`,
@@ -1,5 +1,11 @@
1
1
  // GSD Migration Preview — Pre-write statistics
2
2
  // Pure function, no I/O. Computes counts from a GSDProject.
3
+ function countCanonicalDecisionRows(content) {
4
+ return content
5
+ .split('\n')
6
+ .filter((line) => /^\|\s*D\d+\s*\|/.test(line.trim()))
7
+ .length;
8
+ }
3
9
  /**
4
10
  * Compute pre-write statistics from a GSDProject without performing I/O.
5
11
  * Used to show the user what a migration will produce before writing anything.
@@ -35,6 +41,9 @@ export function generatePreview(project) {
35
41
  reqCounts.total++;
36
42
  }
37
43
  return {
44
+ decisions: {
45
+ total: countCanonicalDecisionRows(project.decisionsContent),
46
+ },
38
47
  milestoneCount: project.milestones.length,
39
48
  totalSlices,
40
49
  totalTasks,
@@ -187,16 +187,48 @@ function normalizeStatus(status) {
187
187
  return 'validated';
188
188
  return 'active';
189
189
  }
190
+ function normalizeRequirementId(id) {
191
+ const match = id.trim().match(/^R(\d+)$/i);
192
+ if (!match)
193
+ return null;
194
+ return `R${match[1].padStart(3, '0')}`;
195
+ }
190
196
  function mapRequirements(reqs) {
191
197
  let autoId = 0;
198
+ const reservedIds = new Set(reqs
199
+ .map((req) => normalizeRequirementId(req.id))
200
+ .filter((id) => id !== null));
201
+ const usedIds = new Set();
202
+ function nextRequirementId() {
203
+ let id = '';
204
+ do {
205
+ autoId++;
206
+ id = padId('R', autoId, 3);
207
+ } while (usedIds.has(id) || reservedIds.has(id));
208
+ usedIds.add(id);
209
+ return id;
210
+ }
192
211
  return reqs.map((req) => {
193
- autoId++;
212
+ const originalId = req.id.trim();
213
+ const canonicalId = normalizeRequirementId(originalId);
214
+ let id;
215
+ let description = req.description;
216
+ if (canonicalId && !usedIds.has(canonicalId)) {
217
+ id = canonicalId;
218
+ usedIds.add(id);
219
+ }
220
+ else {
221
+ id = nextRequirementId();
222
+ if (originalId) {
223
+ description = `Legacy ID: ${originalId}\n\n${description}`;
224
+ }
225
+ }
194
226
  return {
195
- id: req.id && req.id.trim() !== '' ? req.id : padId('R', autoId, 3),
227
+ id,
196
228
  title: req.title,
197
229
  class: 'core-capability',
198
230
  status: normalizeStatus(req.status),
199
- description: req.description,
231
+ description,
200
232
  source: 'inferred',
201
233
  primarySlice: 'none yet',
202
234
  };
@@ -233,7 +265,22 @@ function deriveDecisions(parsed) {
233
265
  }
234
266
  if (decisions.length === 0)
235
267
  return '';
236
- return decisions.map((d) => `- ${d}`).join('\n');
268
+ const lines = [
269
+ '# Decisions Register',
270
+ '',
271
+ '<!-- Append-only. Never edit or remove existing rows.',
272
+ ' To reverse a decision, add a new row that supersedes it.',
273
+ ' Read this file at the start of any planning or research phase. -->',
274
+ '',
275
+ '| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By |',
276
+ '|---|------|-------|----------|--------|-----------|------------|---------|',
277
+ ];
278
+ decisions.forEach((decision, index) => {
279
+ const id = padId('D', index + 1, 3);
280
+ const escaped = decision.replace(/\|/g, '\\|');
281
+ lines.push(`| ${id} | migration | migrated-summary | ${escaped} | ${escaped} | Migrated from legacy summary key-decisions | Yes | agent |`);
282
+ });
283
+ return lines.join('\n') + '\n';
237
284
  }
238
285
  // ─── Main Entry Point ──────────────────────────────────────────────────────
239
286
  export function transformToGSD(parsed) {
@@ -296,7 +296,17 @@ export function formatProject(content) {
296
296
  */
297
297
  export function formatDecisions(content) {
298
298
  if (!content || !content.trim()) {
299
- return '# Decisions\n\n<!-- Append-only register of architectural and pattern decisions -->\n\n| ID | Decision | Rationale | Date |\n|----|----------|-----------|------|\n';
299
+ return [
300
+ '# Decisions Register',
301
+ '',
302
+ '<!-- Append-only. Never edit or remove existing rows.',
303
+ ' To reverse a decision, add a new row that supersedes it.',
304
+ ' Read this file at the start of any planning or research phase. -->',
305
+ '',
306
+ '| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By |',
307
+ '|---|------|-------|----------|--------|-----------|------------|---------|',
308
+ '',
309
+ ].join('\n');
300
310
  }
301
311
  return content.endsWith('\n') ? content : content + '\n';
302
312
  }