gsd-pi 2.23.0 → 2.24.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 (121) hide show
  1. package/dist/cli.js +12 -3
  2. package/dist/headless.d.ts +4 -0
  3. package/dist/headless.js +118 -10
  4. package/dist/help-text.js +22 -7
  5. package/dist/resource-loader.js +64 -9
  6. package/dist/resources/extensions/gsd/auto-dispatch.ts +51 -2
  7. package/dist/resources/extensions/gsd/auto-prompts.ts +73 -0
  8. package/dist/resources/extensions/gsd/auto-recovery.ts +41 -2
  9. package/dist/resources/extensions/gsd/auto-worktree.ts +15 -3
  10. package/dist/resources/extensions/gsd/auto.ts +123 -41
  11. package/dist/resources/extensions/gsd/commands.ts +176 -10
  12. package/dist/resources/extensions/gsd/complexity.ts +1 -0
  13. package/dist/resources/extensions/gsd/dashboard-overlay.ts +38 -0
  14. package/dist/resources/extensions/gsd/doctor.ts +56 -11
  15. package/dist/resources/extensions/gsd/exit-command.ts +2 -2
  16. package/dist/resources/extensions/gsd/gitignore.ts +1 -0
  17. package/dist/resources/extensions/gsd/guided-flow.ts +75 -0
  18. package/dist/resources/extensions/gsd/index.ts +34 -1
  19. package/dist/resources/extensions/gsd/parallel-eligibility.ts +233 -0
  20. package/dist/resources/extensions/gsd/parallel-merge.ts +156 -0
  21. package/dist/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
  22. package/dist/resources/extensions/gsd/preferences.ts +65 -1
  23. package/dist/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
  24. package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
  25. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +40 -61
  26. package/dist/resources/extensions/gsd/provider-error-pause.ts +29 -2
  27. package/dist/resources/extensions/gsd/session-status-io.ts +197 -0
  28. package/dist/resources/extensions/gsd/state.ts +72 -30
  29. package/dist/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
  30. package/dist/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
  31. package/dist/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
  32. package/dist/resources/extensions/gsd/tests/auto-recovery.test.ts +202 -2
  33. package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
  34. package/dist/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
  35. package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
  36. package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
  37. package/dist/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
  38. package/dist/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
  39. package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
  40. package/dist/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
  41. package/dist/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
  42. package/dist/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
  43. package/dist/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
  44. package/dist/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
  45. package/dist/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
  46. package/dist/resources/extensions/gsd/types.ts +15 -1
  47. package/dist/resources/extensions/subagent/index.ts +5 -0
  48. package/dist/resources/extensions/subagent/worker-registry.ts +99 -0
  49. package/dist/update-check.d.ts +9 -0
  50. package/dist/update-check.js +97 -0
  51. package/package.json +6 -1
  52. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  53. package/packages/pi-ai/dist/providers/anthropic.js +16 -7
  54. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  55. package/packages/pi-ai/dist/providers/azure-openai-responses.d.ts.map +1 -1
  56. package/packages/pi-ai/dist/providers/azure-openai-responses.js +12 -4
  57. package/packages/pi-ai/dist/providers/azure-openai-responses.js.map +1 -1
  58. package/packages/pi-ai/dist/providers/google-vertex.d.ts.map +1 -1
  59. package/packages/pi-ai/dist/providers/google-vertex.js +21 -9
  60. package/packages/pi-ai/dist/providers/google-vertex.js.map +1 -1
  61. package/packages/pi-ai/dist/providers/openai-completions.d.ts.map +1 -1
  62. package/packages/pi-ai/dist/providers/openai-completions.js +12 -4
  63. package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
  64. package/packages/pi-ai/dist/providers/openai-responses.d.ts.map +1 -1
  65. package/packages/pi-ai/dist/providers/openai-responses.js +12 -4
  66. package/packages/pi-ai/dist/providers/openai-responses.js.map +1 -1
  67. package/packages/pi-ai/src/providers/anthropic.ts +21 -8
  68. package/packages/pi-ai/src/providers/azure-openai-responses.ts +16 -4
  69. package/packages/pi-ai/src/providers/google-vertex.ts +32 -17
  70. package/packages/pi-ai/src/providers/openai-completions.ts +16 -4
  71. package/packages/pi-ai/src/providers/openai-responses.ts +16 -4
  72. package/packages/pi-coding-agent/dist/core/agent-session.js +1 -1
  73. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  74. package/packages/pi-coding-agent/dist/core/settings-manager.js +1 -1
  75. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  76. package/packages/pi-coding-agent/src/core/agent-session.ts +1 -1
  77. package/packages/pi-coding-agent/src/core/settings-manager.ts +2 -2
  78. package/scripts/postinstall.js +7 -109
  79. package/src/resources/extensions/gsd/auto-dispatch.ts +51 -2
  80. package/src/resources/extensions/gsd/auto-prompts.ts +73 -0
  81. package/src/resources/extensions/gsd/auto-recovery.ts +41 -2
  82. package/src/resources/extensions/gsd/auto-worktree.ts +15 -3
  83. package/src/resources/extensions/gsd/auto.ts +123 -41
  84. package/src/resources/extensions/gsd/commands.ts +176 -10
  85. package/src/resources/extensions/gsd/complexity.ts +1 -0
  86. package/src/resources/extensions/gsd/dashboard-overlay.ts +38 -0
  87. package/src/resources/extensions/gsd/doctor.ts +56 -11
  88. package/src/resources/extensions/gsd/exit-command.ts +2 -2
  89. package/src/resources/extensions/gsd/gitignore.ts +1 -0
  90. package/src/resources/extensions/gsd/guided-flow.ts +75 -0
  91. package/src/resources/extensions/gsd/index.ts +34 -1
  92. package/src/resources/extensions/gsd/parallel-eligibility.ts +233 -0
  93. package/src/resources/extensions/gsd/parallel-merge.ts +156 -0
  94. package/src/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
  95. package/src/resources/extensions/gsd/preferences.ts +65 -1
  96. package/src/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
  97. package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
  98. package/src/resources/extensions/gsd/prompts/validate-milestone.md +40 -61
  99. package/src/resources/extensions/gsd/provider-error-pause.ts +29 -2
  100. package/src/resources/extensions/gsd/session-status-io.ts +197 -0
  101. package/src/resources/extensions/gsd/state.ts +72 -30
  102. package/src/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
  103. package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
  104. package/src/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
  105. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +202 -2
  106. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
  107. package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
  108. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
  109. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
  110. package/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
  111. package/src/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
  112. package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
  113. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
  114. package/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
  115. package/src/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
  116. package/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
  117. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
  118. package/src/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
  119. package/src/resources/extensions/gsd/types.ts +15 -1
  120. package/src/resources/extensions/subagent/index.ts +5 -0
  121. package/src/resources/extensions/subagent/worker-registry.ts +99 -0
@@ -46,7 +46,7 @@ Research what this slice needs. Narrate key findings and surprises as you go —
46
46
  2. **Skill Discovery ({{skillDiscoveryMode}}):**{{skillDiscoveryInstructions}}
47
47
  3. Explore relevant code for this slice's scope. For targeted exploration, use `rg`, `find`, and reads. For broad or unfamiliar subsystems, use `scout` to map the relevant area first.
48
48
  4. Use `resolve_library` / `get_library_docs` for unfamiliar libraries — skip this for libraries already used in the codebase
49
- 5. Use the **Research** output template from the inlined context above — include only sections that have real content
49
+ 5. Use the **Research** output template from the inlined context above — include only sections that have real content. The template is already inlined above; do NOT attempt to read any template file from disk (there is no `templates/SLICE-RESEARCH.md` — the correct template is already present in this prompt).
50
50
  6. Write `{{outputPath}}`
51
51
 
52
52
  The slice directory already exists at `{{slicePath}}/`. Do NOT mkdir — just write the file.
@@ -1,6 +1,6 @@
1
1
  You are executing GSD auto-mode.
2
2
 
3
- ## UNIT: Validate Milestone {{milestoneId}} ("{{milestoneTitle}}") — Remediation Round {{remediationRound}}
3
+ ## UNIT: Validate Milestone {{milestoneId}} ("{{milestoneTitle}}")
4
4
 
5
5
  ## Working Directory
6
6
 
@@ -8,84 +8,63 @@ Your working directory is `{{workingDirectory}}`. All file reads, writes, and sh
8
8
 
9
9
  ## Your Role in the Pipeline
10
10
 
11
- All slices are done. Before the **complete-milestone agent** closes this milestone, you reconcile planned work against what was actually delivered. You audit success criteria against evidence, inventory deferred work across all slice summaries and UAT results, and classify gaps. If auto-remediable gaps exist on the first pass, you append remediation slices to the roadmap so the pipeline can execute them before completion. After remediation slices run, you re-validate. The milestone only proceeds to completion once validation passes.
11
+ All slices are done. Before the milestone can be completed, you must validate that the planned work was delivered as specified. Compare the roadmap's success criteria and slice definitions against the actual slice summaries and UAT results. This is a reconciliation gate catch gaps, regressions, or missing deliverables before the milestone is sealed.
12
12
 
13
- This is a gate, not a formality. But most milestones pass bias toward "pass" unless you find concrete evidence of unmet criteria or meaningful gaps.
13
+ This is remediation round {{remediationRound}}. If this is round 0, this is the first validation pass. If > 0, prior validation found issues and remediation slices were added and executed verify those remediation slices resolved the issues.
14
14
 
15
15
  All relevant context has been preloaded below — the roadmap, all slice summaries, UAT results, requirements, decisions, and project context are inlined. Start working immediately without re-reading these files.
16
16
 
17
17
  {{inlinedContext}}
18
18
 
19
- If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during validation, without relaxing required verification or artifact rules.
19
+ ## Validation Steps
20
20
 
21
- Then:
21
+ 1. For each **success criterion** in `{{roadmapPath}}`, check whether slice summaries and UAT results provide evidence that it was met. Record pass/fail per criterion.
22
+ 2. For each **slice** in the roadmap, verify its demo/deliverable claim against its summary. Flag any slice whose summary does not substantiate its claimed output.
23
+ 3. Check **cross-slice integration points** — do boundary map entries (produces/consumes) align with what was actually built?
24
+ 4. Check **requirement coverage** — are all active requirements addressed by at least one slice?
25
+ 5. Determine a verdict:
26
+ - `pass` — all criteria met, all slices delivered, no gaps
27
+ - `needs-attention` — minor gaps that do not block completion (document them)
28
+ - `needs-remediation` — material gaps found; add remediation slices to the roadmap
22
29
 
23
- ### Step 1: Audit Success Criteria
30
+ ## Output
24
31
 
25
- Enumerate each success criterion from the roadmap's `## Success Criteria` section. For each criterion, map it to concrete evidence from slice summaries, UAT results, or observable behavior.
32
+ Write `{{validationPath}}` with this structure:
26
33
 
27
- Format each criterion as:
34
+ ```markdown
35
+ ---
36
+ verdict: <pass|needs-attention|needs-remediation>
37
+ remediation_round: {{remediationRound}}
38
+ ---
28
39
 
29
- - `Criterion text` — **MET** — evidence: {{specific slice summary, UAT result, test output, or observable behavior}}
30
- - `Criterion text` — **NOT MET** — gap: {{what's missing and why}}
40
+ # Milestone Validation: {{milestoneId}}
31
41
 
32
- Every criterion must have a definitive verdict. Do not mark a criterion as MET without specific evidence.
42
+ ## Success Criteria Checklist
43
+ - [x] Criterion 1 — evidence: ...
44
+ - [ ] Criterion 2 — gap: ...
33
45
 
34
- ### Step 2: Inventory Deferred Work
46
+ ## Slice Delivery Audit
47
+ | Slice | Claimed | Delivered | Status |
48
+ |-------|---------|-----------|--------|
49
+ | S01 | ... | ... | pass |
35
50
 
36
- Scan ALL slice summaries for:
37
- - `Known Limitations` sections
38
- - `Follow-ups` sections
39
- - `Deviations` sections
51
+ ## Cross-Slice Integration
52
+ (any boundary mismatches)
40
53
 
41
- Scan ALL UAT results for:
42
- - `Not Proven By This UAT` sections
43
- - Any PARTIAL or FAIL verdicts
54
+ ## Requirement Coverage
55
+ (any unaddressed requirements)
44
56
 
45
- Check:
46
- - `.gsd/REQUIREMENTS.md` for Active requirements not yet Validated
47
- - `.gsd/CAPTURES.md` for unresolved deferred captures
57
+ ## Verdict Rationale
58
+ (why this verdict was chosen)
48
59
 
49
- Collect every item into a single inventory. Do not skip items because they seem minor — the classification step handles prioritization.
60
+ ## Remediation Plan
61
+ (only if verdict is needs-remediation — list new slices to add to the roadmap)
62
+ ```
50
63
 
51
- ### Step 3: Classify Each Gap
52
-
53
- For every unmet criterion and every deferred work item, classify it as one of:
54
-
55
- - **auto-remediable** — can be fixed by adding a new slice (missing feature, unfixed bug, untested path, incomplete integration)
56
- - **human-required** — needs Lex's input (design decision, external service dependency, manual verification, judgment call, ambiguous requirement)
57
- - **acceptable** — known limitation that's OK to ship (documented trade-off, explicitly scoped for a future milestone, minor rough edge with no user impact)
58
-
59
- Be conservative with **auto-remediable**. Only classify a gap as auto-remediable if you're confident a slice can resolve it without human judgment. When in doubt, classify as **human-required**.
60
-
61
- ### Step 4: Act on Gaps
62
-
63
- **If this is remediation round 0 AND auto-remediable gaps exist:**
64
-
65
- 1. Define remediation slices to address auto-remediable gaps. Follow the exact roadmap slice format:
66
- `- [ ] **S0X: Title** \`risk:medium\` \`depends:[]\``
67
- Include a brief description of what each slice must accomplish.
68
- 2. Append these slices to `{{roadmapPath}}` after existing slices (do not modify completed slices).
69
- 3. Update the boundary map in the roadmap if the new slices introduce new integration points.
70
- 4. Set verdict to `needs-remediation`.
71
-
72
- **If this is remediation round 1 or higher:**
73
-
74
- Do NOT add more slices. At this point either:
75
- - All remaining gaps are acceptable — set verdict to `pass`
76
- - Remaining gaps need Lex's input — set verdict to `needs-attention`
77
-
78
- Never add remediation slices after round 0. If round 0 remediation didn't close the gaps, escalate.
79
-
80
- **If no auto-remediable gaps exist (any round):**
81
-
82
- - If all criteria are MET and deferred items are acceptable or human-required only — set verdict to `pass` (with human-required items noted)
83
- - If human-required items are blocking — set verdict to `needs-attention`
84
-
85
- ### Step 5: Write Validation Report
86
-
87
- Write `{{validationPath}}` using the milestone-validation template. Fill all frontmatter fields and every section. The report must be a complete record of the validation — a future agent reading only this file should understand what was checked, what passed, and what remains.
64
+ If verdict is `needs-remediation`:
65
+ - Add new slices to `{{roadmapPath}}` with unchecked `[ ]` status
66
+ - These slices will be planned and executed before validation re-runs
88
67
 
89
68
  **You MUST write `{{validationPath}}` before finishing.**
90
69
 
91
- When done, say: "Milestone {{milestoneId}} validated."
70
+ When done, say: "Milestone {{milestoneId}} validation complete — verdict: <verdict>."
@@ -2,11 +2,38 @@ export type ProviderErrorPauseUI = {
2
2
  notify(message: string, level?: "info" | "warning" | "error" | "success"): void;
3
3
  };
4
4
 
5
+ /**
6
+ * Pause auto-mode due to a provider error.
7
+ *
8
+ * For rate-limit errors with a known reset delay, schedules an automatic
9
+ * resume after the delay and shows a countdown notification. For all other
10
+ * errors, pauses indefinitely (user must manually resume).
11
+ */
5
12
  export async function pauseAutoForProviderError(
6
13
  ui: ProviderErrorPauseUI,
7
14
  errorDetail: string,
8
15
  pause: () => Promise<void>,
16
+ options?: {
17
+ isRateLimit?: boolean;
18
+ retryAfterMs?: number;
19
+ resume?: () => void;
20
+ },
9
21
  ): Promise<void> {
10
- ui.notify(`Auto-mode paused due to provider error${errorDetail}`, "warning");
11
- await pause();
22
+ if (options?.isRateLimit && options.retryAfterMs && options.retryAfterMs > 0 && options.resume) {
23
+ const delaySec = Math.ceil(options.retryAfterMs / 1000);
24
+ ui.notify(
25
+ `Rate limited${errorDetail}. Auto-resuming in ${delaySec}s...`,
26
+ "warning",
27
+ );
28
+ await pause();
29
+
30
+ // Schedule auto-resume after the rate limit window
31
+ setTimeout(() => {
32
+ ui.notify("Rate limit window elapsed. Resuming auto-mode.", "info");
33
+ options.resume!();
34
+ }, options.retryAfterMs);
35
+ } else {
36
+ ui.notify(`Auto-mode paused due to provider error${errorDetail}`, "warning");
37
+ await pause();
38
+ }
12
39
  }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * GSD Session Status I/O
3
+ *
4
+ * File-based IPC protocol for coordinator-worker communication in
5
+ * parallel milestone orchestration. Each worker writes its status to a
6
+ * file; the coordinator reads all status files to monitor progress.
7
+ *
8
+ * Atomic writes (write to .tmp, then rename) prevent partial reads.
9
+ * Signal files let the coordinator send pause/resume/stop/rebase to workers.
10
+ * Stale detection combines PID liveness checks with heartbeat timeouts.
11
+ */
12
+
13
+ import {
14
+ writeFileSync,
15
+ readFileSync,
16
+ renameSync,
17
+ unlinkSync,
18
+ readdirSync,
19
+ mkdirSync,
20
+ existsSync,
21
+ } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { gsdRoot } from "./paths.js";
24
+
25
+ // ─── Types ─────────────────────────────────────────────────────────────────
26
+
27
+ export interface SessionStatus {
28
+ milestoneId: string;
29
+ pid: number;
30
+ state: "running" | "paused" | "stopped" | "error";
31
+ currentUnit: { type: string; id: string; startedAt: number } | null;
32
+ completedUnits: number;
33
+ cost: number;
34
+ lastHeartbeat: number;
35
+ startedAt: number;
36
+ worktreePath: string;
37
+ }
38
+
39
+ export type SessionSignal = "pause" | "resume" | "stop" | "rebase";
40
+
41
+ export interface SignalMessage {
42
+ signal: SessionSignal;
43
+ sentAt: number;
44
+ from: "coordinator";
45
+ }
46
+
47
+ // ─── Constants ─────────────────────────────────────────────────────────────
48
+
49
+ const PARALLEL_DIR = "parallel";
50
+ const STATUS_SUFFIX = ".status.json";
51
+ const SIGNAL_SUFFIX = ".signal.json";
52
+ const TMP_SUFFIX = ".tmp";
53
+ const DEFAULT_STALE_TIMEOUT_MS = 30_000;
54
+
55
+ // ─── Helpers ───────────────────────────────────────────────────────────────
56
+
57
+ function parallelDir(basePath: string): string {
58
+ return join(gsdRoot(basePath), PARALLEL_DIR);
59
+ }
60
+
61
+ function statusPath(basePath: string, milestoneId: string): string {
62
+ return join(parallelDir(basePath), `${milestoneId}${STATUS_SUFFIX}`);
63
+ }
64
+
65
+ function signalPath(basePath: string, milestoneId: string): string {
66
+ return join(parallelDir(basePath), `${milestoneId}${SIGNAL_SUFFIX}`);
67
+ }
68
+
69
+ function ensureParallelDir(basePath: string): void {
70
+ const dir = parallelDir(basePath);
71
+ if (!existsSync(dir)) {
72
+ mkdirSync(dir, { recursive: true });
73
+ }
74
+ }
75
+
76
+ function isPidAlive(pid: number): boolean {
77
+ try {
78
+ process.kill(pid, 0);
79
+ return true;
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ // ─── Status I/O ────────────────────────────────────────────────────────────
86
+
87
+ /** Write session status atomically (write to .tmp, then rename). */
88
+ export function writeSessionStatus(basePath: string, status: SessionStatus): void {
89
+ try {
90
+ ensureParallelDir(basePath);
91
+ const dest = statusPath(basePath, status.milestoneId);
92
+ const tmp = dest + TMP_SUFFIX;
93
+ writeFileSync(tmp, JSON.stringify(status, null, 2), "utf-8");
94
+ renameSync(tmp, dest);
95
+ } catch { /* non-fatal */ }
96
+ }
97
+
98
+ /** Read a specific milestone's session status. */
99
+ export function readSessionStatus(basePath: string, milestoneId: string): SessionStatus | null {
100
+ try {
101
+ const p = statusPath(basePath, milestoneId);
102
+ if (!existsSync(p)) return null;
103
+ const raw = readFileSync(p, "utf-8");
104
+ return JSON.parse(raw) as SessionStatus;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /** Read all session status files from .gsd/parallel/. */
111
+ export function readAllSessionStatuses(basePath: string): SessionStatus[] {
112
+ const dir = parallelDir(basePath);
113
+ if (!existsSync(dir)) return [];
114
+
115
+ const results: SessionStatus[] = [];
116
+ try {
117
+ const entries = readdirSync(dir);
118
+ for (const entry of entries) {
119
+ if (!entry.endsWith(STATUS_SUFFIX)) continue;
120
+ try {
121
+ const raw = readFileSync(join(dir, entry), "utf-8");
122
+ results.push(JSON.parse(raw) as SessionStatus);
123
+ } catch { /* skip corrupt files */ }
124
+ }
125
+ } catch { /* non-fatal */ }
126
+ return results;
127
+ }
128
+
129
+ /** Remove a milestone's session status file. */
130
+ export function removeSessionStatus(basePath: string, milestoneId: string): void {
131
+ try {
132
+ const p = statusPath(basePath, milestoneId);
133
+ if (existsSync(p)) unlinkSync(p);
134
+ } catch { /* non-fatal */ }
135
+ }
136
+
137
+ // ─── Signal I/O ────────────────────────────────────────────────────────────
138
+
139
+ /** Write a signal file for a worker to consume. */
140
+ export function sendSignal(basePath: string, milestoneId: string, signal: SessionSignal): void {
141
+ try {
142
+ ensureParallelDir(basePath);
143
+ const dest = signalPath(basePath, milestoneId);
144
+ const tmp = dest + TMP_SUFFIX;
145
+ const msg: SignalMessage = { signal, sentAt: Date.now(), from: "coordinator" };
146
+ writeFileSync(tmp, JSON.stringify(msg, null, 2), "utf-8");
147
+ renameSync(tmp, dest);
148
+ } catch { /* non-fatal */ }
149
+ }
150
+
151
+ /** Read and delete a signal file (atomic consume). Returns null if no signal pending. */
152
+ export function consumeSignal(basePath: string, milestoneId: string): SignalMessage | null {
153
+ try {
154
+ const p = signalPath(basePath, milestoneId);
155
+ if (!existsSync(p)) return null;
156
+ const raw = readFileSync(p, "utf-8");
157
+ unlinkSync(p);
158
+ return JSON.parse(raw) as SignalMessage;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ // ─── Stale Detection ───────────────────────────────────────────────────────
165
+
166
+ /** Check whether a session is stale (PID dead or heartbeat timed out). */
167
+ export function isSessionStale(
168
+ status: SessionStatus,
169
+ timeoutMs: number = DEFAULT_STALE_TIMEOUT_MS,
170
+ ): boolean {
171
+ if (!isPidAlive(status.pid)) return true;
172
+ const elapsed = Date.now() - status.lastHeartbeat;
173
+ return elapsed > timeoutMs;
174
+ }
175
+
176
+ /** Find and remove stale sessions. Returns the milestone IDs that were cleaned up. */
177
+ export function cleanupStaleSessions(
178
+ basePath: string,
179
+ timeoutMs: number = DEFAULT_STALE_TIMEOUT_MS,
180
+ ): string[] {
181
+ const removed: string[] = [];
182
+ const statuses = readAllSessionStatuses(basePath);
183
+
184
+ for (const status of statuses) {
185
+ if (isSessionStale(status, timeoutMs)) {
186
+ removeSessionStatus(basePath, status.milestoneId);
187
+ // Also clean up any lingering signal file
188
+ try {
189
+ const sig = signalPath(basePath, status.milestoneId);
190
+ if (existsSync(sig)) unlinkSync(sig);
191
+ } catch { /* non-fatal */ }
192
+ removed.push(status.milestoneId);
193
+ }
194
+ }
195
+
196
+ return removed;
197
+ }
@@ -32,7 +32,6 @@ import {
32
32
 
33
33
  import { milestoneIdSort, findMilestoneIds } from './guided-flow.js';
34
34
  import { nativeBatchParseGsdFiles, type BatchParsedFile } from './native-parser-bridge.js';
35
- import { isDbAvailable, _getAdapter } from './gsd-db.js';
36
35
 
37
36
  import { join, resolve } from 'path';
38
37
  import { debugCount, debugTime } from './debug-logger.js';
@@ -53,6 +52,19 @@ export function isMilestoneComplete(roadmap: Roadmap): boolean {
53
52
  return roadmap.slices.length > 0 && roadmap.slices.every(s => s.done);
54
53
  }
55
54
 
55
+ /**
56
+ * Check whether a VALIDATION file's verdict is terminal (pass or needs-attention).
57
+ * A non-terminal verdict (needs-remediation) means validation must re-run
58
+ * after remediation slices are executed.
59
+ */
60
+ export function isValidationTerminal(validationContent: string): boolean {
61
+ const match = validationContent.match(/^---\n([\s\S]*?)\n---/);
62
+ if (!match) return false;
63
+ const verdict = match[1].match(/verdict:\s*(\S+)/);
64
+ if (!verdict) return false;
65
+ return verdict[1] === 'pass' || verdict[1] === 'needs-attention';
66
+ }
67
+
56
68
  // ─── State Derivation ──────────────────────────────────────────────────────
57
69
 
58
70
  // ── deriveState memoization ─────────────────────────────────────────────────
@@ -82,6 +94,11 @@ export function invalidateStateCache(): void {
82
94
  */
83
95
  export async function getActiveMilestoneId(basePath: string): Promise<string | null> {
84
96
  const milestoneIds = findMilestoneIds(basePath);
97
+ // Parallel worker isolation
98
+ const milestoneLock = process.env.GSD_MILESTONE_LOCK;
99
+ if (milestoneLock) {
100
+ return milestoneIds.includes(milestoneLock) ? milestoneLock : null;
101
+ }
85
102
  for (const mid of milestoneIds) {
86
103
  const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
87
104
  const content = roadmapFile ? await loadFile(roadmapFile) : null;
@@ -129,6 +146,18 @@ export async function deriveState(basePath: string): Promise<GSDState> {
129
146
  async function _deriveStateImpl(basePath: string): Promise<GSDState> {
130
147
  const milestoneIds = findMilestoneIds(basePath);
131
148
 
149
+ // ── Parallel worker isolation ──────────────────────────────────────────
150
+ // When GSD_MILESTONE_LOCK is set, this process is a parallel worker
151
+ // scoped to a single milestone. Filter the milestone list so this worker
152
+ // only sees its assigned milestone (all others are treated as if they
153
+ // don't exist). This gives each worker complete isolation without
154
+ // modifying any other state derivation logic.
155
+ const milestoneLock = process.env.GSD_MILESTONE_LOCK;
156
+ if (milestoneLock && milestoneIds.includes(milestoneLock)) {
157
+ milestoneIds.length = 0;
158
+ milestoneIds.push(milestoneLock);
159
+ }
160
+
132
161
  // ── Batch-parse file cache ──────────────────────────────────────────────
133
162
  // When the native Rust parser is available, read every .md file under .gsd/
134
163
  // in one call and build an in-memory content map keyed by absolute path.
@@ -136,30 +165,12 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
136
165
  const fileContentCache = new Map<string, string>();
137
166
  const gsdDir = gsdRoot(basePath);
138
167
 
139
- // ── DB-first content loading ──
140
- // When the DB is available, load artifact content from the artifacts table
141
- // (indexed SELECT instead of O(N) file I/O). Falls back to native Rust batch
142
- // parser, which in turn falls back to sequential JS reads via cachedLoadFile.
143
- let dbContentLoaded = false;
144
- if (isDbAvailable()) {
145
- const adapter = _getAdapter();
146
- if (adapter) {
147
- try {
148
- const rows = adapter.prepare('SELECT path, full_content FROM artifacts').all();
149
- for (const row of rows) {
150
- const relPath = (row as Record<string, unknown>)['path'] as string;
151
- const content = (row as Record<string, unknown>)['full_content'] as string;
152
- const absPath = resolve(gsdDir, relPath);
153
- fileContentCache.set(absPath, content);
154
- }
155
- dbContentLoaded = rows.length > 0;
156
- } catch {
157
- // DB query failed — fall through to native batch parse
158
- }
159
- }
160
- }
161
-
162
- if (!dbContentLoaded) {
168
+ // NOTE: We intentionally do NOT load from the SQLite DB here (#759).
169
+ // The DB's artifacts table is populated once during migrateFromMarkdown
170
+ // and is never updated when files change on disk (e.g. roadmap [x] updates,
171
+ // plan checkbox changes). Using stale DB content causes deriveState to
172
+ // return incorrect phase/slice state, leading to infinite skip loops.
173
+ // The native Rust batch parser is fast enough for state derivation.
163
174
  const batchFiles = nativeBatchParseGsdFiles(gsdDir);
164
175
  if (batchFiles) {
165
176
  for (const f of batchFiles) {
@@ -167,7 +178,6 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
167
178
  fileContentCache.set(absPath, f.rawContent);
168
179
  }
169
180
  }
170
- }
171
181
 
172
182
  /**
173
183
  * Load file content from batch cache first, falling back to disk read.
@@ -279,10 +289,20 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
279
289
  const complete = isMilestoneComplete(roadmap);
280
290
 
281
291
  if (complete) {
282
- // All slices done — check if milestone summary exists
292
+ // All slices done — check validation and summary state
293
+ const validationFile = resolveMilestoneFile(basePath, mid, "VALIDATION");
294
+ const validationContent = validationFile ? await cachedLoadFile(validationFile) : null;
295
+ const validationTerminal = validationContent ? isValidationTerminal(validationContent) : false;
283
296
  const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY");
284
- if (!summaryFile && !activeMilestoneFound) {
285
- // All slices complete but no summary written yet → completing-milestone
297
+
298
+ if (!validationTerminal && !activeMilestoneFound) {
299
+ // No terminal validation yet → validating-milestone
300
+ activeMilestone = { id: mid, title };
301
+ activeRoadmap = roadmap;
302
+ activeMilestoneFound = true;
303
+ registry.push({ id: mid, title, status: 'active' });
304
+ } else if (!summaryFile && !activeMilestoneFound) {
305
+ // Validated but no summary written yet → completing-milestone
286
306
  activeMilestone = { id: mid, title };
287
307
  activeRoadmap = roadmap;
288
308
  activeMilestoneFound = true;
@@ -385,12 +405,34 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
385
405
  };
386
406
  }
387
407
 
388
- // Check if active milestone needs completion (all slices done, no summary)
408
+ // Check if active milestone needs validation or completion (all slices done)
389
409
  if (isMilestoneComplete(activeRoadmap)) {
410
+ const validationFile = resolveMilestoneFile(basePath, activeMilestone.id, "VALIDATION");
411
+ const validationContent = validationFile ? await cachedLoadFile(validationFile) : null;
412
+ const validationTerminal = validationContent ? isValidationTerminal(validationContent) : false;
390
413
  const sliceProgress = {
391
414
  done: activeRoadmap.slices.length,
392
415
  total: activeRoadmap.slices.length,
393
416
  };
417
+
418
+ if (!validationTerminal) {
419
+ return {
420
+ activeMilestone,
421
+ activeSlice: null,
422
+ activeTask: null,
423
+ phase: 'validating-milestone',
424
+ recentDecisions: [],
425
+ blockers: [],
426
+ nextAction: `Validate milestone ${activeMilestone.id} before completion.`,
427
+ registry,
428
+ requirements,
429
+ progress: {
430
+ milestones: milestoneProgress,
431
+ slices: sliceProgress,
432
+ },
433
+ };
434
+ }
435
+
394
436
  return {
395
437
  activeMilestone,
396
438
  activeSlice: null,
@@ -27,3 +27,84 @@ test("pauseAutoForProviderError warns and pauses without requiring ctx.log", asy
27
27
  },
28
28
  ]);
29
29
  });
30
+
31
+ test("pauseAutoForProviderError schedules auto-resume for rate limit errors", async () => {
32
+ const notifications: Array<{ message: string; level: string }> = [];
33
+ let pauseCalls = 0;
34
+ let resumeCalled = false;
35
+
36
+ // Use fake timer
37
+ const originalSetTimeout = globalThis.setTimeout;
38
+ const timers: Array<{ fn: () => void; delay: number }> = [];
39
+ globalThis.setTimeout = ((fn: () => void, delay: number) => {
40
+ timers.push({ fn, delay });
41
+ return 0 as unknown as ReturnType<typeof setTimeout>;
42
+ }) as typeof setTimeout;
43
+
44
+ try {
45
+ await pauseAutoForProviderError(
46
+ {
47
+ notify(message, level?) {
48
+ notifications.push({ message, level: level ?? "info" });
49
+ },
50
+ },
51
+ ": rate limit exceeded",
52
+ async () => {
53
+ pauseCalls += 1;
54
+ },
55
+ {
56
+ isRateLimit: true,
57
+ retryAfterMs: 90000,
58
+ resume: () => {
59
+ resumeCalled = true;
60
+ },
61
+ },
62
+ );
63
+
64
+ assert.equal(pauseCalls, 1, "should pause auto-mode");
65
+ assert.equal(timers.length, 1, "should schedule one timer");
66
+ assert.equal(timers[0].delay, 90000, "timer should match retryAfterMs");
67
+ assert.deepEqual(notifications[0], {
68
+ message: "Rate limited: rate limit exceeded. Auto-resuming in 90s...",
69
+ level: "warning",
70
+ });
71
+
72
+ // Fire the timer
73
+ timers[0].fn();
74
+ assert.equal(resumeCalled, true, "should call resume after timer fires");
75
+ assert.deepEqual(notifications[1], {
76
+ message: "Rate limit window elapsed. Resuming auto-mode.",
77
+ level: "info",
78
+ });
79
+ } finally {
80
+ globalThis.setTimeout = originalSetTimeout;
81
+ }
82
+ });
83
+
84
+ test("pauseAutoForProviderError falls back to indefinite pause when not rate limit", async () => {
85
+ const notifications: Array<{ message: string; level: string }> = [];
86
+ let pauseCalls = 0;
87
+
88
+ await pauseAutoForProviderError(
89
+ {
90
+ notify(message, level?) {
91
+ notifications.push({ message, level: level ?? "info" });
92
+ },
93
+ },
94
+ ": connection refused",
95
+ async () => {
96
+ pauseCalls += 1;
97
+ },
98
+ {
99
+ isRateLimit: false,
100
+ },
101
+ );
102
+
103
+ assert.equal(pauseCalls, 1);
104
+ assert.deepEqual(notifications, [
105
+ {
106
+ message: "Auto-mode paused due to provider error: connection refused",
107
+ level: "warning",
108
+ },
109
+ ]);
110
+ });
@@ -9,8 +9,12 @@ import {
9
9
 
10
10
  test("getBudgetAlertLevel returns the expected threshold bucket", () => {
11
11
  assert.equal(getBudgetAlertLevel(0.10), 0);
12
+ assert.equal(getBudgetAlertLevel(0.74), 0);
12
13
  assert.equal(getBudgetAlertLevel(0.75), 75);
13
- assert.equal(getBudgetAlertLevel(0.89), 75);
14
+ assert.equal(getBudgetAlertLevel(0.79), 75);
15
+ assert.equal(getBudgetAlertLevel(0.80), 80);
16
+ assert.equal(getBudgetAlertLevel(0.85), 80);
17
+ assert.equal(getBudgetAlertLevel(0.89), 80);
14
18
  assert.equal(getBudgetAlertLevel(0.90), 90);
15
19
  assert.equal(getBudgetAlertLevel(1.00), 100);
16
20
  });
@@ -18,14 +22,27 @@ test("getBudgetAlertLevel returns the expected threshold bucket", () => {
18
22
  test("getNewBudgetAlertLevel only emits once per threshold", () => {
19
23
  assert.equal(getNewBudgetAlertLevel(0, 0.74), null);
20
24
  assert.equal(getNewBudgetAlertLevel(0, 0.75), 75);
21
- assert.equal(getNewBudgetAlertLevel(75, 0.80), null);
22
- assert.equal(getNewBudgetAlertLevel(75, 0.90), 90);
25
+ assert.equal(getNewBudgetAlertLevel(75, 0.79), null);
26
+ assert.equal(getNewBudgetAlertLevel(75, 0.80), 80);
27
+ assert.equal(getNewBudgetAlertLevel(80, 0.85), null);
28
+ assert.equal(getNewBudgetAlertLevel(80, 0.90), 90);
23
29
  assert.equal(getNewBudgetAlertLevel(90, 0.95), null);
24
30
  assert.equal(getNewBudgetAlertLevel(90, 1.0), 100);
25
31
  assert.equal(getNewBudgetAlertLevel(100, 1.2), null);
26
32
  });
27
33
 
34
+ test("80% alert fires exactly once between 75% and 90%", () => {
35
+ // Transition from 75 → 80 emits 80
36
+ assert.equal(getNewBudgetAlertLevel(75, 0.80), 80);
37
+ // Already at 80 — no re-emission
38
+ assert.equal(getNewBudgetAlertLevel(80, 0.82), null);
39
+ assert.equal(getNewBudgetAlertLevel(80, 0.89), null);
40
+ // Transition from 80 → 90 emits 90
41
+ assert.equal(getNewBudgetAlertLevel(80, 0.90), 90);
42
+ });
43
+
28
44
  test("getBudgetEnforcementAction maps the configured ceiling behavior", () => {
45
+ assert.equal(getBudgetEnforcementAction("warn", 0.80), "none");
29
46
  assert.equal(getBudgetEnforcementAction("warn", 0.99), "none");
30
47
  assert.equal(getBudgetEnforcementAction("warn", 1.0), "warn");
31
48
  assert.equal(getBudgetEnforcementAction("pause", 1.0), "pause");