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

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 (87) 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 +128 -52
  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/resources/extensions/gsd/tools/workflow-tool-executors.js +119 -0
  10. package/dist/resources/extensions/gsd/worktree-lifecycle.js +21 -2
  11. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  12. package/dist/web/standalone/.next/BUILD_ID +1 -1
  13. package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
  14. package/dist/web/standalone/.next/build-manifest.json +2 -2
  15. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  16. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  17. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  18. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  25. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/index.html +1 -1
  33. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
  40. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  41. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  42. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  43. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  44. package/package.json +1 -1
  45. package/packages/contracts/dist/rpc.test.js +7 -0
  46. package/packages/contracts/dist/rpc.test.js.map +1 -1
  47. package/packages/contracts/dist/workflow.d.ts +21 -0
  48. package/packages/contracts/dist/workflow.d.ts.map +1 -1
  49. package/packages/contracts/dist/workflow.js +24 -0
  50. package/packages/contracts/dist/workflow.js.map +1 -1
  51. package/packages/contracts/src/rpc.test.ts +8 -0
  52. package/packages/contracts/src/workflow.ts +24 -0
  53. package/packages/mcp-server/README.md +13 -4
  54. package/packages/mcp-server/dist/workflow-tools.d.ts +0 -3
  55. package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
  56. package/packages/mcp-server/dist/workflow-tools.js +80 -0
  57. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  58. package/packages/mcp-server/src/workflow-tools.test.ts +22 -0
  59. package/packages/mcp-server/src/workflow-tools.ts +168 -0
  60. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  61. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  62. package/packages/pi-tui/dist/tui.d.ts.map +1 -1
  63. package/packages/pi-tui/dist/tui.js +5 -0
  64. package/packages/pi-tui/dist/tui.js.map +1 -1
  65. package/packages/pi-tui/src/tui.ts +6 -0
  66. package/packages/pi-tui/tsconfig.tsbuildinfo +1 -1
  67. package/packages/rpc-client/tsconfig.tsbuildinfo +1 -1
  68. package/src/resources/extensions/gsd/auto/contracts.ts +46 -11
  69. package/src/resources/extensions/gsd/auto/orchestrator.ts +118 -6
  70. package/src/resources/extensions/gsd/auto.ts +136 -51
  71. package/src/resources/extensions/gsd/md-importer.ts +1 -1
  72. package/src/resources/extensions/gsd/migrate/command.ts +5 -0
  73. package/src/resources/extensions/gsd/migrate/preview.ts +10 -0
  74. package/src/resources/extensions/gsd/migrate/transformer.ts +58 -4
  75. package/src/resources/extensions/gsd/migrate/writer.ts +14 -1
  76. package/src/resources/extensions/gsd/tests/auto-orchestrator.test.ts +408 -4
  77. package/src/resources/extensions/gsd/tests/auto-paused-ui-cleanup.test.ts +6 -5
  78. package/src/resources/extensions/gsd/tests/auto-runtime-state.test.ts +4 -4
  79. package/src/resources/extensions/gsd/tests/integration/migrate-command.test.ts +48 -3
  80. package/src/resources/extensions/gsd/tests/migrate-transformer.test.ts +5 -1
  81. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +6 -1
  82. package/src/resources/extensions/gsd/tests/state-corruption-2945.test.ts +6 -0
  83. package/src/resources/extensions/gsd/tests/worktree-lifecycle.test.ts +25 -0
  84. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +135 -0
  85. package/src/resources/extensions/gsd/worktree-lifecycle.ts +20 -2
  86. /package/dist/web/standalone/.next/static/{KDRTXR-22LPCsa80X9dey → Wop3A7KRGyR06H3rla_1-}/_buildManifest.js +0 -0
  87. /package/dist/web/standalone/.next/static/{KDRTXR-22LPCsa80X9dey → Wop3A7KRGyR06H3rla_1-}/_ssgManifest.js +0 -0
@@ -1 +1 @@
1
- 5c6d4acc2e1d8c2b
1
+ 291e7683e17ec7a9
@@ -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";
@@ -711,13 +712,11 @@ export async function cleanupAfterLoopExit(ctx) {
711
712
  }
712
713
  initHealthWidget(ctx);
713
714
  }
714
- // ADR-016 phase 3 (#5693): the stop-path basePath restore routes through
715
- // `Lifecycle.restoreToProjectRoot()`, the sole owner of `s.basePath`
716
- // mutation. The verb assigns `s.basePath` before any throwable work
717
- // (rebuildGitService, cache invalidation), so a thrown error still leaves
718
- // basePath restored no fallback assignment needed at the call site.
719
- // The chdir stays here because `restoreToProjectRoot` is a pure
720
- // session-state mutation.
715
+ // ADR-016 phase 3 (#5693): the stop-path basePath restore + chdir routes
716
+ // through `Lifecycle.restoreToProjectRoot()`, the sole owner of both
717
+ // `s.basePath` mutation and the paired `process.chdir` for auto-loop
718
+ // transitions. The verb assigns `s.basePath` before any throwable work, so
719
+ // a thrown error still leaves basePath restored.
721
720
  if (s.originalBasePath) {
722
721
  try {
723
722
  buildLifecycle().restoreToProjectRoot();
@@ -725,12 +724,6 @@ export async function cleanupAfterLoopExit(ctx) {
725
724
  catch (err) {
726
725
  logWarning("engine", `restore project root failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" });
727
726
  }
728
- try {
729
- process.chdir(s.originalBasePath);
730
- }
731
- catch (err) {
732
- logWarning("engine", `basePath restore/chdir failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" });
733
- }
734
727
  }
735
728
  if (s.originalBasePath && s.cmdCtx) {
736
729
  const result = await rerootCommandSession(s.cmdCtx, s.originalBasePath);
@@ -1001,8 +994,8 @@ export async function stopAuto(ctx, pi, reason, options = {}) {
1001
994
  }
1002
995
  }
1003
996
  // ── Step 7: Restore basePath and chdir (ADR-016 phase 3, #5693) ──
1004
- // `restoreToProjectRoot` assigns s.basePath before any throwable work;
1005
- // no fallback assignment is needed at the call site.
997
+ // `restoreToProjectRoot` owns both s.basePath restore and process.chdir;
998
+ // no paired chdir is needed at the call site.
1006
999
  if (s.originalBasePath) {
1007
1000
  try {
1008
1001
  buildLifecycle().restoreToProjectRoot();
@@ -1010,13 +1003,6 @@ export async function stopAuto(ctx, pi, reason, options = {}) {
1010
1003
  catch (e) {
1011
1004
  debugLog("stop-cleanup-basepath", { error: e instanceof Error ? e.message : String(e) });
1012
1005
  }
1013
- try {
1014
- process.chdir(s.basePath);
1015
- }
1016
- catch (err) {
1017
- /* best-effort */
1018
- logWarning("engine", `chdir failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" });
1019
- }
1020
1006
  }
1021
1007
  // Re-root the active command session/tool runtime after worktree teardown.
1022
1008
  // mergeAndExit restores process.cwd(), but AgentSession has already captured
@@ -1385,6 +1371,66 @@ export function buildWorktreeLifecycleDeps() {
1385
1371
  function buildLifecycle() {
1386
1372
  return new WorktreeLifecycle(s, buildWorktreeLifecycleDeps());
1387
1373
  }
1374
+ /**
1375
+ * Build the production `DispatchAdapter` used by `createWiredAutoOrchestrationModule`.
1376
+ *
1377
+ * Exported so tests can verify parity with `runDispatch`'s `resolveDispatch` call —
1378
+ * the wired adapter must derive `structuredQuestionsAvailable`, `sessionContextWindow`,
1379
+ * `sessionProvider`, and `modelRegistry` the same way phases.ts:runDispatch does.
1380
+ */
1381
+ export function createWiredDispatchAdapter(ctx, pi, dispatchBasePath) {
1382
+ return {
1383
+ async decideNextUnit(input) {
1384
+ const state = input.stateSnapshot;
1385
+ const active = state.activeMilestone;
1386
+ if (!active)
1387
+ return null;
1388
+ const prefs = loadEffectiveGSDPreferences(dispatchBasePath)?.preferences;
1389
+ // Derive session-derived dispatch inputs the same way phases.ts:runDispatch does
1390
+ // (#5789). Prefer caller-supplied values when present so test harnesses and
1391
+ // alternative wirings can inject deterministic snapshots; otherwise pull from
1392
+ // the captured pi/ctx references.
1393
+ const sessionProvider = input.sessionProvider ?? ctx.model?.provider;
1394
+ const sessionContextWindow = input.sessionContextWindow ?? ctx.model?.contextWindow;
1395
+ const modelRegistry = input.modelRegistry ?? ctx.modelRegistry;
1396
+ const authMode = sessionProvider && typeof ctx.modelRegistry?.getProviderAuthMode === "function"
1397
+ ? ctx.modelRegistry.getProviderAuthMode(sessionProvider)
1398
+ : undefined;
1399
+ const activeTools = typeof pi.getActiveTools === "function" ? pi.getActiveTools() : [];
1400
+ // Mirrors runDispatch: deep-planning keeps approval gates in plain chat
1401
+ // because structured questions can be cancelled outside the chat turn on
1402
+ // some transports.
1403
+ const structuredQuestionsAvailable = input.structuredQuestionsAvailable ??
1404
+ (prefs?.planning_depth === "deep"
1405
+ ? "false"
1406
+ : supportsStructuredQuestions(activeTools, {
1407
+ authMode,
1408
+ baseUrl: ctx.model?.baseUrl,
1409
+ })
1410
+ ? "true"
1411
+ : "false");
1412
+ const action = await resolveDispatch({
1413
+ basePath: dispatchBasePath,
1414
+ mid: active.id,
1415
+ midTitle: active.title,
1416
+ state,
1417
+ prefs,
1418
+ structuredQuestionsAvailable,
1419
+ sessionContextWindow,
1420
+ sessionProvider,
1421
+ modelRegistry,
1422
+ });
1423
+ if (action.action !== "dispatch")
1424
+ return null;
1425
+ return {
1426
+ unitType: action.unitType,
1427
+ unitId: action.unitId,
1428
+ reason: action.matchedRule ?? "dispatch",
1429
+ preconditions: [],
1430
+ };
1431
+ },
1432
+ };
1433
+ }
1388
1434
  /**
1389
1435
  * Thin entry glue for the new Auto Orchestration module.
1390
1436
  *
@@ -1392,7 +1438,7 @@ function buildLifecycle() {
1392
1438
  * no behavior changes to the existing auto loop. It provides a concrete seam
1393
1439
  * the next refactor steps can adopt incrementally.
1394
1440
  */
1395
- export function createWiredAutoOrchestrationModule(ctx, _pi, dispatchBasePath, runtimeBasePath = resolveProjectRoot(dispatchBasePath)) {
1441
+ export function createWiredAutoOrchestrationModule(ctx, pi, dispatchBasePath, runtimeBasePath = resolveProjectRoot(dispatchBasePath)) {
1396
1442
  const flowId = `auto-orchestrator-${Date.now()}`;
1397
1443
  let seq = 0;
1398
1444
  const deps = {
@@ -1416,30 +1462,7 @@ export function createWiredAutoOrchestrationModule(ctx, _pi, dispatchBasePath, r
1416
1462
  };
1417
1463
  },
1418
1464
  },
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
- },
1465
+ dispatch: createWiredDispatchAdapter(ctx, pi, dispatchBasePath),
1443
1466
  recovery: {
1444
1467
  async classifyAndRecover(input) {
1445
1468
  const recovery = classifyFailure(input);
@@ -1488,12 +1511,26 @@ export function createWiredAutoOrchestrationModule(ctx, _pi, dispatchBasePath, r
1488
1511
  async cleanupOnStop() { },
1489
1512
  },
1490
1513
  health: {
1514
+ checkResourcesStale() {
1515
+ return checkResourcesStale(s.resourceVersionOnStart);
1516
+ },
1491
1517
  async preAdvanceGate() {
1492
- const gate = await preDispatchHealthGate(dispatchBasePath);
1493
- return {
1494
- allow: gate.proceed,
1495
- reason: gate.reason,
1496
- };
1518
+ try {
1519
+ const gate = await preDispatchHealthGate(dispatchBasePath);
1520
+ if (gate.proceed) {
1521
+ return {
1522
+ kind: "pass",
1523
+ fixesApplied: gate.fixesApplied,
1524
+ };
1525
+ }
1526
+ return {
1527
+ kind: "fail",
1528
+ reason: gate.reason ?? "Pre-dispatch health check failed — run /gsd doctor for details.",
1529
+ };
1530
+ }
1531
+ catch (error) {
1532
+ return { kind: "threw", error };
1533
+ }
1497
1534
  },
1498
1535
  async postAdvanceRecord(result) {
1499
1536
  if (result.kind === "error") {
@@ -1561,6 +1598,45 @@ export function createWiredAutoOrchestrationModule(ctx, _pi, dispatchBasePath, r
1561
1598
  }
1562
1599
  },
1563
1600
  },
1601
+ uokGate: {
1602
+ async emit(input) {
1603
+ const prefs = loadEffectiveGSDPreferences(dispatchBasePath)?.preferences;
1604
+ const uokFlags = resolveUokFlags(prefs);
1605
+ if (!uokFlags.gates)
1606
+ return;
1607
+ const milestoneId = input.milestoneId ?? s.currentMilestoneId ?? undefined;
1608
+ try {
1609
+ const { UokGateRunner } = await import("./uok/gate-runner.js");
1610
+ const runner = new UokGateRunner();
1611
+ runner.register({
1612
+ id: input.gateId,
1613
+ type: input.gateType,
1614
+ execute: async () => ({
1615
+ outcome: input.outcome,
1616
+ failureClass: input.failureClass,
1617
+ rationale: input.rationale,
1618
+ findings: input.findings ?? "",
1619
+ }),
1620
+ });
1621
+ await runner.run(input.gateId, {
1622
+ basePath: dispatchBasePath,
1623
+ traceId: `pre-dispatch:${flowId}`,
1624
+ turnId: `orch-${seq}`,
1625
+ milestoneId,
1626
+ unitType: "pre-dispatch",
1627
+ unitId: `orch-${seq}`,
1628
+ });
1629
+ }
1630
+ catch (err) {
1631
+ logWarning("engine", `uok gate emit failed: ${getErrorMessage(err)}`, {
1632
+ file: "auto.ts",
1633
+ gateId: input.gateId,
1634
+ gateType: input.gateType,
1635
+ ...(milestoneId ? { milestoneId } : {}),
1636
+ });
1637
+ }
1638
+ },
1639
+ },
1564
1640
  };
1565
1641
  return createAutoOrchestrator(deps);
1566
1642
  }
@@ -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
  }