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.
- package/README.md +94 -47
- package/dist/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/gsd/auto/contracts.js +1 -0
- package/dist/resources/extensions/gsd/auto/orchestrator.js +146 -0
- package/dist/resources/extensions/gsd/auto/phases.js +61 -7
- package/dist/resources/extensions/gsd/auto/session.js +8 -0
- package/dist/resources/extensions/gsd/auto-artifact-paths.js +2 -2
- package/dist/resources/extensions/gsd/auto-dispatch.js +2 -0
- package/dist/resources/extensions/gsd/auto-prompts.js +52 -29
- package/dist/resources/extensions/gsd/auto-recovery.js +63 -55
- package/dist/resources/extensions/gsd/auto-runtime-state.js +4 -0
- package/dist/resources/extensions/gsd/auto-start.js +3 -2
- package/dist/resources/extensions/gsd/auto.js +159 -2
- package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +9 -1
- package/dist/resources/extensions/gsd/bootstrap/exec-tools.js +2 -2
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +41 -45
- package/dist/resources/extensions/gsd/bootstrap/write-gate.js +8 -8
- package/dist/resources/extensions/gsd/commands/context.js +1 -1
- package/dist/resources/extensions/gsd/gsd-db.js +34 -1
- package/dist/resources/extensions/gsd/guided-flow.js +40 -0
- package/dist/resources/extensions/gsd/paths.js +5 -1
- package/dist/resources/extensions/gsd/post-execution-checks.js +25 -6
- package/dist/resources/extensions/gsd/preferences-types.js +20 -2
- package/dist/resources/extensions/gsd/preferences-validation.js +3 -3
- package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +82 -2
- package/dist/resources/extensions/gsd/unit-context-composer.js +32 -0
- package/dist/resources/extensions/gsd/unit-context-manifest.js +21 -0
- package/dist/resources/extensions/gsd/uok/audit.js +23 -9
- package/dist/resources/extensions/gsd/uok/contracts.js +69 -1
- package/dist/resources/extensions/gsd/uok/dispatch-envelope.js +3 -0
- package/dist/resources/extensions/gsd/uok/loop-adapter.js +48 -33
- package/dist/resources/extensions/gsd/uok/timeline.js +125 -0
- package/dist/resources/extensions/shared/gsd-phase-state.js +45 -3
- package/dist/resources/extensions/shared/interview-ui.js +15 -4
- package/dist/tsconfig.extensions.tsbuildinfo +1 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +9 -9
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +9 -9
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/package.json +1 -1
- package/packages/daemon/package.json +2 -2
- package/packages/mcp-server/dist/workflow-tools.d.ts +1 -1
- package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
- package/packages/mcp-server/dist/workflow-tools.js +53 -0
- package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
- package/packages/mcp-server/package.json +2 -2
- package/packages/mcp-server/src/workflow-tools.test.ts +129 -2
- package/packages/mcp-server/src/workflow-tools.ts +81 -0
- package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
- package/packages/native/package.json +1 -1
- package/packages/pi-agent-core/package.json +1 -1
- package/packages/pi-ai/package.json +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/packages/pi-tui/package.json +1 -1
- package/packages/rpc-client/package.json +1 -1
- package/pkg/package.json +1 -1
- package/src/resources/extensions/gsd/auto/contracts.ts +87 -0
- package/src/resources/extensions/gsd/auto/loop-deps.ts +10 -3
- package/src/resources/extensions/gsd/auto/orchestrator.ts +161 -0
- package/src/resources/extensions/gsd/auto/phases.ts +88 -9
- package/src/resources/extensions/gsd/auto/session.ts +11 -0
- package/src/resources/extensions/gsd/auto-artifact-paths.ts +2 -2
- package/src/resources/extensions/gsd/auto-dispatch.ts +1 -0
- package/src/resources/extensions/gsd/auto-prompts.ts +106 -28
- package/src/resources/extensions/gsd/auto-recovery.ts +59 -53
- package/src/resources/extensions/gsd/auto-runtime-state.ts +7 -0
- package/src/resources/extensions/gsd/auto-start.ts +3 -2
- package/src/resources/extensions/gsd/auto.ts +167 -1
- package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +14 -1
- package/src/resources/extensions/gsd/bootstrap/exec-tools.ts +2 -2
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +49 -46
- package/src/resources/extensions/gsd/bootstrap/tests/write-gate-shouldblock-basepath.test.ts +97 -0
- package/src/resources/extensions/gsd/bootstrap/write-gate.ts +8 -4
- package/src/resources/extensions/gsd/commands/context.ts +1 -1
- package/src/resources/extensions/gsd/gsd-db.ts +35 -1
- package/src/resources/extensions/gsd/guided-flow.ts +47 -0
- package/src/resources/extensions/gsd/interrupted-session.ts +1 -0
- package/src/resources/extensions/gsd/paths.ts +6 -1
- package/src/resources/extensions/gsd/post-execution-checks.ts +31 -6
- package/src/resources/extensions/gsd/preferences-types.ts +23 -4
- package/src/resources/extensions/gsd/preferences-validation.ts +3 -3
- package/src/resources/extensions/gsd/tests/auto-abort-pause-regression.test.ts +32 -0
- package/src/resources/extensions/gsd/tests/auto-orchestrator.test.ts +353 -0
- package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +108 -1
- package/src/resources/extensions/gsd/tests/auto-runtime-state.test.ts +39 -0
- package/src/resources/extensions/gsd/tests/auto-session-encapsulation.test.ts +3 -0
- package/src/resources/extensions/gsd/tests/bootstrap-derive-state-db-open.test.ts +2 -2
- package/src/resources/extensions/gsd/tests/check-auto-start-pending-gate.test.ts +203 -0
- package/src/resources/extensions/gsd/tests/check-auto-start-ready-guard.test.ts +148 -0
- package/src/resources/extensions/gsd/tests/current-directory-root-homedir-fallback.test.ts +63 -0
- package/src/resources/extensions/gsd/tests/deep-planning-mode-dispatch.test.ts +42 -0
- package/src/resources/extensions/gsd/tests/deep-project-auto-loop.test.ts +63 -2
- package/src/resources/extensions/gsd/tests/execute-summary-save-empty-project.test.ts +109 -0
- package/src/resources/extensions/gsd/tests/gsd-db.test.ts +95 -0
- package/src/resources/extensions/gsd/tests/guided-flow-prompt-consolidation.test.ts +14 -0
- package/src/resources/extensions/gsd/tests/integration/auto-recovery.test.ts +79 -0
- package/src/resources/extensions/gsd/tests/journal-integration.test.ts +134 -0
- package/src/resources/extensions/gsd/tests/parallel-skill-prompt-integration.test.ts +8 -0
- package/src/resources/extensions/gsd/tests/paused-session-via-db.test.ts +2 -0
- package/src/resources/extensions/gsd/tests/plan-slice.test.ts +27 -0
- package/src/resources/extensions/gsd/tests/post-execution-checks.test.ts +46 -0
- package/src/resources/extensions/gsd/tests/pre-exec-gate-loop.test.ts +3 -0
- package/src/resources/extensions/gsd/tests/register-hooks-compaction-checkpoint.test.ts +85 -0
- package/src/resources/extensions/gsd/tests/run-uat-composer.test.ts +2 -0
- package/src/resources/extensions/gsd/tests/subagent-model-dispatch.test.ts +59 -0
- package/src/resources/extensions/gsd/tests/unit-context-composer.test.ts +38 -0
- package/src/resources/extensions/gsd/tests/unit-context-manifest.test.ts +32 -0
- package/src/resources/extensions/gsd/tests/uok-contracts.test.ts +109 -1
- package/src/resources/extensions/gsd/tests/uok-loop-adapter-writer.test.ts +98 -0
- package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +132 -3
- package/src/resources/extensions/gsd/tests/worktree-path-injection.test.ts +3 -0
- package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +84 -1
- package/src/resources/extensions/gsd/unit-context-composer.ts +49 -0
- package/src/resources/extensions/gsd/unit-context-manifest.ts +34 -0
- package/src/resources/extensions/gsd/uok/audit.ts +25 -9
- package/src/resources/extensions/gsd/uok/contracts.ts +105 -0
- package/src/resources/extensions/gsd/uok/dispatch-envelope.ts +4 -0
- package/src/resources/extensions/gsd/uok/loop-adapter.ts +60 -45
- package/src/resources/extensions/gsd/uok/timeline.ts +158 -0
- package/src/resources/extensions/shared/gsd-phase-state.ts +56 -3
- package/src/resources/extensions/shared/interview-ui.ts +18 -5
- package/src/resources/extensions/shared/tests/gsd-phase-state.test.ts +43 -1
- package/src/resources/extensions/shared/tests/interview-notes-loop.test.ts +41 -0
- /package/dist/web/standalone/.next/static/{J-CU-p_sp45CJHT3R9TJS → V-3Ehy4B24f9FCGiLPWIM}/_buildManifest.js +0 -0
- /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 });
|
|
@@ -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(
|
|
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
|
-
|
|
148
|
-
//
|
|
149
|
-
// .
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
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
|
-
/**
|
|
158
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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>(
|
|
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>(
|
|
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
|
+
});
|