gsd-pi 2.11.0 → 2.13.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/dist/cli.js +18 -1
- package/dist/onboarding.js +3 -0
- package/dist/resource-loader.d.ts +2 -0
- package/dist/resource-loader.js +36 -1
- package/dist/resources/extensions/bg-shell/index.ts +51 -7
- package/dist/resources/extensions/gsd/auto-worktree.ts +509 -0
- package/dist/resources/extensions/gsd/auto.ts +381 -13
- package/dist/resources/extensions/gsd/commands.ts +9 -3
- package/dist/resources/extensions/gsd/doctor.ts +254 -3
- package/dist/resources/extensions/gsd/git-self-heal.ts +198 -0
- package/dist/resources/extensions/gsd/git-service.ts +11 -0
- package/dist/resources/extensions/gsd/guided-flow.ts +81 -9
- package/dist/resources/extensions/gsd/post-unit-hooks.ts +449 -0
- package/dist/resources/extensions/gsd/preferences.ts +209 -1
- package/dist/resources/extensions/gsd/prompt-loader.ts +28 -1
- package/dist/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
- package/dist/resources/extensions/gsd/prompts/complete-slice.md +1 -3
- package/dist/resources/extensions/gsd/prompts/discuss.md +10 -8
- package/dist/resources/extensions/gsd/prompts/execute-task.md +4 -2
- package/dist/resources/extensions/gsd/prompts/guided-complete-slice.md +3 -1
- package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -1
- package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
- package/dist/resources/extensions/gsd/prompts/guided-execute-task.md +3 -1
- package/dist/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -2
- package/dist/resources/extensions/gsd/prompts/guided-plan-slice.md +3 -1
- package/dist/resources/extensions/gsd/prompts/guided-research-slice.md +3 -1
- package/dist/resources/extensions/gsd/prompts/plan-milestone.md +9 -12
- package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -3
- package/dist/resources/extensions/gsd/prompts/queue.md +3 -1
- package/dist/resources/extensions/gsd/prompts/research-milestone.md +1 -1
- package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
- package/dist/resources/extensions/gsd/prompts/system.md +32 -29
- package/dist/resources/extensions/gsd/templates/context.md +1 -1
- package/dist/resources/extensions/gsd/templates/state.md +3 -3
- package/dist/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
- package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
- package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
- package/dist/resources/extensions/gsd/tests/doctor-git.test.ts +246 -0
- package/dist/resources/extensions/gsd/tests/doctor.test.ts +115 -1
- package/dist/resources/extensions/gsd/tests/git-self-heal.test.ts +234 -0
- package/dist/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
- package/dist/resources/extensions/gsd/tests/post-unit-hooks.test.ts +297 -0
- package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
- package/dist/resources/extensions/gsd/tests/preferences-hooks.test.ts +226 -0
- package/dist/resources/extensions/gsd/tests/regex-hardening.test.ts +12 -0
- package/dist/resources/extensions/gsd/tests/worktree-e2e.test.ts +315 -0
- package/dist/resources/extensions/gsd/types.ts +109 -0
- package/dist/resources/extensions/gsd/worktree-manager.ts +6 -4
- package/dist/resources/extensions/search-the-web/command-search-provider.ts +8 -4
- package/dist/resources/extensions/search-the-web/native-search.ts +15 -10
- package/dist/resources/extensions/search-the-web/provider.ts +19 -2
- package/dist/resources/extensions/search-the-web/tool-fetch-page.ts +62 -0
- package/dist/resources/extensions/search-the-web/tool-llm-context.ts +62 -3
- package/dist/resources/extensions/search-the-web/tool-search.ts +62 -3
- package/dist/wizard.js +1 -0
- package/package.json +1 -1
- package/packages/pi-agent-core/dist/agent-loop.d.ts.map +1 -1
- package/packages/pi-agent-core/dist/agent-loop.js +169 -55
- package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
- package/packages/pi-agent-core/dist/agent.d.ts +13 -1
- package/packages/pi-agent-core/dist/agent.d.ts.map +1 -1
- package/packages/pi-agent-core/dist/agent.js +16 -0
- package/packages/pi-agent-core/dist/agent.js.map +1 -1
- package/packages/pi-agent-core/dist/types.d.ts +91 -1
- package/packages/pi-agent-core/dist/types.d.ts.map +1 -1
- package/packages/pi-agent-core/dist/types.js.map +1 -1
- package/packages/pi-agent-core/src/agent-loop.ts +273 -63
- package/packages/pi-agent-core/src/agent.ts +24 -0
- package/packages/pi-agent-core/src/types.ts +98 -0
- package/packages/pi-ai/dist/env-api-keys.js +1 -0
- package/packages/pi-ai/dist/env-api-keys.js.map +1 -1
- package/packages/pi-ai/dist/models.generated.d.ts +314 -0
- package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
- package/packages/pi-ai/dist/models.generated.js +236 -0
- package/packages/pi-ai/dist/models.generated.js.map +1 -1
- package/packages/pi-ai/dist/types.d.ts +1 -1
- package/packages/pi-ai/dist/types.d.ts.map +1 -1
- package/packages/pi-ai/dist/types.js.map +1 -1
- package/packages/pi-ai/src/env-api-keys.ts +1 -0
- package/packages/pi-ai/src/models.generated.ts +236 -0
- package/packages/pi-ai/src/types.ts +2 -1
- package/packages/pi-coding-agent/dist/cli/args.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/cli/args.js +2 -1
- package/packages/pi-coding-agent/dist/cli/args.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.d.ts +10 -0
- package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.js +69 -8
- package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +4 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.js +2 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +5 -0
- package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-resolver.js +1 -0
- package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/sdk.js +3 -3
- package/packages/pi-coding-agent/dist/core/sdk.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/system-prompt.js +6 -0
- package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
- package/packages/pi-coding-agent/src/cli/args.ts +2 -1
- package/packages/pi-coding-agent/src/core/agent-session.ts +76 -7
- package/packages/pi-coding-agent/src/core/extensions/runner.ts +2 -1
- package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -0
- package/packages/pi-coding-agent/src/core/model-resolver.ts +1 -0
- package/packages/pi-coding-agent/src/core/sdk.ts +3 -3
- package/packages/pi-coding-agent/src/core/system-prompt.ts +9 -0
- package/packages/pi-tui/dist/components/editor.d.ts +11 -0
- package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
- package/packages/pi-tui/dist/components/editor.js +64 -6
- package/packages/pi-tui/dist/components/editor.js.map +1 -1
- package/packages/pi-tui/src/components/editor.ts +71 -6
- package/src/resources/extensions/bg-shell/index.ts +51 -7
- package/src/resources/extensions/gsd/auto-worktree.ts +509 -0
- package/src/resources/extensions/gsd/auto.ts +381 -13
- package/src/resources/extensions/gsd/commands.ts +9 -3
- package/src/resources/extensions/gsd/doctor.ts +254 -3
- package/src/resources/extensions/gsd/git-self-heal.ts +198 -0
- package/src/resources/extensions/gsd/git-service.ts +11 -0
- package/src/resources/extensions/gsd/guided-flow.ts +81 -9
- package/src/resources/extensions/gsd/post-unit-hooks.ts +449 -0
- package/src/resources/extensions/gsd/preferences.ts +209 -1
- package/src/resources/extensions/gsd/prompt-loader.ts +28 -1
- package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
- package/src/resources/extensions/gsd/prompts/complete-slice.md +1 -3
- package/src/resources/extensions/gsd/prompts/discuss.md +10 -8
- package/src/resources/extensions/gsd/prompts/execute-task.md +4 -2
- package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-execute-task.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -2
- package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-research-slice.md +3 -1
- package/src/resources/extensions/gsd/prompts/plan-milestone.md +9 -12
- package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -3
- package/src/resources/extensions/gsd/prompts/queue.md +3 -1
- package/src/resources/extensions/gsd/prompts/research-milestone.md +1 -1
- package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
- package/src/resources/extensions/gsd/prompts/system.md +32 -29
- package/src/resources/extensions/gsd/templates/context.md +1 -1
- package/src/resources/extensions/gsd/templates/state.md +3 -3
- package/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
- package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
- package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
- package/src/resources/extensions/gsd/tests/doctor-git.test.ts +246 -0
- package/src/resources/extensions/gsd/tests/doctor.test.ts +115 -1
- package/src/resources/extensions/gsd/tests/git-self-heal.test.ts +234 -0
- package/src/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
- package/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts +297 -0
- package/src/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
- package/src/resources/extensions/gsd/tests/preferences-hooks.test.ts +226 -0
- package/src/resources/extensions/gsd/tests/regex-hardening.test.ts +12 -0
- package/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +315 -0
- package/src/resources/extensions/gsd/types.ts +109 -0
- package/src/resources/extensions/gsd/worktree-manager.ts +6 -4
- package/src/resources/extensions/search-the-web/command-search-provider.ts +8 -4
- package/src/resources/extensions/search-the-web/native-search.ts +15 -10
- package/src/resources/extensions/search-the-web/provider.ts +19 -2
- package/src/resources/extensions/search-the-web/tool-fetch-page.ts +62 -0
- package/src/resources/extensions/search-the-web/tool-llm-context.ts +62 -3
- package/src/resources/extensions/search-the-web/tool-search.ts +62 -3
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@gsd/pi-coding-agent";
|
|
10
10
|
import { showNextAction } from "../shared/next-action-ui.js";
|
|
11
11
|
import { loadFile, parseRoadmap } from "./files.js";
|
|
12
|
-
import { loadPrompt } from "./prompt-loader.js";
|
|
12
|
+
import { loadPrompt, inlineTemplate } from "./prompt-loader.js";
|
|
13
13
|
import { deriveState } from "./state.js";
|
|
14
14
|
import { startAuto } from "./auto.js";
|
|
15
15
|
import { readCrashLock, clearLock, formatCrashInfo } from "./crash-recovery.js";
|
|
16
|
+
import { listUnitRuntimeRecords, clearUnitRuntimeRecord } from "./unit-runtime.js";
|
|
17
|
+
import { resolveExpectedArtifactPath } from "./auto.js";
|
|
16
18
|
import {
|
|
17
19
|
gsdRoot, milestonesDir, resolveMilestoneFile, resolveMilestonePath,
|
|
18
20
|
resolveSliceFile, resolveSlicePath, resolveGsdRootFile, relGsdRootFile,
|
|
@@ -97,11 +99,19 @@ function dispatchWorkflow(pi: ExtensionAPI, note: string, customType = "gsd-run"
|
|
|
97
99
|
*/
|
|
98
100
|
function buildDiscussPrompt(nextId: string, preamble: string, _basePath: string): string {
|
|
99
101
|
const milestoneRel = `.gsd/milestones/${nextId}`;
|
|
102
|
+
const inlinedTemplates = [
|
|
103
|
+
inlineTemplate("project", "Project"),
|
|
104
|
+
inlineTemplate("requirements", "Requirements"),
|
|
105
|
+
inlineTemplate("context", "Context"),
|
|
106
|
+
inlineTemplate("roadmap", "Roadmap"),
|
|
107
|
+
inlineTemplate("decisions", "Decisions"),
|
|
108
|
+
].join("\n\n---\n\n");
|
|
100
109
|
return loadPrompt("discuss", {
|
|
101
110
|
milestoneId: nextId,
|
|
102
111
|
preamble,
|
|
103
112
|
contextPath: `${milestoneRel}/${nextId}-CONTEXT.md`,
|
|
104
113
|
roadmapPath: `${milestoneRel}/${nextId}-ROADMAP.md`,
|
|
114
|
+
inlinedTemplates,
|
|
105
115
|
});
|
|
106
116
|
}
|
|
107
117
|
|
|
@@ -234,11 +244,13 @@ export async function showQueue(
|
|
|
234
244
|
].join(" ");
|
|
235
245
|
|
|
236
246
|
// ── Dispatch the queue prompt ───────────────────────────────────────
|
|
247
|
+
const queueInlinedTemplates = inlineTemplate("context", "Context");
|
|
237
248
|
const prompt = loadPrompt("queue", {
|
|
238
249
|
preamble,
|
|
239
250
|
nextId,
|
|
240
251
|
nextIdPlus1,
|
|
241
252
|
existingMilestonesContext: existingContext,
|
|
253
|
+
inlinedTemplates: queueInlinedTemplates,
|
|
242
254
|
});
|
|
243
255
|
|
|
244
256
|
pi.sendMessage(
|
|
@@ -415,6 +427,7 @@ async function buildDiscussSlicePrompt(
|
|
|
415
427
|
const sliceDirPath = `.gsd/milestones/${mid}/slices/${sid}`;
|
|
416
428
|
const sliceContextPath = `${sliceDirPath}/${sid}-CONTEXT.md`;
|
|
417
429
|
|
|
430
|
+
const inlinedTemplates = inlineTemplate("slice-context", "Slice Context");
|
|
418
431
|
return loadPrompt("guided-discuss-slice", {
|
|
419
432
|
milestoneId: mid,
|
|
420
433
|
sliceId: sid,
|
|
@@ -423,6 +436,7 @@ async function buildDiscussSlicePrompt(
|
|
|
423
436
|
sliceDirPath,
|
|
424
437
|
contextPath: sliceContextPath,
|
|
425
438
|
projectRoot: base,
|
|
439
|
+
inlinedTemplates,
|
|
426
440
|
});
|
|
427
441
|
}
|
|
428
442
|
|
|
@@ -506,6 +520,42 @@ export async function showDiscuss(
|
|
|
506
520
|
/**
|
|
507
521
|
* The one wizard. Reads state, shows contextual options, dispatches into the workflow doc.
|
|
508
522
|
*/
|
|
523
|
+
/**
|
|
524
|
+
* Self-heal: scan runtime records and clear stale ones left behind when
|
|
525
|
+
* auto-mode crashed mid-unit. auto.ts has its own selfHealRuntimeRecords()
|
|
526
|
+
* but guided-flow (manual /gsd mode) never called it — meaning stale records
|
|
527
|
+
* persisted until the next /gsd auto run. This ensures the wizard always
|
|
528
|
+
* starts from a clean state regardless of how the previous session ended.
|
|
529
|
+
*/
|
|
530
|
+
function selfHealRuntimeRecords(basePath: string, ctx: ExtensionContext): { cleared: number } {
|
|
531
|
+
try {
|
|
532
|
+
const records = listUnitRuntimeRecords(basePath);
|
|
533
|
+
let cleared = 0;
|
|
534
|
+
for (const record of records) {
|
|
535
|
+
const { unitType, unitId, phase } = record;
|
|
536
|
+
// Clear records whose expected artifact already exists (completed but not cleaned up)
|
|
537
|
+
const artifactPath = resolveExpectedArtifactPath(unitType, unitId, basePath);
|
|
538
|
+
if (artifactPath && existsSync(artifactPath)) {
|
|
539
|
+
clearUnitRuntimeRecord(basePath, unitType, unitId);
|
|
540
|
+
cleared++;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
// Clear records stuck in dispatched or timeout phase (process died mid-unit)
|
|
544
|
+
if (phase === "dispatched" || phase === "timeout") {
|
|
545
|
+
clearUnitRuntimeRecord(basePath, unitType, unitId);
|
|
546
|
+
cleared++;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (cleared > 0) {
|
|
550
|
+
ctx.ui.notify(`Self-heal: cleared ${cleared} stale runtime record(s) from a previous session.`, "info");
|
|
551
|
+
}
|
|
552
|
+
return { cleared };
|
|
553
|
+
} catch {
|
|
554
|
+
// Non-fatal — self-heal should never block the wizard
|
|
555
|
+
return { cleared: 0 };
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
509
559
|
export async function showSmartEntry(
|
|
510
560
|
ctx: ExtensionCommandContext,
|
|
511
561
|
pi: ExtensionAPI,
|
|
@@ -544,6 +594,9 @@ export async function showSmartEntry(
|
|
|
544
594
|
}
|
|
545
595
|
}
|
|
546
596
|
|
|
597
|
+
// ── Self-heal stale runtime records from crashed auto-mode sessions ──
|
|
598
|
+
selfHealRuntimeRecords(basePath, ctx);
|
|
599
|
+
|
|
547
600
|
// Check for crash from previous auto-mode session
|
|
548
601
|
const crashLock = readCrashLock(basePath);
|
|
549
602
|
if (crashLock) {
|
|
@@ -683,8 +736,9 @@ export async function showSmartEntry(
|
|
|
683
736
|
});
|
|
684
737
|
|
|
685
738
|
if (choice === "discuss_draft") {
|
|
739
|
+
const discussMilestoneTemplates = inlineTemplate("context", "Context");
|
|
686
740
|
const basePrompt = loadPrompt("guided-discuss-milestone", {
|
|
687
|
-
milestoneId, milestoneTitle,
|
|
741
|
+
milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates,
|
|
688
742
|
});
|
|
689
743
|
const seed = draftContent
|
|
690
744
|
? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}`
|
|
@@ -692,9 +746,10 @@ export async function showSmartEntry(
|
|
|
692
746
|
pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode };
|
|
693
747
|
dispatchWorkflow(pi, seed, "gsd-discuss");
|
|
694
748
|
} else if (choice === "discuss_fresh") {
|
|
749
|
+
const discussMilestoneTemplates = inlineTemplate("context", "Context");
|
|
695
750
|
pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode };
|
|
696
751
|
dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
|
|
697
|
-
milestoneId, milestoneTitle,
|
|
752
|
+
milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates,
|
|
698
753
|
}), "gsd-discuss");
|
|
699
754
|
} else if (choice === "skip_milestone") {
|
|
700
755
|
const milestoneIds = findMilestoneIds(basePath);
|
|
@@ -753,13 +808,20 @@ export async function showSmartEntry(
|
|
|
753
808
|
});
|
|
754
809
|
|
|
755
810
|
if (choice === "plan") {
|
|
811
|
+
const planMilestoneTemplates = [
|
|
812
|
+
inlineTemplate("roadmap", "Roadmap"),
|
|
813
|
+
inlineTemplate("plan", "Slice Plan"),
|
|
814
|
+
inlineTemplate("task-plan", "Task Plan"),
|
|
815
|
+
inlineTemplate("secrets-manifest", "Secrets Manifest"),
|
|
816
|
+
].join("\n\n---\n\n");
|
|
756
817
|
const secretsOutputPath = relMilestoneFile(basePath, milestoneId, "SECRETS");
|
|
757
818
|
dispatchWorkflow(pi, loadPrompt("guided-plan-milestone", {
|
|
758
|
-
milestoneId, milestoneTitle, secretsOutputPath,
|
|
819
|
+
milestoneId, milestoneTitle, secretsOutputPath, inlinedTemplates: planMilestoneTemplates,
|
|
759
820
|
}));
|
|
760
821
|
} else if (choice === "discuss") {
|
|
822
|
+
const discussMilestoneTemplates = inlineTemplate("context", "Context");
|
|
761
823
|
dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
|
|
762
|
-
milestoneId, milestoneTitle,
|
|
824
|
+
milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates,
|
|
763
825
|
}));
|
|
764
826
|
} else if (choice === "skip_milestone") {
|
|
765
827
|
const milestoneIds = findMilestoneIds(basePath);
|
|
@@ -866,14 +928,19 @@ export async function showSmartEntry(
|
|
|
866
928
|
});
|
|
867
929
|
|
|
868
930
|
if (choice === "plan") {
|
|
931
|
+
const planSliceTemplates = [
|
|
932
|
+
inlineTemplate("plan", "Slice Plan"),
|
|
933
|
+
inlineTemplate("task-plan", "Task Plan"),
|
|
934
|
+
].join("\n\n---\n\n");
|
|
869
935
|
dispatchWorkflow(pi, loadPrompt("guided-plan-slice", {
|
|
870
|
-
milestoneId, sliceId, sliceTitle,
|
|
936
|
+
milestoneId, sliceId, sliceTitle, inlinedTemplates: planSliceTemplates,
|
|
871
937
|
}));
|
|
872
938
|
} else if (choice === "discuss") {
|
|
873
939
|
dispatchWorkflow(pi, await buildDiscussSlicePrompt(milestoneId, sliceId, sliceTitle, basePath));
|
|
874
940
|
} else if (choice === "research") {
|
|
941
|
+
const researchTemplates = inlineTemplate("research", "Research");
|
|
875
942
|
dispatchWorkflow(pi, loadPrompt("guided-research-slice", {
|
|
876
|
-
milestoneId, sliceId, sliceTitle,
|
|
943
|
+
milestoneId, sliceId, sliceTitle, inlinedTemplates: researchTemplates,
|
|
877
944
|
}));
|
|
878
945
|
} else if (choice === "status") {
|
|
879
946
|
const { fireStatusViaCommand } = await import("./commands.js");
|
|
@@ -904,8 +971,12 @@ export async function showSmartEntry(
|
|
|
904
971
|
});
|
|
905
972
|
|
|
906
973
|
if (choice === "complete") {
|
|
974
|
+
const completeSliceTemplates = [
|
|
975
|
+
inlineTemplate("slice-summary", "Slice Summary"),
|
|
976
|
+
inlineTemplate("uat", "UAT"),
|
|
977
|
+
].join("\n\n---\n\n");
|
|
907
978
|
dispatchWorkflow(pi, loadPrompt("guided-complete-slice", {
|
|
908
|
-
milestoneId, sliceId, sliceTitle,
|
|
979
|
+
milestoneId, sliceId, sliceTitle, inlinedTemplates: completeSliceTemplates,
|
|
909
980
|
}));
|
|
910
981
|
} else if (choice === "status") {
|
|
911
982
|
const { fireStatusViaCommand } = await import("./commands.js");
|
|
@@ -965,8 +1036,9 @@ export async function showSmartEntry(
|
|
|
965
1036
|
milestoneId, sliceId,
|
|
966
1037
|
}));
|
|
967
1038
|
} else {
|
|
1039
|
+
const executeTaskTemplates = inlineTemplate("task-summary", "Task Summary");
|
|
968
1040
|
dispatchWorkflow(pi, loadPrompt("guided-execute-task", {
|
|
969
|
-
milestoneId, sliceId, taskId, taskTitle,
|
|
1041
|
+
milestoneId, sliceId, taskId, taskTitle, inlinedTemplates: executeTaskTemplates,
|
|
970
1042
|
}));
|
|
971
1043
|
}
|
|
972
1044
|
} else if (choice === "status") {
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
// GSD Extension — Hook Engine (Post-Unit, Pre-Dispatch, State Persistence)
|
|
2
|
+
// Manages hook queue, cycle tracking, artifact verification, pre-dispatch
|
|
3
|
+
// interception, and durable hook state for user-configured extensibility.
|
|
4
|
+
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
PostUnitHookConfig,
|
|
8
|
+
PreDispatchHookConfig,
|
|
9
|
+
HookExecutionState,
|
|
10
|
+
HookDispatchResult,
|
|
11
|
+
PreDispatchResult,
|
|
12
|
+
PersistedHookState,
|
|
13
|
+
HookStatusEntry,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
import { resolvePostUnitHooks, resolvePreDispatchHooks } from "./preferences.js";
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
// ─── Hook Queue State ──────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/** Currently executing hook, or null if in normal dispatch flow. */
|
|
22
|
+
let activeHook: HookExecutionState | null = null;
|
|
23
|
+
|
|
24
|
+
/** Queue of hooks remaining for the current trigger unit. */
|
|
25
|
+
let hookQueue: Array<{
|
|
26
|
+
config: PostUnitHookConfig;
|
|
27
|
+
triggerUnitType: string;
|
|
28
|
+
triggerUnitId: string;
|
|
29
|
+
}> = [];
|
|
30
|
+
|
|
31
|
+
/** Cycle counts per hook+trigger, keyed as "hookName/triggerUnitType/triggerUnitId". */
|
|
32
|
+
const cycleCounts = new Map<string, number>();
|
|
33
|
+
|
|
34
|
+
/** Set when a hook completes with retry_on artifact present — signals caller to re-run trigger. */
|
|
35
|
+
let retryPending = false;
|
|
36
|
+
|
|
37
|
+
/** Stores the trigger unit info for pending retries so caller knows what to re-run. */
|
|
38
|
+
let retryTrigger: { unitType: string; unitId: string } | null = null;
|
|
39
|
+
|
|
40
|
+
// ─── Public API ────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Called after a unit completes. Returns the next hook unit to dispatch,
|
|
44
|
+
* or null if no hooks apply (normal dispatch should proceed).
|
|
45
|
+
*
|
|
46
|
+
* Call flow:
|
|
47
|
+
* 1. A core unit (e.g. execute-task) completes → handleAgentEnd calls this
|
|
48
|
+
* 2. If hooks match, returns first hook to dispatch. Caller sends the prompt.
|
|
49
|
+
* 3. Hook unit completes → handleAgentEnd calls this again (activeHook is set)
|
|
50
|
+
* 4. Checks retry_on / next hook / done → returns next action or null
|
|
51
|
+
*/
|
|
52
|
+
export function checkPostUnitHooks(
|
|
53
|
+
completedUnitType: string,
|
|
54
|
+
completedUnitId: string,
|
|
55
|
+
basePath: string,
|
|
56
|
+
): HookDispatchResult | null {
|
|
57
|
+
// If we just completed a hook unit, handle its result
|
|
58
|
+
if (activeHook) {
|
|
59
|
+
return handleHookCompletion(basePath);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Don't trigger hooks for other hook units (prevent hook-on-hook chains)
|
|
63
|
+
if (completedUnitType.startsWith("hook/")) return null;
|
|
64
|
+
|
|
65
|
+
// Check if any hooks are configured for this unit type
|
|
66
|
+
const hooks = resolvePostUnitHooks().filter(h =>
|
|
67
|
+
h.after.includes(completedUnitType),
|
|
68
|
+
);
|
|
69
|
+
if (hooks.length === 0) return null;
|
|
70
|
+
|
|
71
|
+
// Build hook queue for this trigger
|
|
72
|
+
hookQueue = hooks.map(config => ({
|
|
73
|
+
config,
|
|
74
|
+
triggerUnitType: completedUnitType,
|
|
75
|
+
triggerUnitId: completedUnitId,
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
return dequeueNextHook(basePath);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Returns whether a hook is currently active (for progress display).
|
|
83
|
+
*/
|
|
84
|
+
export function getActiveHook(): HookExecutionState | null {
|
|
85
|
+
return activeHook;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Returns true if a retry of the trigger unit was requested by a hook.
|
|
90
|
+
* Caller should re-dispatch the original trigger unit, then hooks will
|
|
91
|
+
* fire again on its next completion.
|
|
92
|
+
*/
|
|
93
|
+
export function isRetryPending(): boolean {
|
|
94
|
+
return retryPending;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Returns the trigger unit info for a pending retry, or null.
|
|
99
|
+
* Clears the retry state after reading.
|
|
100
|
+
*/
|
|
101
|
+
export function consumeRetryTrigger(): { unitType: string; unitId: string } | null {
|
|
102
|
+
if (!retryPending || !retryTrigger) return null;
|
|
103
|
+
const trigger = { ...retryTrigger };
|
|
104
|
+
retryPending = false;
|
|
105
|
+
retryTrigger = null;
|
|
106
|
+
return trigger;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Reset all hook state. Called on auto-mode start/stop.
|
|
111
|
+
*/
|
|
112
|
+
export function resetHookState(): void {
|
|
113
|
+
activeHook = null;
|
|
114
|
+
hookQueue = [];
|
|
115
|
+
cycleCounts.clear();
|
|
116
|
+
retryPending = false;
|
|
117
|
+
retryTrigger = null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Internal ──────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
function dequeueNextHook(basePath: string): HookDispatchResult | null {
|
|
123
|
+
while (hookQueue.length > 0) {
|
|
124
|
+
const entry = hookQueue.shift()!;
|
|
125
|
+
const { config, triggerUnitType, triggerUnitId } = entry;
|
|
126
|
+
|
|
127
|
+
// Check idempotency — if artifact already exists, skip this hook
|
|
128
|
+
if (config.artifact) {
|
|
129
|
+
const artifactPath = resolveHookArtifactPath(basePath, triggerUnitId, config.artifact);
|
|
130
|
+
if (existsSync(artifactPath)) continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check cycle limit
|
|
134
|
+
const cycleKey = `${config.name}/${triggerUnitType}/${triggerUnitId}`;
|
|
135
|
+
const currentCycle = (cycleCounts.get(cycleKey) ?? 0) + 1;
|
|
136
|
+
const maxCycles = config.max_cycles ?? 1;
|
|
137
|
+
if (currentCycle > maxCycles) continue;
|
|
138
|
+
|
|
139
|
+
cycleCounts.set(cycleKey, currentCycle);
|
|
140
|
+
|
|
141
|
+
activeHook = {
|
|
142
|
+
hookName: config.name,
|
|
143
|
+
triggerUnitType,
|
|
144
|
+
triggerUnitId,
|
|
145
|
+
cycle: currentCycle,
|
|
146
|
+
pendingRetry: false,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Build the prompt with variable substitution
|
|
150
|
+
const [mid, sid, tid] = triggerUnitId.split("/");
|
|
151
|
+
const prompt = config.prompt
|
|
152
|
+
.replace(/\{milestoneId\}/g, mid ?? "")
|
|
153
|
+
.replace(/\{sliceId\}/g, sid ?? "")
|
|
154
|
+
.replace(/\{taskId\}/g, tid ?? "");
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
hookName: config.name,
|
|
158
|
+
prompt,
|
|
159
|
+
model: config.model,
|
|
160
|
+
unitType: `hook/${config.name}`,
|
|
161
|
+
unitId: triggerUnitId,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// No more hooks — clear active state and return null for normal dispatch
|
|
166
|
+
activeHook = null;
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function handleHookCompletion(basePath: string): HookDispatchResult | null {
|
|
171
|
+
const hook = activeHook!;
|
|
172
|
+
const hooks = resolvePostUnitHooks();
|
|
173
|
+
const config = hooks.find(h => h.name === hook.hookName);
|
|
174
|
+
|
|
175
|
+
// Check if retry was requested via retry_on artifact
|
|
176
|
+
if (config?.retry_on) {
|
|
177
|
+
const retryArtifactPath = resolveHookArtifactPath(basePath, hook.triggerUnitId, config.retry_on);
|
|
178
|
+
if (existsSync(retryArtifactPath)) {
|
|
179
|
+
// Check cycle limit before allowing retry
|
|
180
|
+
const cycleKey = `${config.name}/${hook.triggerUnitType}/${hook.triggerUnitId}`;
|
|
181
|
+
const currentCycle = cycleCounts.get(cycleKey) ?? 1;
|
|
182
|
+
const maxCycles = config.max_cycles ?? 1;
|
|
183
|
+
|
|
184
|
+
if (currentCycle < maxCycles) {
|
|
185
|
+
// Signal retry — caller will re-dispatch the trigger unit
|
|
186
|
+
activeHook = null;
|
|
187
|
+
hookQueue = [];
|
|
188
|
+
retryPending = true;
|
|
189
|
+
retryTrigger = { unitType: hook.triggerUnitType, unitId: hook.triggerUnitId };
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
// Max cycles reached — fall through to normal completion
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Hook completed normally — try next hook in queue
|
|
197
|
+
activeHook = null;
|
|
198
|
+
return dequeueNextHook(basePath);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Resolve the path where a hook artifact is expected to be written.
|
|
203
|
+
* Uses the trigger unit's directory context:
|
|
204
|
+
* - Task-level (M001/S01/T01): .gsd/M001/slices/S01/tasks/T01-{artifact}
|
|
205
|
+
* - Slice-level (M001/S01): .gsd/M001/slices/S01/{artifact}
|
|
206
|
+
* - Milestone-level (M001): .gsd/M001/{artifact}
|
|
207
|
+
*/
|
|
208
|
+
export function resolveHookArtifactPath(basePath: string, unitId: string, artifactName: string): string {
|
|
209
|
+
const parts = unitId.split("/");
|
|
210
|
+
if (parts.length === 3) {
|
|
211
|
+
const [mid, sid, tid] = parts;
|
|
212
|
+
return join(basePath, ".gsd", mid, "slices", sid, "tasks", `${tid}-${artifactName}`);
|
|
213
|
+
}
|
|
214
|
+
if (parts.length === 2) {
|
|
215
|
+
const [mid, sid] = parts;
|
|
216
|
+
return join(basePath, ".gsd", mid, "slices", sid, artifactName);
|
|
217
|
+
}
|
|
218
|
+
return join(basePath, ".gsd", parts[0], artifactName);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
222
|
+
// Phase 2: Pre-Dispatch Hooks
|
|
223
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Run pre-dispatch hooks for a unit about to be dispatched.
|
|
227
|
+
* Returns a result indicating whether the unit should proceed (with optional
|
|
228
|
+
* prompt modifications), be skipped, or be replaced entirely.
|
|
229
|
+
*
|
|
230
|
+
* Multiple hooks can fire for the same unit type. They compose:
|
|
231
|
+
* - "modify" hooks stack (all prepend/append applied in order)
|
|
232
|
+
* - "skip" short-circuits (first matching skip wins)
|
|
233
|
+
* - "replace" short-circuits (first matching replace wins)
|
|
234
|
+
* - Skip/replace hooks take precedence over modify hooks
|
|
235
|
+
*/
|
|
236
|
+
export function runPreDispatchHooks(
|
|
237
|
+
unitType: string,
|
|
238
|
+
unitId: string,
|
|
239
|
+
prompt: string,
|
|
240
|
+
basePath: string,
|
|
241
|
+
): PreDispatchResult {
|
|
242
|
+
// Don't intercept hook units
|
|
243
|
+
if (unitType.startsWith("hook/")) {
|
|
244
|
+
return { action: "proceed", prompt, firedHooks: [] };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const hooks = resolvePreDispatchHooks().filter(h =>
|
|
248
|
+
h.before.includes(unitType),
|
|
249
|
+
);
|
|
250
|
+
if (hooks.length === 0) {
|
|
251
|
+
return { action: "proceed", prompt, firedHooks: [] };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const [mid, sid, tid] = unitId.split("/");
|
|
255
|
+
const substitute = (text: string): string =>
|
|
256
|
+
text
|
|
257
|
+
.replace(/\{milestoneId\}/g, mid ?? "")
|
|
258
|
+
.replace(/\{sliceId\}/g, sid ?? "")
|
|
259
|
+
.replace(/\{taskId\}/g, tid ?? "");
|
|
260
|
+
|
|
261
|
+
const firedHooks: string[] = [];
|
|
262
|
+
let currentPrompt = prompt;
|
|
263
|
+
|
|
264
|
+
for (const hook of hooks) {
|
|
265
|
+
if (hook.action === "skip") {
|
|
266
|
+
// Check optional skip condition
|
|
267
|
+
if (hook.skip_if) {
|
|
268
|
+
const conditionPath = resolveHookArtifactPath(basePath, unitId, hook.skip_if);
|
|
269
|
+
if (!existsSync(conditionPath)) continue; // Condition not met, don't skip
|
|
270
|
+
}
|
|
271
|
+
firedHooks.push(hook.name);
|
|
272
|
+
return { action: "skip", firedHooks };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (hook.action === "replace") {
|
|
276
|
+
firedHooks.push(hook.name);
|
|
277
|
+
return {
|
|
278
|
+
action: "replace",
|
|
279
|
+
prompt: substitute(hook.prompt ?? ""),
|
|
280
|
+
unitType: hook.unit_type,
|
|
281
|
+
model: hook.model,
|
|
282
|
+
firedHooks,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (hook.action === "modify") {
|
|
287
|
+
firedHooks.push(hook.name);
|
|
288
|
+
if (hook.prepend) {
|
|
289
|
+
currentPrompt = `${substitute(hook.prepend)}\n\n${currentPrompt}`;
|
|
290
|
+
}
|
|
291
|
+
if (hook.append) {
|
|
292
|
+
currentPrompt = `${currentPrompt}\n\n${substitute(hook.append)}`;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
action: "proceed",
|
|
299
|
+
prompt: currentPrompt,
|
|
300
|
+
model: hooks.find(h => h.action === "modify" && h.model)?.model,
|
|
301
|
+
firedHooks,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
306
|
+
// Phase 3: Hook State Persistence
|
|
307
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
308
|
+
|
|
309
|
+
const HOOK_STATE_FILE = "hook-state.json";
|
|
310
|
+
|
|
311
|
+
function hookStatePath(basePath: string): string {
|
|
312
|
+
return join(basePath, ".gsd", HOOK_STATE_FILE);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Persist current hook cycle counts to disk so they survive crashes/restarts.
|
|
317
|
+
* Called after each hook dispatch and on auto-mode pause.
|
|
318
|
+
*/
|
|
319
|
+
export function persistHookState(basePath: string): void {
|
|
320
|
+
const state: PersistedHookState = {
|
|
321
|
+
cycleCounts: Object.fromEntries(cycleCounts),
|
|
322
|
+
savedAt: new Date().toISOString(),
|
|
323
|
+
};
|
|
324
|
+
try {
|
|
325
|
+
const dir = join(basePath, ".gsd");
|
|
326
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
327
|
+
writeFileSync(hookStatePath(basePath), JSON.stringify(state, null, 2), "utf-8");
|
|
328
|
+
} catch {
|
|
329
|
+
// Non-fatal — state is recreatable from artifacts
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Restore hook cycle counts from disk after a crash/restart.
|
|
335
|
+
* Called during auto-mode resume.
|
|
336
|
+
*/
|
|
337
|
+
export function restoreHookState(basePath: string): void {
|
|
338
|
+
try {
|
|
339
|
+
const filePath = hookStatePath(basePath);
|
|
340
|
+
if (!existsSync(filePath)) return;
|
|
341
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
342
|
+
const state: PersistedHookState = JSON.parse(raw);
|
|
343
|
+
if (state.cycleCounts && typeof state.cycleCounts === "object") {
|
|
344
|
+
cycleCounts.clear();
|
|
345
|
+
for (const [key, value] of Object.entries(state.cycleCounts)) {
|
|
346
|
+
if (typeof value === "number") {
|
|
347
|
+
cycleCounts.set(key, value);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
} catch {
|
|
352
|
+
// Non-fatal — fresh state is fine
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Clear persisted hook state file from disk.
|
|
358
|
+
* Called on clean auto-mode stop.
|
|
359
|
+
*/
|
|
360
|
+
export function clearPersistedHookState(basePath: string): void {
|
|
361
|
+
try {
|
|
362
|
+
const filePath = hookStatePath(basePath);
|
|
363
|
+
if (existsSync(filePath)) {
|
|
364
|
+
writeFileSync(filePath, JSON.stringify({ cycleCounts: {}, savedAt: new Date().toISOString() }, null, 2), "utf-8");
|
|
365
|
+
}
|
|
366
|
+
} catch {
|
|
367
|
+
// Non-fatal
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
372
|
+
// Phase 3: Hook Status Reporting
|
|
373
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Get status of all configured hooks for display by /gsd hooks.
|
|
377
|
+
*/
|
|
378
|
+
export function getHookStatus(): HookStatusEntry[] {
|
|
379
|
+
const entries: HookStatusEntry[] = [];
|
|
380
|
+
|
|
381
|
+
// Post-unit hooks
|
|
382
|
+
const postHooks = resolvePostUnitHooks();
|
|
383
|
+
for (const hook of postHooks) {
|
|
384
|
+
const activeCycles: Record<string, number> = {};
|
|
385
|
+
for (const [key, count] of cycleCounts) {
|
|
386
|
+
if (key.startsWith(`${hook.name}/`)) {
|
|
387
|
+
activeCycles[key] = count;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
entries.push({
|
|
391
|
+
name: hook.name,
|
|
392
|
+
type: "post",
|
|
393
|
+
enabled: hook.enabled !== false,
|
|
394
|
+
targets: hook.after,
|
|
395
|
+
activeCycles,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Pre-dispatch hooks
|
|
400
|
+
const preHooks = resolvePreDispatchHooks();
|
|
401
|
+
for (const hook of preHooks) {
|
|
402
|
+
entries.push({
|
|
403
|
+
name: hook.name,
|
|
404
|
+
type: "pre",
|
|
405
|
+
enabled: hook.enabled !== false,
|
|
406
|
+
targets: hook.before,
|
|
407
|
+
activeCycles: {},
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return entries;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Format hook status for terminal display.
|
|
416
|
+
*/
|
|
417
|
+
export function formatHookStatus(): string {
|
|
418
|
+
const entries = getHookStatus();
|
|
419
|
+
if (entries.length === 0) {
|
|
420
|
+
return "No hooks configured. Add post_unit_hooks or pre_dispatch_hooks to .gsd/preferences.md";
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const lines: string[] = ["Configured Hooks:", ""];
|
|
424
|
+
|
|
425
|
+
const postHooks = entries.filter(e => e.type === "post");
|
|
426
|
+
const preHooks = entries.filter(e => e.type === "pre");
|
|
427
|
+
|
|
428
|
+
if (postHooks.length > 0) {
|
|
429
|
+
lines.push("Post-Unit Hooks (run after unit completes):");
|
|
430
|
+
for (const hook of postHooks) {
|
|
431
|
+
const status = hook.enabled ? "enabled" : "disabled";
|
|
432
|
+
const cycles = Object.keys(hook.activeCycles).length;
|
|
433
|
+
const cycleInfo = cycles > 0 ? ` (${cycles} active cycle${cycles === 1 ? "" : "s"})` : "";
|
|
434
|
+
lines.push(` ${hook.name} [${status}] → after: ${hook.targets.join(", ")}${cycleInfo}`);
|
|
435
|
+
}
|
|
436
|
+
lines.push("");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (preHooks.length > 0) {
|
|
440
|
+
lines.push("Pre-Dispatch Hooks (run before unit dispatches):");
|
|
441
|
+
for (const hook of preHooks) {
|
|
442
|
+
const status = hook.enabled ? "enabled" : "disabled";
|
|
443
|
+
lines.push(` ${hook.name} [${status}] → before: ${hook.targets.join(", ")}`);
|
|
444
|
+
}
|
|
445
|
+
lines.push("");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return lines.join("\n");
|
|
449
|
+
}
|