gsd-pi 2.79.0 → 2.80.0

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 (151) hide show
  1. package/README.md +94 -47
  2. package/dist/resources/.managed-resources-content-hash +1 -1
  3. package/dist/resources/extensions/gsd/auto/contracts.js +1 -0
  4. package/dist/resources/extensions/gsd/auto/orchestrator.js +146 -0
  5. package/dist/resources/extensions/gsd/auto/phases.js +61 -7
  6. package/dist/resources/extensions/gsd/auto/session.js +8 -0
  7. package/dist/resources/extensions/gsd/auto-artifact-paths.js +2 -2
  8. package/dist/resources/extensions/gsd/auto-dispatch.js +2 -0
  9. package/dist/resources/extensions/gsd/auto-prompts.js +52 -29
  10. package/dist/resources/extensions/gsd/auto-recovery.js +63 -55
  11. package/dist/resources/extensions/gsd/auto-runtime-state.js +4 -0
  12. package/dist/resources/extensions/gsd/auto-start.js +3 -2
  13. package/dist/resources/extensions/gsd/auto.js +159 -2
  14. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +9 -1
  15. package/dist/resources/extensions/gsd/bootstrap/exec-tools.js +2 -2
  16. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +41 -45
  17. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +8 -8
  18. package/dist/resources/extensions/gsd/commands/context.js +1 -1
  19. package/dist/resources/extensions/gsd/gsd-db.js +34 -1
  20. package/dist/resources/extensions/gsd/guided-flow.js +40 -0
  21. package/dist/resources/extensions/gsd/paths.js +5 -1
  22. package/dist/resources/extensions/gsd/post-execution-checks.js +25 -6
  23. package/dist/resources/extensions/gsd/preferences-types.js +20 -2
  24. package/dist/resources/extensions/gsd/preferences-validation.js +3 -3
  25. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +82 -2
  26. package/dist/resources/extensions/gsd/unit-context-composer.js +32 -0
  27. package/dist/resources/extensions/gsd/unit-context-manifest.js +21 -0
  28. package/dist/resources/extensions/gsd/uok/audit.js +23 -9
  29. package/dist/resources/extensions/gsd/uok/contracts.js +69 -1
  30. package/dist/resources/extensions/gsd/uok/dispatch-envelope.js +3 -0
  31. package/dist/resources/extensions/gsd/uok/loop-adapter.js +48 -33
  32. package/dist/resources/extensions/gsd/uok/timeline.js +125 -0
  33. package/dist/resources/extensions/shared/gsd-phase-state.js +45 -3
  34. package/dist/resources/extensions/shared/interview-ui.js +15 -4
  35. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  36. package/dist/web/standalone/.next/BUILD_ID +1 -1
  37. package/dist/web/standalone/.next/app-path-routes-manifest.json +9 -9
  38. package/dist/web/standalone/.next/build-manifest.json +2 -2
  39. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  40. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  48. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/index.html +1 -1
  56. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app-paths-manifest.json +9 -9
  63. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  64. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  65. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  66. package/package.json +1 -1
  67. package/packages/daemon/package.json +2 -2
  68. package/packages/mcp-server/dist/workflow-tools.d.ts +1 -1
  69. package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
  70. package/packages/mcp-server/dist/workflow-tools.js +53 -0
  71. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  72. package/packages/mcp-server/package.json +2 -2
  73. package/packages/mcp-server/src/workflow-tools.test.ts +129 -2
  74. package/packages/mcp-server/src/workflow-tools.ts +81 -0
  75. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  76. package/packages/native/package.json +1 -1
  77. package/packages/pi-agent-core/package.json +1 -1
  78. package/packages/pi-ai/package.json +1 -1
  79. package/packages/pi-coding-agent/package.json +1 -1
  80. package/packages/pi-tui/package.json +1 -1
  81. package/packages/rpc-client/package.json +1 -1
  82. package/pkg/package.json +1 -1
  83. package/src/resources/extensions/gsd/auto/contracts.ts +87 -0
  84. package/src/resources/extensions/gsd/auto/loop-deps.ts +10 -3
  85. package/src/resources/extensions/gsd/auto/orchestrator.ts +161 -0
  86. package/src/resources/extensions/gsd/auto/phases.ts +88 -9
  87. package/src/resources/extensions/gsd/auto/session.ts +11 -0
  88. package/src/resources/extensions/gsd/auto-artifact-paths.ts +2 -2
  89. package/src/resources/extensions/gsd/auto-dispatch.ts +1 -0
  90. package/src/resources/extensions/gsd/auto-prompts.ts +106 -28
  91. package/src/resources/extensions/gsd/auto-recovery.ts +59 -53
  92. package/src/resources/extensions/gsd/auto-runtime-state.ts +7 -0
  93. package/src/resources/extensions/gsd/auto-start.ts +3 -2
  94. package/src/resources/extensions/gsd/auto.ts +167 -1
  95. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +14 -1
  96. package/src/resources/extensions/gsd/bootstrap/exec-tools.ts +2 -2
  97. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +49 -46
  98. package/src/resources/extensions/gsd/bootstrap/tests/write-gate-shouldblock-basepath.test.ts +97 -0
  99. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +8 -4
  100. package/src/resources/extensions/gsd/commands/context.ts +1 -1
  101. package/src/resources/extensions/gsd/gsd-db.ts +35 -1
  102. package/src/resources/extensions/gsd/guided-flow.ts +47 -0
  103. package/src/resources/extensions/gsd/interrupted-session.ts +1 -0
  104. package/src/resources/extensions/gsd/paths.ts +6 -1
  105. package/src/resources/extensions/gsd/post-execution-checks.ts +31 -6
  106. package/src/resources/extensions/gsd/preferences-types.ts +23 -4
  107. package/src/resources/extensions/gsd/preferences-validation.ts +3 -3
  108. package/src/resources/extensions/gsd/tests/auto-abort-pause-regression.test.ts +32 -0
  109. package/src/resources/extensions/gsd/tests/auto-orchestrator.test.ts +353 -0
  110. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +108 -1
  111. package/src/resources/extensions/gsd/tests/auto-runtime-state.test.ts +39 -0
  112. package/src/resources/extensions/gsd/tests/auto-session-encapsulation.test.ts +3 -0
  113. package/src/resources/extensions/gsd/tests/bootstrap-derive-state-db-open.test.ts +2 -2
  114. package/src/resources/extensions/gsd/tests/check-auto-start-pending-gate.test.ts +203 -0
  115. package/src/resources/extensions/gsd/tests/check-auto-start-ready-guard.test.ts +148 -0
  116. package/src/resources/extensions/gsd/tests/current-directory-root-homedir-fallback.test.ts +63 -0
  117. package/src/resources/extensions/gsd/tests/deep-planning-mode-dispatch.test.ts +42 -0
  118. package/src/resources/extensions/gsd/tests/deep-project-auto-loop.test.ts +63 -2
  119. package/src/resources/extensions/gsd/tests/execute-summary-save-empty-project.test.ts +109 -0
  120. package/src/resources/extensions/gsd/tests/gsd-db.test.ts +95 -0
  121. package/src/resources/extensions/gsd/tests/guided-flow-prompt-consolidation.test.ts +14 -0
  122. package/src/resources/extensions/gsd/tests/integration/auto-recovery.test.ts +79 -0
  123. package/src/resources/extensions/gsd/tests/journal-integration.test.ts +134 -0
  124. package/src/resources/extensions/gsd/tests/parallel-skill-prompt-integration.test.ts +8 -0
  125. package/src/resources/extensions/gsd/tests/paused-session-via-db.test.ts +2 -0
  126. package/src/resources/extensions/gsd/tests/plan-slice.test.ts +27 -0
  127. package/src/resources/extensions/gsd/tests/post-execution-checks.test.ts +46 -0
  128. package/src/resources/extensions/gsd/tests/pre-exec-gate-loop.test.ts +3 -0
  129. package/src/resources/extensions/gsd/tests/register-hooks-compaction-checkpoint.test.ts +85 -0
  130. package/src/resources/extensions/gsd/tests/run-uat-composer.test.ts +2 -0
  131. package/src/resources/extensions/gsd/tests/subagent-model-dispatch.test.ts +59 -0
  132. package/src/resources/extensions/gsd/tests/unit-context-composer.test.ts +38 -0
  133. package/src/resources/extensions/gsd/tests/unit-context-manifest.test.ts +32 -0
  134. package/src/resources/extensions/gsd/tests/uok-contracts.test.ts +109 -1
  135. package/src/resources/extensions/gsd/tests/uok-loop-adapter-writer.test.ts +98 -0
  136. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +132 -3
  137. package/src/resources/extensions/gsd/tests/worktree-path-injection.test.ts +3 -0
  138. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +84 -1
  139. package/src/resources/extensions/gsd/unit-context-composer.ts +49 -0
  140. package/src/resources/extensions/gsd/unit-context-manifest.ts +34 -0
  141. package/src/resources/extensions/gsd/uok/audit.ts +25 -9
  142. package/src/resources/extensions/gsd/uok/contracts.ts +105 -0
  143. package/src/resources/extensions/gsd/uok/dispatch-envelope.ts +4 -0
  144. package/src/resources/extensions/gsd/uok/loop-adapter.ts +60 -45
  145. package/src/resources/extensions/gsd/uok/timeline.ts +158 -0
  146. package/src/resources/extensions/shared/gsd-phase-state.ts +56 -3
  147. package/src/resources/extensions/shared/interview-ui.ts +18 -5
  148. package/src/resources/extensions/shared/tests/gsd-phase-state.test.ts +43 -1
  149. package/src/resources/extensions/shared/tests/interview-notes-loop.test.ts +41 -0
  150. /package/dist/web/standalone/.next/static/{J-CU-p_sp45CJHT3R9TJS → V-3Ehy4B24f9FCGiLPWIM}/_buildManifest.js +0 -0
  151. /package/dist/web/standalone/.next/static/{J-CU-p_sp45CJHT3R9TJS → V-3Ehy4B24f9FCGiLPWIM}/_ssgManifest.js +0 -0
@@ -70,6 +70,7 @@ import {
70
70
  } from "./preparation.js";
71
71
  import { verifyExpectedArtifact } from "./auto-recovery.js";
72
72
  import { createWorkspace, scopeMilestone, type MilestoneScope } from "./workspace.js";
73
+ import { getPendingGate, extractDepthVerificationMilestoneId } from "./bootstrap/write-gate.js";
73
74
 
74
75
  // ─── Re-exports (preserve public API for existing importers) ────────────────
75
76
  export {
@@ -410,6 +411,15 @@ export async function checkDeepProjectSetupAfterTurn(
410
411
  }
411
412
  }
412
413
 
414
+ // R2: a depth-verification gate is still pending — the LLM emitted the
415
+ // confirmation question (via ask_user_questions or plain chat) but the user
416
+ // has not approved yet. Returning false keeps the entry in the
417
+ // pendingDeepProjectSetupMap so the next user message can resume.
418
+ const pendingGateId = getPendingGate(entry.basePath);
419
+ if (pendingGateId) {
420
+ return false;
421
+ }
422
+
413
423
  return dispatchNextDeepProjectSetupStage(entry);
414
424
  }
415
425
 
@@ -494,6 +504,25 @@ export function checkAutoStartAfterDiscuss(): boolean {
494
504
  const roadmapFile = existsSync(roadmapFilePath) ? roadmapFilePath : null;
495
505
  if (!contextFile && !roadmapFile) return false; // neither artifact yet — keep waiting
496
506
 
507
+ // Gate 1a: a depth-verification gate is still pending for THIS milestone — the
508
+ // LLM emitted the confirmation question (via ask_user_questions or plain chat)
509
+ // but the user has not answered yet. Advancing now would skip the gate and
510
+ // race ahead with unverified context.
511
+ const basePathForGate = entry.scope.workspace.projectRoot;
512
+ const pendingGateId = getPendingGate(basePathForGate);
513
+ if (pendingGateId) {
514
+ const pendingMilestoneId = extractDepthVerificationMilestoneId(pendingGateId);
515
+ // Block advancement if the gate is for THIS milestone, OR if it's a
516
+ // project/requirements gate (no milestone id encoded) for the deep setup flow.
517
+ const isProjectGate =
518
+ pendingGateId === "depth_verification_project_confirm" ||
519
+ pendingGateId === "depth_verification_requirements_confirm" ||
520
+ pendingGateId === "depth_verification_research_decision_confirm";
521
+ if (pendingMilestoneId === milestoneId || isProjectGate) {
522
+ return false;
523
+ }
524
+ }
525
+
497
526
  // Gate 1b: Discriminate plan-blocked from discuss-incomplete when the DB row is queued.
498
527
  // If the DB is available and the row is still "queued" but CONTEXT.md already exists on
499
528
  // disk, the discuss phase completed but gsd_plan_milestone was hard-blocked by the
@@ -628,6 +657,24 @@ export function checkAutoStartAfterDiscuss(): boolean {
628
657
  try { unlinkSync(manifestPath); } catch (e) { logWarning("guided", `manifest unlink failed: ${(e as Error).message}`); }
629
658
  }
630
659
 
660
+ // R3b: belt-and-suspenders for silent registration failure. The discuss flow
661
+ // finished and STATE.md exists, but the milestone may never have landed in
662
+ // the DB. Without this guard, the user sees "Milestone M001 ready." and then
663
+ // /gsd reports "No Active Milestone".
664
+ if (isDbAvailable()) {
665
+ const milestoneRow = getMilestone(milestoneId);
666
+ if (!milestoneRow) {
667
+ ctx.ui.notify(
668
+ `Milestone ${milestoneId}: discuss artifacts on disk but no DB row exists. ` +
669
+ `PROJECT.md may have failed to register milestones. ` +
670
+ `Re-save PROJECT.md with canonical "- [ ] M001: Title — One-liner" lines, ` +
671
+ `then re-run /gsd to recover.`,
672
+ "error",
673
+ );
674
+ return false;
675
+ }
676
+ }
677
+
631
678
  pendingAutoStartMap.delete(basePath);
632
679
  ctx.ui.notify(`Milestone ${milestoneId} ready.`, "success");
633
680
  startAutoDetached(ctx, pi, basePath, false, { step });
@@ -37,6 +37,7 @@ export interface PausedSessionMetadata {
37
37
  activeRunDir?: string | null;
38
38
  autoStartTime?: number;
39
39
  milestoneLock?: string | null;
40
+ pauseReason?: string;
40
41
  }
41
42
 
42
43
  export interface InterruptedSessionAssessment {
@@ -188,9 +188,14 @@ export function resolveDir(parentDir: string, idPrefix: string): string | null {
188
188
  // Exact match first (current convention: bare ID)
189
189
  const exact = entries.find(e => e.isDirectory() && e.name === idPrefix);
190
190
  if (exact) return exact.name;
191
+ const idLower = idPrefix.toLowerCase();
192
+ const exactCaseInsensitive = entries.find(
193
+ e => e.isDirectory() && e.name.toLowerCase() === idLower
194
+ );
195
+ if (exactCaseInsensitive) return exactCaseInsensitive.name;
191
196
  // Prefix match for legacy descriptor dirs: M001-SOMETHING
192
197
  const prefixed = entries.find(
193
- e => e.isDirectory() && e.name.startsWith(idPrefix + "-")
198
+ e => e.isDirectory() && e.name.toLowerCase().startsWith(idLower + "-")
194
199
  );
195
200
  return prefixed ? prefixed.name : null;
196
201
  } catch {
@@ -144,12 +144,30 @@ export function resolveImportPath(
144
144
  if (existsSync(directPath)) {
145
145
  return { exists: true, resolvedPath: directPath };
146
146
  }
147
- // Only .js/.jsx/.mjs/.cjs imports legitimately fall through for the TS
148
- // ESM convention (.js .ts). Any other explicit extension (.css, .json,
149
- // .svg, images, fonts, .ts, .tsx, …) must stay unresolved when the direct
150
- // path is missing otherwise a stray `./missing.css.ts` could shadow a
151
- // genuinely missing `./missing.css` import.
152
- if (![".js", ".jsx", ".mjs", ".cjs"].includes(explicitExt)) {
147
+
148
+ // Known concrete extensions that should NOT fall through to code-shadow
149
+ // probing when missing. This preserves the "missing.css must stay missing"
150
+ // guarantee while still allowing dotted module stems like ./route.server
151
+ // to resolve as ./route.server.ts.
152
+ const nonFallbackExtensions = new Set([
153
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
154
+ ".json", ".css", ".scss", ".sass", ".less", ".styl",
155
+ ".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".ico", ".bmp",
156
+ ".woff", ".woff2", ".ttf", ".otf", ".eot",
157
+ ]);
158
+ const runtimeFallbackExtensions = new Set([".js", ".jsx", ".mjs", ".cjs"]);
159
+ const dottedStemFallbackExtensions = new Set([".server", ".client", ".webhook"]);
160
+
161
+ if (
162
+ explicitExt !== "" &&
163
+ !runtimeFallbackExtensions.has(explicitExt) &&
164
+ !nonFallbackExtensions.has(explicitExt) &&
165
+ !dottedStemFallbackExtensions.has(explicitExt)
166
+ ) {
167
+ return { exists: false, resolvedPath: null };
168
+ }
169
+
170
+ if (nonFallbackExtensions.has(explicitExt) && !runtimeFallbackExtensions.has(explicitExt)) {
153
171
  return { exists: false, resolvedPath: null };
154
172
  }
155
173
  }
@@ -222,6 +240,13 @@ export function checkImportResolution(
222
240
  const imports = extractRelativeImports(source);
223
241
 
224
242
  for (const { importPath, lineNum } of imports) {
243
+ // React Router generated +types modules may not exist on disk during
244
+ // post-exec checks (generated during framework build). Don't block task
245
+ // completion on these imports.
246
+ if (/^\.{1,2}\/\+types\//.test(importPath)) {
247
+ continue;
248
+ }
249
+
225
250
  const resolution = resolveImportPath(importPath, file, basePath);
226
251
 
227
252
  if (!resolution.exists) {
@@ -154,8 +154,26 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
154
154
  "planning_depth",
155
155
  ]);
156
156
 
157
- /** Canonical list of all dispatch unit types. */
158
- export const KNOWN_UNIT_TYPES = [
157
+ /**
158
+ * Broad union of every recognized unit-type *label* used across the codebase.
159
+ *
160
+ * This intentionally covers more than the manifest-tracked dispatch units in
161
+ * `unit-context-manifest.ts:KNOWN_UNIT_TYPES`. Examples that live here but not
162
+ * in the manifest:
163
+ * - `discuss-slice` — dispatched by `guided-flow.ts` rather than auto-mode;
164
+ * composer falls through to default behavior via `resolveManifest()` null path.
165
+ * - `worktree-merge` — used as a model-routing case, prompt-template name, and
166
+ * commit-message label, not as an LLM-dispatched unit.
167
+ *
168
+ * Used by `preferences-validation.ts` to validate user-provided unit-type
169
+ * references in preferences (model overrides, skill rules, etc.) — preferences
170
+ * may legitimately reference any label, including non-dispatched ones.
171
+ *
172
+ * The manifest-strict subset lives in `unit-context-manifest.ts:KNOWN_UNIT_TYPES`
173
+ * and is enforced 1:1 against `UNIT_MANIFESTS` by the parity test in
174
+ * `tests/unit-context-manifest.test.ts`.
175
+ */
176
+ export const KNOWN_UNIT_LABELS = [
159
177
  "research-milestone", "plan-milestone", "research-slice", "plan-slice", "refine-slice",
160
178
  "execute-task", "reactive-execute", "gate-evaluate", "complete-slice", "replan-slice", "reassess-roadmap",
161
179
  "run-uat", "complete-milestone", "validate-milestone", "rewrite-docs",
@@ -164,7 +182,7 @@ export const KNOWN_UNIT_TYPES = [
164
182
  "workflow-preferences", "discuss-project", "discuss-requirements",
165
183
  "research-decision", "research-project",
166
184
  ] as const;
167
- export type UnitType = (typeof KNOWN_UNIT_TYPES)[number];
185
+ export type UnitLabel = (typeof KNOWN_UNIT_LABELS)[number];
168
186
 
169
187
 
170
188
  export const SKILL_ACTIONS = new Set(["use", "prefer", "avoid"]);
@@ -343,7 +361,8 @@ export interface GSDPreferences {
343
361
  /**
344
362
  * Tool-output sandboxing via gsd_exec. Keeps sub-session context windows
345
363
  * clean by running scripts in a subprocess and only surfacing a short
346
- * digest. See `ContextModeConfig`. Default: disabled.
364
+ * digest. See `ContextModeConfig`. Default: enabled unless explicitly
365
+ * disabled with `context_mode.enabled: false`.
347
366
  */
348
367
  context_mode?: ContextModeConfig;
349
368
  token_profile?: TokenProfile;
@@ -14,7 +14,7 @@ import { normalizeStringArray } from "../shared/format-utils.js";
14
14
 
15
15
  import {
16
16
  KNOWN_PREFERENCE_KEYS,
17
- KNOWN_UNIT_TYPES,
17
+ KNOWN_UNIT_LABELS,
18
18
 
19
19
  SKILL_ACTIONS,
20
20
  type WorkflowMode,
@@ -441,7 +441,7 @@ export function validatePreferences(preferences: GSDPreferences): {
441
441
  if (preferences.post_unit_hooks && Array.isArray(preferences.post_unit_hooks)) {
442
442
  const validHooks: PostUnitHookConfig[] = [];
443
443
  const seenNames = new Set<string>();
444
- const knownUnitTypes = new Set<string>(KNOWN_UNIT_TYPES);
444
+ const knownUnitTypes = new Set<string>(KNOWN_UNIT_LABELS);
445
445
  for (const hook of preferences.post_unit_hooks) {
446
446
  if (!hook || typeof hook !== "object") {
447
447
  errors.push("post_unit_hooks entry must be an object");
@@ -503,7 +503,7 @@ export function validatePreferences(preferences: GSDPreferences): {
503
503
  if (preferences.pre_dispatch_hooks && Array.isArray(preferences.pre_dispatch_hooks)) {
504
504
  const validPreHooks: PreDispatchHookConfig[] = [];
505
505
  const seenPreNames = new Set<string>();
506
- const knownUnitTypes = new Set<string>(KNOWN_UNIT_TYPES);
506
+ const knownUnitTypes = new Set<string>(KNOWN_UNIT_LABELS);
507
507
  const validActions = new Set(["modify", "skip", "replace"]);
508
508
  for (const hook of preferences.pre_dispatch_hooks) {
509
509
  if (!hook || typeof hook !== "object") {
@@ -0,0 +1,32 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { _buildAbortedPauseContext } from "../bootstrap/agent-end-recovery.js";
5
+ import { _buildCancelledUnitStopReason } from "../auto/phases.js";
6
+
7
+ test("aborted agent_end maps errorMessage into structured aborted pause context", () => {
8
+ const withMessage = _buildAbortedPauseContext({ errorMessage: "provider aborted request" });
9
+ assert.deepEqual(withMessage, {
10
+ message: "provider aborted request",
11
+ category: "aborted",
12
+ isTransient: true,
13
+ });
14
+
15
+ const withoutMessage = _buildAbortedPauseContext({});
16
+ assert.deepEqual(withoutMessage, {
17
+ message: "Operation aborted",
18
+ category: "aborted",
19
+ isTransient: true,
20
+ });
21
+ });
22
+
23
+ test("cancelled non-session failures are labeled as unit aborts (not session-creation failures)", () => {
24
+ const cancelled = _buildCancelledUnitStopReason("execute-task", "M001-S001-T001", {
25
+ category: "aborted",
26
+ message: "tool invocation cancelled",
27
+ });
28
+
29
+ assert.match(cancelled.notifyMessage, /aborted after dispatch/);
30
+ assert.equal(cancelled.stopReason, "Unit aborted: tool invocation cancelled");
31
+ assert.equal(cancelled.loopReason, "unit-aborted");
32
+ });
@@ -0,0 +1,353 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { createAutoOrchestrator } from "../auto/orchestrator.js";
5
+ import type { AutoOrchestratorDeps } from "../auto/contracts.js";
6
+
7
+ function makeDeps(overrides: Partial<AutoOrchestratorDeps> = {}): { deps: AutoOrchestratorDeps; calls: string[] } {
8
+ const calls: string[] = [];
9
+
10
+ const deps: AutoOrchestratorDeps = {
11
+ dispatch: {
12
+ async decideNextUnit() {
13
+ calls.push("dispatch.decide");
14
+ return { unitType: "execute-task", unitId: "T01", reason: "ready", preconditions: [] };
15
+ },
16
+ },
17
+ recovery: {
18
+ async classifyAndRecover() {
19
+ calls.push("recovery.classify");
20
+ return { action: "stop", reason: "fatal" };
21
+ },
22
+ },
23
+ worktree: {
24
+ async prepareForUnit() { calls.push("worktree.prepare"); },
25
+ async syncAfterUnit() { calls.push("worktree.sync"); },
26
+ async cleanupOnStop() { calls.push("worktree.cleanup"); },
27
+ },
28
+ health: {
29
+ async preAdvanceGate() {
30
+ calls.push("health.pre");
31
+ return { allow: true };
32
+ },
33
+ async postAdvanceRecord() { calls.push("health.post"); },
34
+ },
35
+ runtime: {
36
+ async ensureLockOwnership() { calls.push("runtime.lock"); },
37
+ async journalTransition(event) { calls.push(`journal:${event.name}`); },
38
+ },
39
+ notifications: {
40
+ async notifyLifecycle(event) { calls.push(`notify:${event.name}`); },
41
+ },
42
+ };
43
+
44
+ return { deps: { ...deps, ...overrides }, calls };
45
+ }
46
+
47
+ test("start() advances and records active unit", async () => {
48
+ const { deps, calls } = makeDeps();
49
+ const orchestrator = createAutoOrchestrator(deps);
50
+
51
+ const result = await orchestrator.start({ basePath: "/tmp/project", trigger: "manual" });
52
+
53
+ assert.equal(result.kind, "advanced");
54
+ const status = orchestrator.getStatus();
55
+ assert.equal(status.phase, "running");
56
+ assert.deepEqual(status.activeUnit, { unitType: "execute-task", unitId: "T01" });
57
+ assert.ok(calls.includes("journal:start"));
58
+ assert.ok(calls.includes("journal:advance"));
59
+ });
60
+
61
+ test("advance() returns blocked when health gate denies", async () => {
62
+ const { deps } = makeDeps({
63
+ health: {
64
+ async preAdvanceGate() { return { allow: false, reason: "doctor-block" }; },
65
+ async postAdvanceRecord() {},
66
+ },
67
+ });
68
+ const orchestrator = createAutoOrchestrator(deps);
69
+
70
+ const result = await orchestrator.advance();
71
+
72
+ assert.equal(result.kind, "blocked");
73
+ assert.equal(result.reason, "doctor-block");
74
+ });
75
+
76
+ test("advance() stops when dispatch has no next unit", async () => {
77
+ const { deps } = makeDeps({
78
+ dispatch: {
79
+ async decideNextUnit() { return null; },
80
+ },
81
+ });
82
+ const orchestrator = createAutoOrchestrator(deps);
83
+
84
+ const result = await orchestrator.advance();
85
+
86
+ assert.equal(result.kind, "stopped");
87
+ assert.equal(orchestrator.getStatus().phase, "stopped");
88
+ });
89
+
90
+ test("advance() uses recovery on error", async () => {
91
+ const { deps, calls } = makeDeps({
92
+ runtime: {
93
+ async ensureLockOwnership() { throw new Error("lock lost"); },
94
+ async journalTransition(event) { calls.push(`journal:${event.name}`); },
95
+ },
96
+ recovery: {
97
+ async classifyAndRecover() { return { action: "escalate", reason: "needs manual" }; },
98
+ },
99
+ });
100
+ const orchestrator = createAutoOrchestrator(deps);
101
+
102
+ const result = await orchestrator.advance();
103
+
104
+ assert.equal(result.kind, "error");
105
+ assert.equal(result.reason, "needs manual");
106
+ assert.equal(orchestrator.getStatus().phase, "error");
107
+ assert.ok(calls.includes("journal:advance-error"));
108
+ });
109
+
110
+ test("advance() is idempotent for the same active unit", async () => {
111
+ const { deps, calls } = makeDeps();
112
+ const orchestrator = createAutoOrchestrator(deps);
113
+
114
+ const first = await orchestrator.advance();
115
+ const second = await orchestrator.advance();
116
+
117
+ assert.equal(first.kind, "advanced");
118
+ assert.equal(second.kind, "blocked");
119
+ assert.equal(second.reason, "idempotent advance: unit already active");
120
+
121
+ const prepareCalls = calls.filter((c) => c === "worktree.prepare").length;
122
+ assert.equal(prepareCalls, 1);
123
+ });
124
+
125
+ test("resume() re-enters running flow via advance", async () => {
126
+ const { deps } = makeDeps();
127
+ const orchestrator = createAutoOrchestrator(deps);
128
+
129
+ const result = await orchestrator.resume();
130
+
131
+ assert.equal(result.kind, "advanced");
132
+ assert.equal(orchestrator.getStatus().phase, "running");
133
+ });
134
+
135
+ test("resume() clears idempotent lock and allows re-advance", async () => {
136
+ const { deps } = makeDeps();
137
+ const orchestrator = createAutoOrchestrator(deps);
138
+
139
+ const first = await orchestrator.advance();
140
+ const blocked = await orchestrator.advance();
141
+ const resumed = await orchestrator.resume();
142
+
143
+ assert.equal(first.kind, "advanced");
144
+ assert.equal(blocked.kind, "blocked");
145
+ assert.equal(resumed.kind, "advanced");
146
+ });
147
+
148
+ test("transitionCount increases across lifecycle transitions", async () => {
149
+ const { deps } = makeDeps();
150
+ const orchestrator = createAutoOrchestrator(deps);
151
+
152
+ const before = orchestrator.getStatus().transitionCount;
153
+ await orchestrator.start({ basePath: "/tmp/project", trigger: "manual" });
154
+ const afterStart = orchestrator.getStatus().transitionCount;
155
+ await orchestrator.stop("done");
156
+ const afterStop = orchestrator.getStatus().transitionCount;
157
+
158
+ assert.ok(afterStart > before);
159
+ assert.ok(afterStop > afterStart);
160
+ });
161
+
162
+ test("stop() clears idempotent unit lock so advance can run again", async () => {
163
+ const { deps } = makeDeps();
164
+ const orchestrator = createAutoOrchestrator(deps);
165
+
166
+ const first = await orchestrator.advance();
167
+ const blocked = await orchestrator.advance();
168
+ const stopped = await orchestrator.stop("reset");
169
+ const second = await orchestrator.advance();
170
+
171
+ assert.equal(first.kind, "advanced");
172
+ assert.equal(blocked.kind, "blocked");
173
+ assert.equal(stopped.kind, "stopped");
174
+ assert.equal(second.kind, "advanced");
175
+ });
176
+
177
+ test("advance() stopped clears previous activeUnit", async () => {
178
+ let first = true;
179
+ const { deps } = makeDeps({
180
+ dispatch: {
181
+ async decideNextUnit() {
182
+ if (first) {
183
+ first = false;
184
+ return { unitType: "execute-task", unitId: "T01", reason: "ready", preconditions: [] };
185
+ }
186
+ return null;
187
+ },
188
+ },
189
+ });
190
+ const orchestrator = createAutoOrchestrator(deps);
191
+
192
+ await orchestrator.advance();
193
+ const stopped = await orchestrator.advance();
194
+
195
+ assert.equal(stopped.kind, "stopped");
196
+ assert.equal(orchestrator.getStatus().activeUnit, undefined);
197
+ });
198
+
199
+ test("recovery stop clears activeUnit", async () => {
200
+ const { deps, calls } = makeDeps({
201
+ runtime: {
202
+ async ensureLockOwnership() { throw new Error("boom"); },
203
+ async journalTransition(event) { calls.push(`journal:${event.name}`); },
204
+ },
205
+ recovery: {
206
+ async classifyAndRecover() { return { action: "stop", reason: "fatal" }; },
207
+ },
208
+ });
209
+ const orchestrator = createAutoOrchestrator(deps);
210
+
211
+ const result = await orchestrator.advance();
212
+
213
+ assert.equal(result.kind, "stopped");
214
+ assert.equal(orchestrator.getStatus().activeUnit, undefined);
215
+ assert.ok(calls.includes("journal:advance-stopped"));
216
+ assert.ok(calls.includes("notify:stopped"));
217
+ assert.ok(!calls.includes("notify:error"));
218
+ });
219
+
220
+ test("recovery retry maps to paused result", async () => {
221
+ const { deps, calls } = makeDeps({
222
+ runtime: {
223
+ async ensureLockOwnership() { throw new Error("boom"); },
224
+ async journalTransition(event) { calls.push(`journal:${event.name}`); },
225
+ },
226
+ recovery: {
227
+ async classifyAndRecover() { return { action: "retry", reason: "transient" }; },
228
+ },
229
+ });
230
+ const orchestrator = createAutoOrchestrator(deps);
231
+
232
+ const result = await orchestrator.advance();
233
+
234
+ assert.equal(result.kind, "paused");
235
+ assert.equal(result.reason, "transient");
236
+ assert.equal(orchestrator.getStatus().phase, "paused");
237
+ assert.ok(calls.includes("journal:advance-paused"));
238
+ assert.ok(calls.includes("notify:pause"));
239
+ });
240
+
241
+ test("getStatus() returns defensive copy of activeUnit", async () => {
242
+ const { deps } = makeDeps();
243
+ const orchestrator = createAutoOrchestrator(deps);
244
+
245
+ await orchestrator.advance();
246
+ const snap1 = orchestrator.getStatus();
247
+ if (snap1.activeUnit) snap1.activeUnit.unitId = "MUTATED";
248
+ const snap2 = orchestrator.getStatus();
249
+
250
+ assert.equal(snap2.activeUnit?.unitId, "T01");
251
+ });
252
+
253
+ test("start() clears prior idempotent lock", async () => {
254
+ const { deps } = makeDeps();
255
+ const orchestrator = createAutoOrchestrator(deps);
256
+
257
+ await orchestrator.advance();
258
+ const blocked = await orchestrator.advance();
259
+ const restarted = await orchestrator.start({ basePath: "/tmp/project", trigger: "manual" });
260
+
261
+ assert.equal(blocked.kind, "blocked");
262
+ assert.equal(restarted.kind, "advanced");
263
+ });
264
+
265
+ test("error path emits error notification", async () => {
266
+ const { deps, calls } = makeDeps({
267
+ runtime: {
268
+ async ensureLockOwnership() { throw new Error("boom"); },
269
+ async journalTransition(event) { calls.push(`journal:${event.name}`); },
270
+ },
271
+ recovery: {
272
+ async classifyAndRecover() { return { action: "escalate", reason: "needs manual" }; },
273
+ },
274
+ });
275
+ const orchestrator = createAutoOrchestrator(deps);
276
+
277
+ await orchestrator.advance();
278
+
279
+ assert.ok(calls.includes("notify:error"));
280
+ });
281
+
282
+ test("blocked path journals advance-blocked", async () => {
283
+ const { deps, calls } = makeDeps();
284
+ const orchestrator = createAutoOrchestrator(deps);
285
+
286
+ await orchestrator.advance();
287
+ await orchestrator.advance();
288
+
289
+ assert.ok(calls.includes("journal:advance-blocked"));
290
+ });
291
+
292
+ test("health post hook runs on blocked result", async () => {
293
+ const { deps, calls } = makeDeps();
294
+ const orchestrator = createAutoOrchestrator(deps);
295
+
296
+ await orchestrator.advance();
297
+ await orchestrator.advance();
298
+
299
+ assert.ok(calls.includes("health.post"));
300
+ });
301
+
302
+ test("start() emits start notification", async () => {
303
+ const { deps, calls } = makeDeps();
304
+ const orchestrator = createAutoOrchestrator(deps);
305
+
306
+ await orchestrator.start({ basePath: "/tmp/project", trigger: "manual" });
307
+
308
+ assert.ok(calls.includes("notify:start"));
309
+ });
310
+
311
+ test("resume() emits resume notification", async () => {
312
+ const { deps, calls } = makeDeps();
313
+ const orchestrator = createAutoOrchestrator(deps);
314
+
315
+ await orchestrator.resume();
316
+
317
+ assert.ok(calls.includes("notify:resume"));
318
+ });
319
+
320
+ test("stopped with no remaining units clears idempotent lock for next advance", async () => {
321
+ let callCount = 0;
322
+ const { deps } = makeDeps({
323
+ dispatch: {
324
+ async decideNextUnit() {
325
+ callCount += 1;
326
+ if (callCount === 2) return null;
327
+ return { unitType: "execute-task", unitId: "T01", reason: "ready", preconditions: [] };
328
+ },
329
+ },
330
+ });
331
+ const orchestrator = createAutoOrchestrator(deps);
332
+
333
+ const first = await orchestrator.advance();
334
+ const stopped = await orchestrator.advance();
335
+ const after = await orchestrator.advance();
336
+
337
+ assert.equal(first.kind, "advanced");
338
+ assert.equal(stopped.kind, "stopped");
339
+ assert.equal(after.kind, "advanced");
340
+ });
341
+
342
+ test("stop() cleans up worktree and transitions to stopped", async () => {
343
+ const { deps, calls } = makeDeps();
344
+ const orchestrator = createAutoOrchestrator(deps);
345
+
346
+ const result = await orchestrator.stop("user-request");
347
+
348
+ assert.equal(result.kind, "stopped");
349
+ assert.equal(orchestrator.getStatus().phase, "stopped");
350
+ assert.ok(calls.includes("worktree.cleanup"));
351
+ assert.ok(calls.includes("journal:stop"));
352
+ assert.ok(calls.includes("notify:stop"));
353
+ });