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,146 @@
|
|
|
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 { appendSessionLog, readSessionLog, inspectForCrashedSessions, getLastRunningSession, computeResumePoint, } from './session-log.js';
|
|
6
|
+
describe('session-log', () => {
|
|
7
|
+
let logPath;
|
|
8
|
+
let tmpDir;
|
|
9
|
+
let workspaceRoot;
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'session-log-test-'));
|
|
12
|
+
logPath = join(tmpDir, 'session-log.jsonl');
|
|
13
|
+
workspaceRoot = join(tmpDir, 'workspace');
|
|
14
|
+
mkdirSync(join(workspaceRoot, '.planning', 'phases'), { recursive: true });
|
|
15
|
+
});
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
try {
|
|
18
|
+
rmSync(tmpDir, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// ignore
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
const entry = (status, overrides) => ({
|
|
25
|
+
timestamp: new Date().toISOString(),
|
|
26
|
+
goalTitle: 'Test goal',
|
|
27
|
+
phase: '/gsd/execute-plan',
|
|
28
|
+
phaseNumber: 1,
|
|
29
|
+
planNumber: 1,
|
|
30
|
+
sessionId: null,
|
|
31
|
+
command: '/gsd/execute-plan .planning/phases/01-x/01-01-PLAN.md',
|
|
32
|
+
status,
|
|
33
|
+
...overrides,
|
|
34
|
+
});
|
|
35
|
+
describe('appendSessionLog and readSessionLog', () => {
|
|
36
|
+
it('appends one line and readSessionLog returns entries', async () => {
|
|
37
|
+
await appendSessionLog(logPath, entry('running'));
|
|
38
|
+
const entries = await readSessionLog(logPath);
|
|
39
|
+
expect(entries).toHaveLength(1);
|
|
40
|
+
expect(entries[0].status).toBe('running');
|
|
41
|
+
expect(entries[0].goalTitle).toBe('Test goal');
|
|
42
|
+
});
|
|
43
|
+
it('appends multiple lines', async () => {
|
|
44
|
+
await appendSessionLog(logPath, entry('running'));
|
|
45
|
+
await appendSessionLog(logPath, entry('done', { durationMs: 100 }));
|
|
46
|
+
const entries = await readSessionLog(logPath);
|
|
47
|
+
expect(entries).toHaveLength(2);
|
|
48
|
+
expect(entries[0].status).toBe('running');
|
|
49
|
+
expect(entries[1].status).toBe('done');
|
|
50
|
+
expect(entries[1].durationMs).toBe(100);
|
|
51
|
+
});
|
|
52
|
+
it('skips malformed lines', async () => {
|
|
53
|
+
writeFileSync(logPath, '{"status":"running"}\nnot json\n{"timestamp":"x","goalTitle":"","phase":"","sessionId":null,"command":"","status":"done"}\n', 'utf-8');
|
|
54
|
+
const entries = await readSessionLog(logPath);
|
|
55
|
+
expect(entries).toHaveLength(2);
|
|
56
|
+
});
|
|
57
|
+
it('returns empty array for missing file', async () => {
|
|
58
|
+
const entries = await readSessionLog(join(tmpDir, 'nonexistent.jsonl'));
|
|
59
|
+
expect(entries).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe('inspectForCrashedSessions', () => {
|
|
63
|
+
it('returns null for empty log', async () => {
|
|
64
|
+
writeFileSync(logPath, '', 'utf-8');
|
|
65
|
+
const got = await inspectForCrashedSessions(logPath);
|
|
66
|
+
expect(got).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
it('returns null when last entry is done', async () => {
|
|
69
|
+
await appendSessionLog(logPath, entry('running'));
|
|
70
|
+
await appendSessionLog(logPath, entry('done'));
|
|
71
|
+
const got = await inspectForCrashedSessions(logPath);
|
|
72
|
+
expect(got).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
it('returns null when last entry is timeout', async () => {
|
|
75
|
+
await appendSessionLog(logPath, entry('timeout'));
|
|
76
|
+
const got = await inspectForCrashedSessions(logPath);
|
|
77
|
+
expect(got).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
it('returns last entry when last is running', async () => {
|
|
80
|
+
await appendSessionLog(logPath, entry('done'));
|
|
81
|
+
await appendSessionLog(logPath, entry('running', { goalTitle: 'Current' }));
|
|
82
|
+
const got = await inspectForCrashedSessions(logPath);
|
|
83
|
+
expect(got).not.toBeNull();
|
|
84
|
+
expect(got.status).toBe('running');
|
|
85
|
+
expect(got.goalTitle).toBe('Current');
|
|
86
|
+
});
|
|
87
|
+
it('returns last entry when last is crashed', async () => {
|
|
88
|
+
await appendSessionLog(logPath, entry('running'));
|
|
89
|
+
await appendSessionLog(logPath, entry('crashed', { error: 'exit 1' }));
|
|
90
|
+
const got = await inspectForCrashedSessions(logPath);
|
|
91
|
+
expect(got).not.toBeNull();
|
|
92
|
+
expect(got.status).toBe('crashed');
|
|
93
|
+
expect(got.error).toBe('exit 1');
|
|
94
|
+
});
|
|
95
|
+
it('skips malformed lines and returns last valid running/crashed', async () => {
|
|
96
|
+
writeFileSync(logPath, 'garbage\n', 'utf-8');
|
|
97
|
+
await appendSessionLog(logPath, entry('crashed'));
|
|
98
|
+
const got = await inspectForCrashedSessions(logPath);
|
|
99
|
+
expect(got).not.toBeNull();
|
|
100
|
+
expect(got.status).toBe('crashed');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe('getLastRunningSession', () => {
|
|
104
|
+
it('returns last running entry', async () => {
|
|
105
|
+
await appendSessionLog(logPath, entry('done'));
|
|
106
|
+
await appendSessionLog(logPath, entry('running', { goalTitle: 'A' }));
|
|
107
|
+
const got = await getLastRunningSession(logPath);
|
|
108
|
+
expect(got).not.toBeNull();
|
|
109
|
+
expect(got.goalTitle).toBe('A');
|
|
110
|
+
});
|
|
111
|
+
it('returns null when no running', async () => {
|
|
112
|
+
await appendSessionLog(logPath, entry('done'));
|
|
113
|
+
const got = await getLastRunningSession(logPath);
|
|
114
|
+
expect(got).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
describe('computeResumePoint', () => {
|
|
118
|
+
it('returns null for empty log', async () => {
|
|
119
|
+
const got = await computeResumePoint(logPath, workspaceRoot, 'My Goal');
|
|
120
|
+
expect(got).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
it('returns null when goal title mismatch', async () => {
|
|
123
|
+
await appendSessionLog(logPath, entry('crashed', { goalTitle: 'Other Goal' }));
|
|
124
|
+
const got = await computeResumePoint(logPath, workspaceRoot, 'My Goal');
|
|
125
|
+
expect(got).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
it('returns ResumeFrom based on first missing SUMMARY file', async () => {
|
|
128
|
+
// ROADMAP: one phase, incomplete
|
|
129
|
+
writeFileSync(join(workspaceRoot, '.planning', 'ROADMAP.md'), '- [ ] **Phase 1: Alpha** — Test phase\n', 'utf-8');
|
|
130
|
+
const phaseDir = join(workspaceRoot, '.planning', 'phases', '01-alpha');
|
|
131
|
+
mkdirSync(phaseDir, { recursive: true });
|
|
132
|
+
// Plan 1 executed, plan 2 not executed
|
|
133
|
+
writeFileSync(join(phaseDir, '01-01-PLAN.md'), '# Plan 1\n', 'utf-8');
|
|
134
|
+
writeFileSync(join(phaseDir, '01-01-SUMMARY.md'), '# Summary 1\n', 'utf-8');
|
|
135
|
+
writeFileSync(join(phaseDir, '01-02-PLAN.md'), '# Plan 2\n', 'utf-8');
|
|
136
|
+
await appendSessionLog(logPath, entry('crashed', { goalTitle: 'My Goal' }));
|
|
137
|
+
const got = await computeResumePoint(logPath, workspaceRoot, 'My Goal');
|
|
138
|
+
expect(got).toEqual({ phaseNumber: 1, planNumber: 2 });
|
|
139
|
+
});
|
|
140
|
+
it('returns null when firstPendingGoalTitle is empty', async () => {
|
|
141
|
+
await appendSessionLog(logPath, entry('crashed', { goalTitle: 'My Goal' }));
|
|
142
|
+
const got = await computeResumePoint(logPath, workspaceRoot, '');
|
|
143
|
+
expect(got).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Logger } from './logger.js';
|
|
2
|
+
import type { StateSnapshot } from './state-types.js';
|
|
3
|
+
export type { StateSnapshot } from './state-types.js';
|
|
4
|
+
export declare function parseStateFile(contents: string, logger?: Logger): StateSnapshot | null;
|
|
5
|
+
export declare function readStateFile(filePath: string, logger?: Logger): Promise<StateSnapshot | null>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { parseStateMd, readStateMd } from './state-parser.js';
|
|
2
|
+
export function parseStateFile(contents, logger) {
|
|
3
|
+
try {
|
|
4
|
+
const snapshot = parseStateMd(contents);
|
|
5
|
+
if (!snapshot && logger) {
|
|
6
|
+
logger.warn({ reason: 'unparseable_state_md' }, 'Failed to parse STATE.md contents');
|
|
7
|
+
}
|
|
8
|
+
return snapshot;
|
|
9
|
+
}
|
|
10
|
+
catch (err) {
|
|
11
|
+
if (logger) {
|
|
12
|
+
logger.warn({ err }, 'Exception while parsing STATE.md contents');
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function readStateFile(filePath, logger) {
|
|
18
|
+
try {
|
|
19
|
+
const snapshot = await readStateMd(filePath);
|
|
20
|
+
if (!snapshot && logger) {
|
|
21
|
+
logger.warn({ path: filePath, reason: 'unparseable_or_missing' }, 'Failed to read STATE.md');
|
|
22
|
+
}
|
|
23
|
+
return snapshot;
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
if (logger) {
|
|
27
|
+
logger.warn({ err, path: filePath }, 'Exception while reading STATE.md');
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { StateSnapshot } from './state-types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Parses the "## Current Position" section of STATE.md into a StateSnapshot.
|
|
4
|
+
* Returns null if the section is missing, content is empty/whitespace, or any
|
|
5
|
+
* required field (phase line with "of", plan line, status) is missing or malformed.
|
|
6
|
+
* progressPercent is null when the progress line is absent or has no percentage.
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseStateMd(content: string): StateSnapshot | null;
|
|
9
|
+
/**
|
|
10
|
+
* Reads STATE.md from the given file path and returns a StateSnapshot.
|
|
11
|
+
* Returns null if the file is missing, unreadable, or content fails to parse.
|
|
12
|
+
*/
|
|
13
|
+
export declare function readStateMd(filePath: string): Promise<StateSnapshot | null>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
const SECTION_RE = /## Current Position\n([\s\S]*?)(?=\n## |$)/;
|
|
3
|
+
const PHASE_RE = /^Phase:\s*(\d+)\s+of\s+(\d+)\s*\(([^)]+)\)\s*$/m;
|
|
4
|
+
const PLAN_RE = /^Plan:\s*(\d+)\s+of\s+(\d+)\s+in current phase\s*$/m;
|
|
5
|
+
const STATUS_RE = /^Status:\s*(.+)$/m;
|
|
6
|
+
const LAST_ACTIVITY_RE = /^Last activity:\s*(.+)$/m;
|
|
7
|
+
const PROGRESS_RE = /^Progress:\s*[^\n]*?(\d+)%\s*$/m;
|
|
8
|
+
const GIT_SHA_RE = /^Git SHA:\s*(.+)$/m;
|
|
9
|
+
/**
|
|
10
|
+
* Parses the "## Current Position" section of STATE.md into a StateSnapshot.
|
|
11
|
+
* Returns null if the section is missing, content is empty/whitespace, or any
|
|
12
|
+
* required field (phase line with "of", plan line, status) is missing or malformed.
|
|
13
|
+
* progressPercent is null when the progress line is absent or has no percentage.
|
|
14
|
+
*/
|
|
15
|
+
export function parseStateMd(content) {
|
|
16
|
+
if (typeof content !== 'string' || content.trim().length === 0) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const sectionMatch = content.match(SECTION_RE);
|
|
20
|
+
if (!sectionMatch) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const block = sectionMatch[1];
|
|
24
|
+
const phaseMatch = block.match(PHASE_RE);
|
|
25
|
+
if (!phaseMatch) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const phaseNumber = parseInt(phaseMatch[1], 10);
|
|
29
|
+
const totalPhases = parseInt(phaseMatch[2], 10);
|
|
30
|
+
const phaseName = phaseMatch[3].trim();
|
|
31
|
+
const planMatch = block.match(PLAN_RE);
|
|
32
|
+
if (!planMatch) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const planNumber = parseInt(planMatch[1], 10);
|
|
36
|
+
const totalPlans = parseInt(planMatch[2], 10);
|
|
37
|
+
const statusMatch = block.match(STATUS_RE);
|
|
38
|
+
if (!statusMatch) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const status = statusMatch[1].trim();
|
|
42
|
+
let lastActivity = '';
|
|
43
|
+
const lastActivityMatch = block.match(LAST_ACTIVITY_RE);
|
|
44
|
+
if (lastActivityMatch) {
|
|
45
|
+
lastActivity = lastActivityMatch[1].trim();
|
|
46
|
+
}
|
|
47
|
+
let progressPercent = null;
|
|
48
|
+
const progressMatch = block.match(PROGRESS_RE);
|
|
49
|
+
if (progressMatch) {
|
|
50
|
+
progressPercent = parseInt(progressMatch[1], 10);
|
|
51
|
+
}
|
|
52
|
+
let gitSha = null;
|
|
53
|
+
const gitMatch = content.match(GIT_SHA_RE);
|
|
54
|
+
if (gitMatch && gitMatch[1].trim().length > 0) {
|
|
55
|
+
gitSha = gitMatch[1].trim();
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
phaseNumber,
|
|
59
|
+
totalPhases,
|
|
60
|
+
phaseName,
|
|
61
|
+
planNumber,
|
|
62
|
+
totalPlans,
|
|
63
|
+
status,
|
|
64
|
+
lastActivity,
|
|
65
|
+
progressPercent,
|
|
66
|
+
gitSha,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Reads STATE.md from the given file path and returns a StateSnapshot.
|
|
71
|
+
* Returns null if the file is missing, unreadable, or content fails to parse.
|
|
72
|
+
*/
|
|
73
|
+
export async function readStateMd(filePath) {
|
|
74
|
+
let content;
|
|
75
|
+
try {
|
|
76
|
+
content = await readFile(filePath, 'utf-8');
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return parseStateMd(content);
|
|
82
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseStateMd, readStateMd } from './state-parser.js';
|
|
3
|
+
const STANDARD_BLOCK = `
|
|
4
|
+
## Current Position
|
|
5
|
+
|
|
6
|
+
Phase: 3 of 7 (Cursor Agent Integration)
|
|
7
|
+
Plan: 3 of 3 in current phase
|
|
8
|
+
Status: Phase complete
|
|
9
|
+
Last activity: 2026-03-16 — Completed 03-03-PLAN.md — Phase 3 complete
|
|
10
|
+
|
|
11
|
+
Progress: ██████░░░░ 43%
|
|
12
|
+
`;
|
|
13
|
+
describe('parseStateMd', () => {
|
|
14
|
+
it('parses standard content with all fields', () => {
|
|
15
|
+
const got = parseStateMd(STANDARD_BLOCK);
|
|
16
|
+
expect(got).not.toBeNull();
|
|
17
|
+
expect(got).toEqual({
|
|
18
|
+
phaseNumber: 3,
|
|
19
|
+
totalPhases: 7,
|
|
20
|
+
phaseName: 'Cursor Agent Integration',
|
|
21
|
+
planNumber: 3,
|
|
22
|
+
totalPlans: 3,
|
|
23
|
+
status: 'Phase complete',
|
|
24
|
+
lastActivity: '2026-03-16 — Completed 03-03-PLAN.md — Phase 3 complete',
|
|
25
|
+
progressPercent: 43,
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
it('parses phase in-progress (Executing plan)', () => {
|
|
29
|
+
const content = `
|
|
30
|
+
## Current Position
|
|
31
|
+
|
|
32
|
+
Phase: 2 of 7 (Core Orchestration Loop)
|
|
33
|
+
Plan: 1 of 3 in current phase
|
|
34
|
+
Status: Executing plan
|
|
35
|
+
Last activity: 2026-03-16 — Running 02-01-PLAN.md
|
|
36
|
+
|
|
37
|
+
Progress: ██░░░░░░░░ 14%
|
|
38
|
+
`;
|
|
39
|
+
const got = parseStateMd(content);
|
|
40
|
+
expect(got).not.toBeNull();
|
|
41
|
+
expect(got.status).toBe('Executing plan');
|
|
42
|
+
expect(got.phaseNumber).toBe(2);
|
|
43
|
+
expect(got.planNumber).toBe(1);
|
|
44
|
+
});
|
|
45
|
+
it('parses plan 1 of 1 in phase', () => {
|
|
46
|
+
const content = `
|
|
47
|
+
## Current Position
|
|
48
|
+
|
|
49
|
+
Phase: 1 of 7 (Foundation & CLI Scaffold)
|
|
50
|
+
Plan: 1 of 1 in current phase
|
|
51
|
+
Status: Planned
|
|
52
|
+
Last activity: 2026-03-16 — Not started
|
|
53
|
+
|
|
54
|
+
Progress: ░░░░░░░░░░ 0%
|
|
55
|
+
`;
|
|
56
|
+
const got = parseStateMd(content);
|
|
57
|
+
expect(got).not.toBeNull();
|
|
58
|
+
expect(got.planNumber).toBe(1);
|
|
59
|
+
expect(got.totalPlans).toBe(1);
|
|
60
|
+
});
|
|
61
|
+
it('returns null when "Current Position" section is missing', () => {
|
|
62
|
+
const content = `
|
|
63
|
+
# Project State
|
|
64
|
+
|
|
65
|
+
## Some Other Section
|
|
66
|
+
|
|
67
|
+
Phase: 1 of 7 (Foundation)
|
|
68
|
+
Plan: 1 of 1 in current phase
|
|
69
|
+
Status: Planned
|
|
70
|
+
`;
|
|
71
|
+
expect(parseStateMd(content)).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
it('returns null for empty content', () => {
|
|
74
|
+
expect(parseStateMd('')).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
it('returns null for whitespace-only content', () => {
|
|
77
|
+
expect(parseStateMd(' \n\n \t ')).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
it('returns null when phase line has no "of" (malformed)', () => {
|
|
80
|
+
const content = `
|
|
81
|
+
## Current Position
|
|
82
|
+
|
|
83
|
+
Phase: 3 (Cursor Agent Integration)
|
|
84
|
+
Plan: 3 of 3 in current phase
|
|
85
|
+
Status: Phase complete
|
|
86
|
+
Last activity: 2026-03-16
|
|
87
|
+
`;
|
|
88
|
+
expect(parseStateMd(content)).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
it('returns null when plan line is missing', () => {
|
|
91
|
+
const content = `
|
|
92
|
+
## Current Position
|
|
93
|
+
|
|
94
|
+
Phase: 3 of 7 (Cursor Agent Integration)
|
|
95
|
+
Status: Phase complete
|
|
96
|
+
Last activity: 2026-03-16
|
|
97
|
+
`;
|
|
98
|
+
expect(parseStateMd(content)).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
it('returns null when status line is missing', () => {
|
|
101
|
+
const content = `
|
|
102
|
+
## Current Position
|
|
103
|
+
|
|
104
|
+
Phase: 3 of 7 (Cursor Agent Integration)
|
|
105
|
+
Plan: 3 of 3 in current phase
|
|
106
|
+
Last activity: 2026-03-16
|
|
107
|
+
`;
|
|
108
|
+
expect(parseStateMd(content)).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
it('sets progressPercent to null when progress line has no percentage', () => {
|
|
111
|
+
const content = `
|
|
112
|
+
## Current Position
|
|
113
|
+
|
|
114
|
+
Phase: 3 of 7 (Cursor Agent Integration)
|
|
115
|
+
Plan: 3 of 3 in current phase
|
|
116
|
+
Status: Phase complete
|
|
117
|
+
Last activity: 2026-03-16
|
|
118
|
+
|
|
119
|
+
Progress: ██████░░░░
|
|
120
|
+
`;
|
|
121
|
+
const got = parseStateMd(content);
|
|
122
|
+
expect(got).not.toBeNull();
|
|
123
|
+
expect(got.progressPercent).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
it('sets progressPercent to null when progress line is missing', () => {
|
|
126
|
+
const content = `
|
|
127
|
+
## Current Position
|
|
128
|
+
|
|
129
|
+
Phase: 3 of 7 (Cursor Agent Integration)
|
|
130
|
+
Plan: 3 of 3 in current phase
|
|
131
|
+
Status: Phase complete
|
|
132
|
+
Last activity: 2026-03-16
|
|
133
|
+
`;
|
|
134
|
+
const got = parseStateMd(content);
|
|
135
|
+
expect(got).not.toBeNull();
|
|
136
|
+
expect(got.progressPercent).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
it('parses real STATE.md Current Position block (phase 4)', () => {
|
|
139
|
+
const content = `
|
|
140
|
+
# Project State
|
|
141
|
+
|
|
142
|
+
## Project Reference
|
|
143
|
+
|
|
144
|
+
See: .planning/PROJECT.md
|
|
145
|
+
|
|
146
|
+
## Current Position
|
|
147
|
+
|
|
148
|
+
Phase: 4 of 7 (State Monitoring & Phase Transitions)
|
|
149
|
+
Plan: 0 of 3 in current phase
|
|
150
|
+
Status: Planned
|
|
151
|
+
Last activity: 2026-03-16 — Phase 4 planned with 3 plans (TDD parser, watcher + events, daemon/orchestrator wiring)
|
|
152
|
+
|
|
153
|
+
Progress: ██████░░░░ 43%
|
|
154
|
+
|
|
155
|
+
## Performance Metrics
|
|
156
|
+
`;
|
|
157
|
+
const got = parseStateMd(content);
|
|
158
|
+
expect(got).not.toBeNull();
|
|
159
|
+
expect(got).toEqual({
|
|
160
|
+
phaseNumber: 4,
|
|
161
|
+
totalPhases: 7,
|
|
162
|
+
phaseName: 'State Monitoring & Phase Transitions',
|
|
163
|
+
planNumber: 0,
|
|
164
|
+
totalPlans: 3,
|
|
165
|
+
status: 'Planned',
|
|
166
|
+
lastActivity: '2026-03-16 — Phase 4 planned with 3 plans (TDD parser, watcher + events, daemon/orchestrator wiring)',
|
|
167
|
+
progressPercent: 43,
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
it('trims status and lastActivity', () => {
|
|
171
|
+
const content = `
|
|
172
|
+
## Current Position
|
|
173
|
+
|
|
174
|
+
Phase: 1 of 7 (Foundation)
|
|
175
|
+
Plan: 1 of 1 in current phase
|
|
176
|
+
Status: Planned
|
|
177
|
+
Last activity: 2026-03-16 — Started
|
|
178
|
+
|
|
179
|
+
Progress: 10%
|
|
180
|
+
`;
|
|
181
|
+
const got = parseStateMd(content);
|
|
182
|
+
expect(got).not.toBeNull();
|
|
183
|
+
expect(got.status).toBe('Planned');
|
|
184
|
+
expect(got.lastActivity).toBe('2026-03-16 — Started');
|
|
185
|
+
expect(got.progressPercent).toBe(10);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
describe('readStateMd', () => {
|
|
189
|
+
it('reads and parses existing STATE.md', async () => {
|
|
190
|
+
const { mkdtemp, writeFile, rm, mkdir } = await import('node:fs/promises');
|
|
191
|
+
const { tmpdir } = await import('node:os');
|
|
192
|
+
const { join } = await import('node:path');
|
|
193
|
+
const dir = await mkdtemp(join(tmpdir(), 'state-parser-existing-'));
|
|
194
|
+
const planningDir = join(dir, '.planning');
|
|
195
|
+
const statePath = join(planningDir, 'STATE.md');
|
|
196
|
+
try {
|
|
197
|
+
await mkdir(planningDir, { recursive: true });
|
|
198
|
+
await writeFile(statePath, STANDARD_BLOCK, 'utf-8');
|
|
199
|
+
const got = await readStateMd(statePath);
|
|
200
|
+
expect(got).not.toBeNull();
|
|
201
|
+
expect(typeof got.phaseNumber).toBe('number');
|
|
202
|
+
expect(typeof got.totalPhases).toBe('number');
|
|
203
|
+
expect(typeof got.status).toBe('string');
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
await rm(dir, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
it('returns null when file is missing', async () => {
|
|
210
|
+
const got = await readStateMd('.planning/NONEXISTENT-STATE.md');
|
|
211
|
+
expect(got).toBeNull();
|
|
212
|
+
});
|
|
213
|
+
it('returns null when file content is unparseable', async () => {
|
|
214
|
+
const { mkdtemp, writeFile, rm } = await import('node:fs/promises');
|
|
215
|
+
const { tmpdir } = await import('node:os');
|
|
216
|
+
const { join } = await import('node:path');
|
|
217
|
+
const dir = await mkdtemp(join(tmpdir(), 'state-parser-'));
|
|
218
|
+
const badPath = join(dir, 'STATE.md');
|
|
219
|
+
try {
|
|
220
|
+
await writeFile(badPath, 'not a valid state file, no ## Current Position', 'utf-8');
|
|
221
|
+
const got = await readStateMd(badPath);
|
|
222
|
+
expect(got).toBeNull();
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
await rm(dir, { recursive: true, force: true });
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface StateSnapshot {
|
|
2
|
+
phaseNumber: number;
|
|
3
|
+
totalPhases: number;
|
|
4
|
+
phaseName: string;
|
|
5
|
+
planNumber: number;
|
|
6
|
+
totalPlans: number;
|
|
7
|
+
status: string;
|
|
8
|
+
lastActivity: string;
|
|
9
|
+
/**
|
|
10
|
+
* When present, represents the overall progress percentage parsed from the
|
|
11
|
+
* "Progress:" line in STATE.md. Null when the progress line is missing or
|
|
12
|
+
* has no percentage.
|
|
13
|
+
*/
|
|
14
|
+
progressPercent: number | null;
|
|
15
|
+
/**
|
|
16
|
+
* Optional git SHA associated with the snapshot, parsed from a "Git SHA:"
|
|
17
|
+
* line when present. Older STATE.md files may not include this.
|
|
18
|
+
*/
|
|
19
|
+
gitSha?: string | null;
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import type { Logger } from './logger.js';
|
|
3
|
+
import type { StateSnapshot } from './state-types.js';
|
|
4
|
+
/** Progress event payloads emitted by StateWatcher */
|
|
5
|
+
export type ProgressEvent = {
|
|
6
|
+
type: 'state_changed';
|
|
7
|
+
previous: StateSnapshot | null;
|
|
8
|
+
current: StateSnapshot;
|
|
9
|
+
} | {
|
|
10
|
+
type: 'phase_advanced';
|
|
11
|
+
fromPhase: number;
|
|
12
|
+
toPhase: number;
|
|
13
|
+
phaseName: string;
|
|
14
|
+
} | {
|
|
15
|
+
type: 'plan_advanced';
|
|
16
|
+
phaseNumber: number;
|
|
17
|
+
fromPlan: number;
|
|
18
|
+
toPlan: number;
|
|
19
|
+
} | {
|
|
20
|
+
type: 'phase_completed';
|
|
21
|
+
phaseNumber: number;
|
|
22
|
+
phaseName: string;
|
|
23
|
+
} | {
|
|
24
|
+
type: 'goal_completed';
|
|
25
|
+
progressPercent: number;
|
|
26
|
+
};
|
|
27
|
+
export interface StateWatcherOptions {
|
|
28
|
+
stateMdPath: string;
|
|
29
|
+
debounceMs?: number;
|
|
30
|
+
logger: Logger;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Watches STATE.md for changes, parses content, and emits typed progress events.
|
|
34
|
+
* Used by the daemon and dashboard for real-time progress visibility.
|
|
35
|
+
*/
|
|
36
|
+
export declare class StateWatcher extends EventEmitter {
|
|
37
|
+
private readonly stateMdPath;
|
|
38
|
+
private readonly debounceMs;
|
|
39
|
+
private readonly logger;
|
|
40
|
+
private watcher;
|
|
41
|
+
private debounceTimer;
|
|
42
|
+
private lastSnapshot;
|
|
43
|
+
private goalCompleteEmitted;
|
|
44
|
+
constructor(options: StateWatcherOptions);
|
|
45
|
+
start(): void;
|
|
46
|
+
private handleChange;
|
|
47
|
+
stop(): void;
|
|
48
|
+
getLastSnapshot(): StateSnapshot | null;
|
|
49
|
+
}
|