gsd-unsupervised 1.0.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 +263 -0
- package/bin/gsd-unsupervised +3 -0
- package/bin/start-daemon.sh +12 -0
- package/bin/unsupervised-gsd +2 -0
- package/dist/agent-runner.d.ts +26 -0
- package/dist/agent-runner.js +111 -0
- package/dist/agent-runner.spawn.test.d.ts +1 -0
- package/dist/agent-runner.spawn.test.js +128 -0
- package/dist/agent-runner.test.d.ts +1 -0
- package/dist/agent-runner.test.js +26 -0
- package/dist/bootstrap/wsl-bootstrap.d.ts +11 -0
- package/dist/bootstrap/wsl-bootstrap.js +14 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +172 -0
- package/dist/config/paths.d.ts +8 -0
- package/dist/config/paths.js +36 -0
- package/dist/config/wsl.d.ts +4 -0
- package/dist/config/wsl.js +43 -0
- package/dist/config.d.ts +79 -0
- package/dist/config.js +95 -0
- package/dist/config.test.d.ts +1 -0
- package/dist/config.test.js +27 -0
- package/dist/cursor-agent.d.ts +17 -0
- package/dist/cursor-agent.invoker.test.d.ts +1 -0
- package/dist/cursor-agent.invoker.test.js +150 -0
- package/dist/cursor-agent.js +156 -0
- package/dist/cursor-agent.test.d.ts +1 -0
- package/dist/cursor-agent.test.js +60 -0
- package/dist/daemon.d.ts +17 -0
- package/dist/daemon.js +374 -0
- package/dist/git.d.ts +23 -0
- package/dist/git.js +76 -0
- package/dist/goals.d.ts +34 -0
- package/dist/goals.js +148 -0
- package/dist/gsd-state.d.ts +49 -0
- package/dist/gsd-state.js +76 -0
- package/dist/init-wizard.d.ts +5 -0
- package/dist/init-wizard.js +96 -0
- package/dist/lifecycle.d.ts +41 -0
- package/dist/lifecycle.js +103 -0
- package/dist/lifecycle.test.d.ts +1 -0
- package/dist/lifecycle.test.js +116 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +31 -0
- package/dist/notifier.d.ts +6 -0
- package/dist/notifier.js +37 -0
- package/dist/orchestrator.d.ts +35 -0
- package/dist/orchestrator.js +791 -0
- package/dist/resource-governor.d.ts +54 -0
- package/dist/resource-governor.js +57 -0
- package/dist/resource-governor.test.d.ts +1 -0
- package/dist/resource-governor.test.js +33 -0
- package/dist/resume-pointer.d.ts +36 -0
- package/dist/resume-pointer.js +116 -0
- package/dist/roadmap-parser.d.ts +24 -0
- package/dist/roadmap-parser.js +105 -0
- package/dist/roadmap-parser.test.d.ts +1 -0
- package/dist/roadmap-parser.test.js +57 -0
- package/dist/session-log.d.ts +53 -0
- package/dist/session-log.js +92 -0
- package/dist/session-log.test.d.ts +1 -0
- package/dist/session-log.test.js +146 -0
- package/dist/state-index.d.ts +5 -0
- package/dist/state-index.js +31 -0
- package/dist/state-parser.d.ts +13 -0
- package/dist/state-parser.js +82 -0
- package/dist/state-parser.test.d.ts +1 -0
- package/dist/state-parser.test.js +228 -0
- package/dist/state-types.d.ts +20 -0
- package/dist/state-types.js +1 -0
- package/dist/state-watcher.d.ts +49 -0
- package/dist/state-watcher.js +148 -0
- package/dist/status-server.d.ts +112 -0
- package/dist/status-server.js +379 -0
- package/dist/status-server.test.d.ts +1 -0
- package/dist/status-server.test.js +206 -0
- package/dist/stream-events.d.ts +423 -0
- package/dist/stream-events.js +87 -0
- package/dist/stream-events.test.d.ts +1 -0
- package/dist/stream-events.test.js +304 -0
- package/dist/todos-api.d.ts +5 -0
- package/dist/todos-api.js +35 -0
- package/package.json +54 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export interface LoadInfo {
|
|
2
|
+
load1: number;
|
|
3
|
+
load5: number;
|
|
4
|
+
load15: number;
|
|
5
|
+
cpuCount: number;
|
|
6
|
+
/**
|
|
7
|
+
* Approximate fraction of total CPU capacity used, based on 1-minute load
|
|
8
|
+
* average divided by logical CPU count. This is a heuristic, not a precise
|
|
9
|
+
* utilization metric, but is stable and cheap to compute.
|
|
10
|
+
*/
|
|
11
|
+
cpuFraction: number;
|
|
12
|
+
/**
|
|
13
|
+
* Approximate fraction of total system memory in use, based on
|
|
14
|
+
* (total - free) / total from `os.totalmem()` / `os.freemem()`.
|
|
15
|
+
*/
|
|
16
|
+
memoryFraction: number;
|
|
17
|
+
totalMemBytes: number;
|
|
18
|
+
freeMemBytes: number;
|
|
19
|
+
}
|
|
20
|
+
export interface WaitForHeadroomOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Maximum allowed CPU fraction before new agent work is allowed to start.
|
|
23
|
+
* 1.0 means 100% of all logical CPUs; 0.75 (default) means ~75% of total.
|
|
24
|
+
*/
|
|
25
|
+
maxCpuFraction: number;
|
|
26
|
+
/**
|
|
27
|
+
* Maximum allowed memory fraction before new agent work is allowed to start.
|
|
28
|
+
* 1.0 means 100% of total RAM; 0.9 (default) means ~90%.
|
|
29
|
+
*/
|
|
30
|
+
maxMemoryFraction?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Minimum delay between load checks while waiting for headroom.
|
|
33
|
+
* Defaults to 2s to avoid busy-waiting.
|
|
34
|
+
*/
|
|
35
|
+
pollIntervalMs?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Optional absolute upper bound on wait time. When exceeded, the function
|
|
38
|
+
* resolves anyway so the daemon cannot deadlock if the machine is saturated.
|
|
39
|
+
* Defaults to 2 minutes.
|
|
40
|
+
*/
|
|
41
|
+
maxWaitMs?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Optional logger interface (subset of pino) used for structured logging.
|
|
44
|
+
*/
|
|
45
|
+
logger?: {
|
|
46
|
+
debug: (obj: unknown, msg?: string) => void;
|
|
47
|
+
warn: (obj: unknown, msg?: string) => void;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export declare function currentLoadInfo(maxCpuFraction?: number, maxMemoryFraction?: number): LoadInfo & {
|
|
51
|
+
maxCpuFraction?: number;
|
|
52
|
+
maxMemoryFraction?: number;
|
|
53
|
+
};
|
|
54
|
+
export declare function waitForHeadroom(options: WaitForHeadroomOptions): Promise<void>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
export function currentLoadInfo(maxCpuFraction, maxMemoryFraction) {
|
|
3
|
+
const [load1, load5, load15] = os.loadavg();
|
|
4
|
+
const cpuCount = Math.max(os.cpus()?.length ?? 1, 1);
|
|
5
|
+
const cpuFraction = cpuCount > 0 ? load1 / cpuCount : 0;
|
|
6
|
+
const totalMemBytes = os.totalmem();
|
|
7
|
+
const freeMemBytes = os.freemem();
|
|
8
|
+
const memoryFraction = totalMemBytes > 0 ? (totalMemBytes - freeMemBytes) / totalMemBytes : 0;
|
|
9
|
+
return {
|
|
10
|
+
load1,
|
|
11
|
+
load5,
|
|
12
|
+
load15,
|
|
13
|
+
cpuCount,
|
|
14
|
+
cpuFraction,
|
|
15
|
+
memoryFraction,
|
|
16
|
+
totalMemBytes,
|
|
17
|
+
freeMemBytes,
|
|
18
|
+
maxCpuFraction,
|
|
19
|
+
maxMemoryFraction,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export async function waitForHeadroom(options) {
|
|
23
|
+
const { maxCpuFraction, maxMemoryFraction, pollIntervalMs = 2000, maxWaitMs = 120_000, logger, } = options;
|
|
24
|
+
// Treat thresholds >= 1 as "no gating" for that resource to keep tests
|
|
25
|
+
// and opt-out configurations fast.
|
|
26
|
+
const validCpu = maxCpuFraction !== undefined && maxCpuFraction > 0 && maxCpuFraction < 1;
|
|
27
|
+
const validMem = maxMemoryFraction !== undefined && maxMemoryFraction > 0 && maxMemoryFraction < 1;
|
|
28
|
+
if (!validCpu && !validMem) {
|
|
29
|
+
// Misconfiguration — do not block orchestration, just log once and return.
|
|
30
|
+
logger?.warn({ maxCpuFraction, maxMemoryFraction }, 'resource-governor: invalid thresholds, skipping headroom check');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const start = Date.now();
|
|
34
|
+
// First, allow a cheap fast-path so we don't sleep when there's plenty of headroom.
|
|
35
|
+
let info = currentLoadInfo(maxCpuFraction, maxMemoryFraction);
|
|
36
|
+
const withinCpu = !validCpu || info.cpuFraction <= maxCpuFraction;
|
|
37
|
+
const withinMem = !validMem || info.memoryFraction <= maxMemoryFraction;
|
|
38
|
+
if (withinCpu && withinMem) {
|
|
39
|
+
logger?.debug({ load: info }, 'resource-governor: sufficient CPU headroom, proceeding immediately');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
logger?.warn({ load: info }, 'resource-governor: high system load detected, waiting for headroom');
|
|
43
|
+
// Slow-path: periodically poll until below threshold or timeout expires.
|
|
44
|
+
while ((!validCpu || info.cpuFraction > maxCpuFraction) ||
|
|
45
|
+
(!validMem || info.memoryFraction > maxMemoryFraction)) {
|
|
46
|
+
const elapsed = Date.now() - start;
|
|
47
|
+
if (elapsed >= maxWaitMs) {
|
|
48
|
+
logger?.warn({ load: info, elapsedMs: elapsed, maxWaitMs }, 'resource-governor: max wait exceeded, proceeding despite high load');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const remainingMs = maxWaitMs - elapsed;
|
|
52
|
+
const delayMs = Math.min(pollIntervalMs, remainingMs);
|
|
53
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
54
|
+
info = currentLoadInfo(maxCpuFraction, maxMemoryFraction);
|
|
55
|
+
}
|
|
56
|
+
logger?.debug({ load: info, waitedMs: Date.now() - start }, 'resource-governor: CPU headroom restored, resuming work');
|
|
57
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { currentLoadInfo, waitForHeadroom } from './resource-governor.js';
|
|
3
|
+
describe('resource-governor', () => {
|
|
4
|
+
it('returns sane currentLoadInfo shape', () => {
|
|
5
|
+
const info = currentLoadInfo();
|
|
6
|
+
expect(typeof info.load1).toBe('number');
|
|
7
|
+
expect(typeof info.load5).toBe('number');
|
|
8
|
+
expect(typeof info.load15).toBe('number');
|
|
9
|
+
expect(typeof info.cpuCount).toBe('number');
|
|
10
|
+
expect(typeof info.cpuFraction).toBe('number');
|
|
11
|
+
expect(info.cpuCount).toBeGreaterThan(0);
|
|
12
|
+
});
|
|
13
|
+
it('waitForHeadroom resolves quickly when load is already below threshold', async () => {
|
|
14
|
+
const logger = { debug: vi.fn(), warn: vi.fn() };
|
|
15
|
+
await expect(waitForHeadroom({
|
|
16
|
+
maxCpuFraction: 0.99,
|
|
17
|
+
pollIntervalMs: 10,
|
|
18
|
+
maxWaitMs: 1000,
|
|
19
|
+
logger,
|
|
20
|
+
})).resolves.toBeUndefined();
|
|
21
|
+
});
|
|
22
|
+
it('waitForHeadroom does not throw on invalid config', async () => {
|
|
23
|
+
const logger = { debug: vi.fn(), warn: vi.fn() };
|
|
24
|
+
await expect(waitForHeadroom({
|
|
25
|
+
// Invalid (> 1) but should not crash orchestration.
|
|
26
|
+
maxCpuFraction: 1.5,
|
|
27
|
+
pollIntervalMs: 10,
|
|
28
|
+
maxWaitMs: 50,
|
|
29
|
+
logger,
|
|
30
|
+
})).resolves.toBeUndefined();
|
|
31
|
+
expect(logger.warn).toHaveBeenCalled();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure computation of resume pointers from session log and STATE.md.
|
|
3
|
+
* Used by the daemon/orchestrator to resume from the last known successful
|
|
4
|
+
* phase/plan when a previous run was interrupted (crashed or running).
|
|
5
|
+
*
|
|
6
|
+
* This module has no side effects: it does not mutate files or start processes.
|
|
7
|
+
*/
|
|
8
|
+
/** Resume position for crash recovery. planNumber 0 means "first plan of this phase". */
|
|
9
|
+
export interface ResumePointer {
|
|
10
|
+
phaseNumber: number;
|
|
11
|
+
planNumber: number;
|
|
12
|
+
}
|
|
13
|
+
export interface ComputeResumePointerOpts {
|
|
14
|
+
/** Path to the session log file (JSONL). */
|
|
15
|
+
sessionLogPath: string;
|
|
16
|
+
/** Path to STATE.md. */
|
|
17
|
+
stateMdPath: string;
|
|
18
|
+
/** First pending goal title; only entries matching this goal are considered. */
|
|
19
|
+
goalTitle: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Derives the last known successful execution point from the session log.
|
|
23
|
+
* Returns null when:
|
|
24
|
+
* - Session log or STATE is missing/unreadable
|
|
25
|
+
* - No plan-complete or phase-complete entries exist for the goal
|
|
26
|
+
* - The last entry for the goal is not running/crashed (run completed cleanly)
|
|
27
|
+
* - STATE disagrees with the log in a way that indicates inconsistency (conservative fallback)
|
|
28
|
+
*
|
|
29
|
+
* plan-complete = status 'done' and phase '/gsd/execute-plan' with phaseNumber and planNumber
|
|
30
|
+
* phase-complete = status 'done' and phase '/gsd/plan-phase' with phaseNumber
|
|
31
|
+
*
|
|
32
|
+
* When the last plan-complete is phase X plan Y, the pointer is (X, Y+1) — resume at next plan.
|
|
33
|
+
* When the last phase-complete is phase X (no plan-complete in that phase), the pointer is (X+1, 0) — resume at first plan of next phase.
|
|
34
|
+
* planNumber 0 means "first plan of the indicated phase".
|
|
35
|
+
*/
|
|
36
|
+
export declare function computeResumePointer(opts: ComputeResumePointerOpts): Promise<ResumePointer | null>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure computation of resume pointers from session log and STATE.md.
|
|
3
|
+
* Used by the daemon/orchestrator to resume from the last known successful
|
|
4
|
+
* phase/plan when a previous run was interrupted (crashed or running).
|
|
5
|
+
*
|
|
6
|
+
* This module has no side effects: it does not mutate files or start processes.
|
|
7
|
+
*/
|
|
8
|
+
import { readSessionLog } from './session-log.js';
|
|
9
|
+
import { readStateMd } from './state-parser.js';
|
|
10
|
+
/**
|
|
11
|
+
* Derives the last known successful execution point from the session log.
|
|
12
|
+
* Returns null when:
|
|
13
|
+
* - Session log or STATE is missing/unreadable
|
|
14
|
+
* - No plan-complete or phase-complete entries exist for the goal
|
|
15
|
+
* - The last entry for the goal is not running/crashed (run completed cleanly)
|
|
16
|
+
* - STATE disagrees with the log in a way that indicates inconsistency (conservative fallback)
|
|
17
|
+
*
|
|
18
|
+
* plan-complete = status 'done' and phase '/gsd/execute-plan' with phaseNumber and planNumber
|
|
19
|
+
* phase-complete = status 'done' and phase '/gsd/plan-phase' with phaseNumber
|
|
20
|
+
*
|
|
21
|
+
* When the last plan-complete is phase X plan Y, the pointer is (X, Y+1) — resume at next plan.
|
|
22
|
+
* When the last phase-complete is phase X (no plan-complete in that phase), the pointer is (X+1, 0) — resume at first plan of next phase.
|
|
23
|
+
* planNumber 0 means "first plan of the indicated phase".
|
|
24
|
+
*/
|
|
25
|
+
export async function computeResumePointer(opts) {
|
|
26
|
+
const { sessionLogPath, stateMdPath, goalTitle } = opts;
|
|
27
|
+
const goalTrim = goalTitle.trim();
|
|
28
|
+
if (!goalTrim)
|
|
29
|
+
return null;
|
|
30
|
+
const entries = await readSessionLog(sessionLogPath);
|
|
31
|
+
if (entries.length === 0)
|
|
32
|
+
return null;
|
|
33
|
+
const stateSnapshot = await readStateMd(stateMdPath);
|
|
34
|
+
// Filter entries for this goal
|
|
35
|
+
const goalEntries = entries.filter((e) => (e.goalTitle ?? '').trim() === goalTrim);
|
|
36
|
+
if (goalEntries.length === 0)
|
|
37
|
+
return null;
|
|
38
|
+
// Only resume when the last entry for this goal is running or crashed
|
|
39
|
+
const lastForGoal = goalEntries[goalEntries.length - 1];
|
|
40
|
+
if (lastForGoal.status !== 'running' && lastForGoal.status !== 'crashed') {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
// Find last plan-complete (done + execute-plan with phaseNumber and planNumber)
|
|
44
|
+
const planComplete = findLastPlanComplete(goalEntries);
|
|
45
|
+
const phaseComplete = findLastPhaseComplete(goalEntries);
|
|
46
|
+
let pointer = null;
|
|
47
|
+
if (planComplete) {
|
|
48
|
+
// Resume at next plan in same phase
|
|
49
|
+
pointer = {
|
|
50
|
+
phaseNumber: planComplete.phaseNumber,
|
|
51
|
+
planNumber: (planComplete.planNumber ?? 0) + 1,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
else if (phaseComplete) {
|
|
55
|
+
// Resume at first plan of next phase
|
|
56
|
+
pointer = {
|
|
57
|
+
phaseNumber: (phaseComplete.phaseNumber ?? 0) + 1,
|
|
58
|
+
planNumber: 0,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (!pointer)
|
|
62
|
+
return null;
|
|
63
|
+
// Cross-check STATE: if STATE shows a phase in progress but we have no corresponding
|
|
64
|
+
// completion in the log, favor the more conservative (earlier) pointer
|
|
65
|
+
if (stateSnapshot) {
|
|
66
|
+
const statePhase = stateSnapshot.phaseNumber;
|
|
67
|
+
const statePlan = stateSnapshot.planNumber;
|
|
68
|
+
const statusLower = stateSnapshot.status.toLowerCase();
|
|
69
|
+
// STATE says we're in a phase with plans, but our pointer is ahead — be conservative
|
|
70
|
+
if (pointer.phaseNumber > statePhase ||
|
|
71
|
+
(pointer.phaseNumber === statePhase && pointer.planNumber > statePlan && statePlan > 0)) {
|
|
72
|
+
// STATE might be stale; if status suggests "in progress", use STATE as ceiling
|
|
73
|
+
if (statusLower.includes('executing') ||
|
|
74
|
+
statusLower.includes('planned') ||
|
|
75
|
+
statusLower.includes('resuming')) {
|
|
76
|
+
if (statePhase < pointer.phaseNumber) {
|
|
77
|
+
pointer = { phaseNumber: statePhase, planNumber: statePlan > 0 ? statePlan : 0 };
|
|
78
|
+
}
|
|
79
|
+
else if (statePhase === pointer.phaseNumber && statePlan > 0 && pointer.planNumber > statePlan) {
|
|
80
|
+
pointer = { phaseNumber: statePhase, planNumber: statePlan };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (pointer.phaseNumber < 1)
|
|
86
|
+
return null;
|
|
87
|
+
if (pointer.planNumber < 0)
|
|
88
|
+
return null;
|
|
89
|
+
return pointer;
|
|
90
|
+
}
|
|
91
|
+
function findLastPlanComplete(entries) {
|
|
92
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
93
|
+
const e = entries[i];
|
|
94
|
+
if (e.status === 'done' &&
|
|
95
|
+
e.phase === '/gsd/execute-plan' &&
|
|
96
|
+
typeof e.phaseNumber === 'number' &&
|
|
97
|
+
e.phaseNumber >= 1 &&
|
|
98
|
+
typeof e.planNumber === 'number' &&
|
|
99
|
+
e.planNumber >= 1) {
|
|
100
|
+
return e;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
function findLastPhaseComplete(entries) {
|
|
106
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
107
|
+
const e = entries[i];
|
|
108
|
+
if (e.status === 'done' &&
|
|
109
|
+
e.phase === '/gsd/plan-phase' &&
|
|
110
|
+
typeof e.phaseNumber === 'number' &&
|
|
111
|
+
e.phaseNumber >= 1) {
|
|
112
|
+
return e;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface PhaseInfo {
|
|
2
|
+
number: number;
|
|
3
|
+
name: string;
|
|
4
|
+
description: string;
|
|
5
|
+
dirName: string;
|
|
6
|
+
complete: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface PlanInfo {
|
|
9
|
+
phaseNumber: number;
|
|
10
|
+
planNumber: number;
|
|
11
|
+
planPath: string;
|
|
12
|
+
summaryPath: string;
|
|
13
|
+
executed: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function parseRoadmap(roadmapPath: string): Promise<PhaseInfo[]>;
|
|
16
|
+
export declare function findPhaseDir(phasesRoot: string, phaseNumber: number): string | null;
|
|
17
|
+
export declare function discoverPlans(phaseDir: string): Promise<PlanInfo[]>;
|
|
18
|
+
export declare function getNextUnexecutedPlan(plans: PlanInfo[]): PlanInfo | null;
|
|
19
|
+
export declare function isPhaseComplete(plans: PlanInfo[]): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Returns true iff the plan's SUMMARY file exists (auditable plan completion).
|
|
22
|
+
* Same heuristic as discoverPlans: plan XX-N-PLAN.md → XX-N-SUMMARY.md (N may be zero-padded).
|
|
23
|
+
*/
|
|
24
|
+
export declare function isPlanCompleted(phaseDir: string, planNumber: number): boolean;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
const PHASE_RE = /^- \[([ xX])\] \*\*Phase (\d+(?:\.\d+)?): (.+?)\*\* — (.+)$/;
|
|
5
|
+
const PLAN_FILE_RE = /^(\d+(?:\.\d+)?)-(\d+)-PLAN\.md$/;
|
|
6
|
+
function phaseNumberToPrefix(phaseNumber) {
|
|
7
|
+
if (Number.isInteger(phaseNumber)) {
|
|
8
|
+
return String(phaseNumber).padStart(2, '0');
|
|
9
|
+
}
|
|
10
|
+
const [intPart, decPart] = String(phaseNumber).split('.');
|
|
11
|
+
return `${intPart.padStart(2, '0')}.${decPart}`;
|
|
12
|
+
}
|
|
13
|
+
export async function parseRoadmap(roadmapPath) {
|
|
14
|
+
let content;
|
|
15
|
+
try {
|
|
16
|
+
content = await readFile(roadmapPath, 'utf-8');
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
|
|
20
|
+
throw new Error(`Roadmap file not found: ${roadmapPath}`);
|
|
21
|
+
}
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
if (content.trim().length === 0)
|
|
25
|
+
return [];
|
|
26
|
+
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
27
|
+
const phases = [];
|
|
28
|
+
for (const line of lines) {
|
|
29
|
+
const match = line.match(PHASE_RE);
|
|
30
|
+
if (!match)
|
|
31
|
+
continue;
|
|
32
|
+
const complete = match[1] === 'x' || match[1] === 'X';
|
|
33
|
+
const number = parseFloat(match[2]);
|
|
34
|
+
const name = match[3];
|
|
35
|
+
const description = match[4];
|
|
36
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
37
|
+
const dirName = `${phaseNumberToPrefix(number)}-${slug}`;
|
|
38
|
+
phases.push({ number, name, description, dirName, complete });
|
|
39
|
+
}
|
|
40
|
+
phases.sort((a, b) => a.number - b.number);
|
|
41
|
+
return phases;
|
|
42
|
+
}
|
|
43
|
+
export function findPhaseDir(phasesRoot, phaseNumber) {
|
|
44
|
+
if (!existsSync(phasesRoot))
|
|
45
|
+
return null;
|
|
46
|
+
const prefix = phaseNumberToPrefix(phaseNumber) + '-';
|
|
47
|
+
let entries;
|
|
48
|
+
try {
|
|
49
|
+
entries = readdirSync(phasesRoot, { withFileTypes: true })
|
|
50
|
+
.filter((d) => d.isDirectory())
|
|
51
|
+
.map((d) => d.name);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const match = entries.find((name) => name.startsWith(prefix));
|
|
57
|
+
return match ? join(phasesRoot, match) : null;
|
|
58
|
+
}
|
|
59
|
+
export async function discoverPlans(phaseDir) {
|
|
60
|
+
let entries;
|
|
61
|
+
try {
|
|
62
|
+
entries = await readdir(phaseDir);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
|
|
66
|
+
throw new Error(`Phase directory not found: ${phaseDir}`);
|
|
67
|
+
}
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
const plans = [];
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const match = entry.match(PLAN_FILE_RE);
|
|
73
|
+
if (!match)
|
|
74
|
+
continue;
|
|
75
|
+
const phaseNumber = parseFloat(match[1]);
|
|
76
|
+
const planNumber = parseInt(match[2], 10);
|
|
77
|
+
const planPath = join(phaseDir, entry);
|
|
78
|
+
const summaryFile = entry.replace('-PLAN.md', '-SUMMARY.md');
|
|
79
|
+
const summaryPath = join(phaseDir, summaryFile);
|
|
80
|
+
const executed = existsSync(summaryPath);
|
|
81
|
+
plans.push({ phaseNumber, planNumber, planPath, summaryPath, executed });
|
|
82
|
+
}
|
|
83
|
+
plans.sort((a, b) => a.planNumber - b.planNumber);
|
|
84
|
+
return plans;
|
|
85
|
+
}
|
|
86
|
+
export function getNextUnexecutedPlan(plans) {
|
|
87
|
+
return plans.find((p) => !p.executed) ?? null;
|
|
88
|
+
}
|
|
89
|
+
export function isPhaseComplete(plans) {
|
|
90
|
+
return plans.length > 0 && plans.every((p) => p.executed);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Returns true iff the plan's SUMMARY file exists (auditable plan completion).
|
|
94
|
+
* Same heuristic as discoverPlans: plan XX-N-PLAN.md → XX-N-SUMMARY.md (N may be zero-padded).
|
|
95
|
+
*/
|
|
96
|
+
export function isPlanCompleted(phaseDir, planNumber) {
|
|
97
|
+
const plans = readdirSync(phaseDir);
|
|
98
|
+
const summarySuffixRe = new RegExp(`-(\\d+)-SUMMARY\\.md$`);
|
|
99
|
+
return plans.some((name) => {
|
|
100
|
+
const m = name.match(summarySuffixRe);
|
|
101
|
+
if (!m)
|
|
102
|
+
return false;
|
|
103
|
+
return parseInt(m[1], 10) === planNumber && existsSync(join(phaseDir, name));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { findPhaseDir, discoverPlans, isPlanCompleted } from './roadmap-parser.js';
|
|
6
|
+
describe('roadmap-parser', () => {
|
|
7
|
+
let phasesRoot;
|
|
8
|
+
let phaseDir;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
phasesRoot = mkdtempSync(join(tmpdir(), 'roadmap-test-'));
|
|
11
|
+
phaseDir = join(phasesRoot, '05-crash-detection-recovery');
|
|
12
|
+
mkdirSync(phaseDir, { recursive: true });
|
|
13
|
+
});
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
try {
|
|
16
|
+
rmSync(phasesRoot, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// ignore
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
describe('isPlanCompleted', () => {
|
|
23
|
+
it('returns true when SUMMARY exists for plan number', () => {
|
|
24
|
+
writeFileSync(join(phaseDir, '05-01-PLAN.md'), '# Plan', 'utf-8');
|
|
25
|
+
writeFileSync(join(phaseDir, '05-01-SUMMARY.md'), '# Summary', 'utf-8');
|
|
26
|
+
expect(isPlanCompleted(phaseDir, 1)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
it('returns false when only PLAN exists', () => {
|
|
29
|
+
writeFileSync(join(phaseDir, '05-01-PLAN.md'), '# Plan', 'utf-8');
|
|
30
|
+
expect(isPlanCompleted(phaseDir, 1)).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
it('returns false when SUMMARY does not exist for plan number', () => {
|
|
33
|
+
writeFileSync(join(phaseDir, '05-02-PLAN.md'), '# Plan', 'utf-8');
|
|
34
|
+
expect(isPlanCompleted(phaseDir, 2)).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
it('returns true for plan 2 when 05-02-SUMMARY.md exists', () => {
|
|
37
|
+
writeFileSync(join(phaseDir, '05-02-PLAN.md'), '# Plan', 'utf-8');
|
|
38
|
+
writeFileSync(join(phaseDir, '05-02-SUMMARY.md'), '# Summary', 'utf-8');
|
|
39
|
+
expect(isPlanCompleted(phaseDir, 2)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('findPhaseDir and discoverPlans', () => {
|
|
43
|
+
it('findPhaseDir finds phase directory by number', () => {
|
|
44
|
+
const dir = findPhaseDir(phasesRoot, 5);
|
|
45
|
+
expect(dir).toBe(phaseDir);
|
|
46
|
+
});
|
|
47
|
+
it('discoverPlans marks executed when SUMMARY exists', async () => {
|
|
48
|
+
writeFileSync(join(phaseDir, '05-01-PLAN.md'), '', 'utf-8');
|
|
49
|
+
writeFileSync(join(phaseDir, '05-01-SUMMARY.md'), '', 'utf-8');
|
|
50
|
+
writeFileSync(join(phaseDir, '05-02-PLAN.md'), '', 'utf-8');
|
|
51
|
+
const plans = await discoverPlans(phaseDir);
|
|
52
|
+
expect(plans).toHaveLength(2);
|
|
53
|
+
expect(plans[0].executed).toBe(true);
|
|
54
|
+
expect(plans[1].executed).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Context passed when invoking the agent for session log entries (goal/phase/plan). */
|
|
2
|
+
export interface SessionLogContext {
|
|
3
|
+
goalTitle: string;
|
|
4
|
+
phaseNumber?: number;
|
|
5
|
+
planNumber?: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Session log schema (append-only, one JSON object per line).
|
|
9
|
+
* - timestamp: ISO string
|
|
10
|
+
* - goalTitle: string
|
|
11
|
+
* - phase: command label (e.g. /gsd/execute-plan)
|
|
12
|
+
* - phaseNumber, planNumber: optional; set when in phase/plan loop for crash recovery
|
|
13
|
+
* - sessionId: string | null
|
|
14
|
+
* - command: full command string
|
|
15
|
+
* - status: 'running' | 'done' | 'crashed' | 'timeout'
|
|
16
|
+
* - durationMs, error: optional
|
|
17
|
+
* No in-place edits; append only.
|
|
18
|
+
*/
|
|
19
|
+
export interface SessionLogEntry {
|
|
20
|
+
timestamp: string;
|
|
21
|
+
goalTitle: string;
|
|
22
|
+
phase: string;
|
|
23
|
+
phaseNumber?: number;
|
|
24
|
+
planNumber?: number;
|
|
25
|
+
sessionId: string | null;
|
|
26
|
+
command: string;
|
|
27
|
+
status: 'running' | 'done' | 'crashed' | 'timeout';
|
|
28
|
+
durationMs?: number;
|
|
29
|
+
error?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function appendSessionLog(logPath: string, entry: SessionLogEntry): Promise<void>;
|
|
32
|
+
export declare function readSessionLog(logPath: string): Promise<SessionLogEntry[]>;
|
|
33
|
+
/**
|
|
34
|
+
* Returns the last entry with status 'running'. Legacy alias for
|
|
35
|
+
* inspectForCrashedSessions when only 'running' is needed.
|
|
36
|
+
*/
|
|
37
|
+
export declare function getLastRunningSession(logPath: string): Promise<SessionLogEntry | null>;
|
|
38
|
+
/**
|
|
39
|
+
* Returns the most recent entry if its status is 'running' or 'crashed', else null.
|
|
40
|
+
* Used for crash recovery to detect an interrupted session. Skips malformed lines.
|
|
41
|
+
*/
|
|
42
|
+
export declare function inspectForCrashedSessions(logPath: string): Promise<SessionLogEntry | null>;
|
|
43
|
+
/** Resume position for crash recovery; only when unambiguous. */
|
|
44
|
+
export interface ResumeFrom {
|
|
45
|
+
phaseNumber: number;
|
|
46
|
+
planNumber: number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Computes a deterministic resume point when the last session was crashed or running.
|
|
50
|
+
* Returns null on ambiguity (empty log, goal mismatch, or missing phase/plan).
|
|
51
|
+
* Derives resume point from SUMMARY file existence via roadmap-parser, not from STATE.md.
|
|
52
|
+
*/
|
|
53
|
+
export declare function computeResumePoint(sessionLogPath: string, workspaceRoot: string, firstPendingGoalTitle: string): Promise<ResumeFrom | null>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { readFile, appendFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { parseRoadmap, findPhaseDir, discoverPlans, getNextUnexecutedPlan } from './roadmap-parser.js';
|
|
5
|
+
export async function appendSessionLog(logPath, entry) {
|
|
6
|
+
const line = JSON.stringify(entry) + '\n';
|
|
7
|
+
try {
|
|
8
|
+
const dir = dirname(logPath);
|
|
9
|
+
if (dir && dir !== '.') {
|
|
10
|
+
await mkdir(dir, { recursive: true }).catch(() => { });
|
|
11
|
+
}
|
|
12
|
+
await appendFile(logPath, line, { encoding: 'utf-8', flag: 'a' });
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// Best-effort only: logging failures must never crash the daemon.
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function readSessionLog(logPath) {
|
|
19
|
+
let content;
|
|
20
|
+
try {
|
|
21
|
+
content = await readFile(logPath, 'utf-8');
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Missing or unreadable log is treated as "no history" for callers.
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
const entries = [];
|
|
28
|
+
for (const line of content.split('\n')) {
|
|
29
|
+
const trimmed = line.trim();
|
|
30
|
+
if (!trimmed)
|
|
31
|
+
continue;
|
|
32
|
+
try {
|
|
33
|
+
entries.push(JSON.parse(trimmed));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// skip malformed lines
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return entries;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Returns the last entry with status 'running'. Legacy alias for
|
|
43
|
+
* inspectForCrashedSessions when only 'running' is needed.
|
|
44
|
+
*/
|
|
45
|
+
export async function getLastRunningSession(logPath) {
|
|
46
|
+
const entries = await readSessionLog(logPath);
|
|
47
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
48
|
+
if (entries[i].status === 'running') {
|
|
49
|
+
return entries[i];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Returns the most recent entry if its status is 'running' or 'crashed', else null.
|
|
56
|
+
* Used for crash recovery to detect an interrupted session. Skips malformed lines.
|
|
57
|
+
*/
|
|
58
|
+
export async function inspectForCrashedSessions(logPath) {
|
|
59
|
+
const entries = await readSessionLog(logPath);
|
|
60
|
+
if (entries.length === 0)
|
|
61
|
+
return null;
|
|
62
|
+
const last = entries[entries.length - 1];
|
|
63
|
+
return last.status === 'running' || last.status === 'crashed' ? last : null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Computes a deterministic resume point when the last session was crashed or running.
|
|
67
|
+
* Returns null on ambiguity (empty log, goal mismatch, or missing phase/plan).
|
|
68
|
+
* Derives resume point from SUMMARY file existence via roadmap-parser, not from STATE.md.
|
|
69
|
+
*/
|
|
70
|
+
export async function computeResumePoint(sessionLogPath, workspaceRoot, firstPendingGoalTitle) {
|
|
71
|
+
const entry = await inspectForCrashedSessions(sessionLogPath);
|
|
72
|
+
const goalTrim = firstPendingGoalTitle.trim();
|
|
73
|
+
if (!entry || !goalTrim)
|
|
74
|
+
return null;
|
|
75
|
+
if (entry.goalTitle.trim() !== goalTrim)
|
|
76
|
+
return null;
|
|
77
|
+
const roadmapPath = path.join(workspaceRoot, '.planning', 'ROADMAP.md');
|
|
78
|
+
const phasesRoot = path.join(workspaceRoot, '.planning', 'phases');
|
|
79
|
+
const phases = await parseRoadmap(roadmapPath);
|
|
80
|
+
for (let i = 0; i < phases.length; i++) {
|
|
81
|
+
const phase = phases[i];
|
|
82
|
+
const phaseDir = findPhaseDir(phasesRoot, phase.number);
|
|
83
|
+
if (!phaseDir)
|
|
84
|
+
continue;
|
|
85
|
+
const plans = await discoverPlans(phaseDir);
|
|
86
|
+
const next = getNextUnexecutedPlan(plans);
|
|
87
|
+
if (next) {
|
|
88
|
+
return { phaseNumber: i + 1, planNumber: next.planNumber };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|