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.
Files changed (83) hide show
  1. package/README.md +263 -0
  2. package/bin/gsd-unsupervised +3 -0
  3. package/bin/start-daemon.sh +12 -0
  4. package/bin/unsupervised-gsd +2 -0
  5. package/dist/agent-runner.d.ts +26 -0
  6. package/dist/agent-runner.js +111 -0
  7. package/dist/agent-runner.spawn.test.d.ts +1 -0
  8. package/dist/agent-runner.spawn.test.js +128 -0
  9. package/dist/agent-runner.test.d.ts +1 -0
  10. package/dist/agent-runner.test.js +26 -0
  11. package/dist/bootstrap/wsl-bootstrap.d.ts +11 -0
  12. package/dist/bootstrap/wsl-bootstrap.js +14 -0
  13. package/dist/cli.d.ts +1 -0
  14. package/dist/cli.js +172 -0
  15. package/dist/config/paths.d.ts +8 -0
  16. package/dist/config/paths.js +36 -0
  17. package/dist/config/wsl.d.ts +4 -0
  18. package/dist/config/wsl.js +43 -0
  19. package/dist/config.d.ts +79 -0
  20. package/dist/config.js +95 -0
  21. package/dist/config.test.d.ts +1 -0
  22. package/dist/config.test.js +27 -0
  23. package/dist/cursor-agent.d.ts +17 -0
  24. package/dist/cursor-agent.invoker.test.d.ts +1 -0
  25. package/dist/cursor-agent.invoker.test.js +150 -0
  26. package/dist/cursor-agent.js +156 -0
  27. package/dist/cursor-agent.test.d.ts +1 -0
  28. package/dist/cursor-agent.test.js +60 -0
  29. package/dist/daemon.d.ts +17 -0
  30. package/dist/daemon.js +374 -0
  31. package/dist/git.d.ts +23 -0
  32. package/dist/git.js +76 -0
  33. package/dist/goals.d.ts +34 -0
  34. package/dist/goals.js +148 -0
  35. package/dist/gsd-state.d.ts +49 -0
  36. package/dist/gsd-state.js +76 -0
  37. package/dist/init-wizard.d.ts +5 -0
  38. package/dist/init-wizard.js +96 -0
  39. package/dist/lifecycle.d.ts +41 -0
  40. package/dist/lifecycle.js +103 -0
  41. package/dist/lifecycle.test.d.ts +1 -0
  42. package/dist/lifecycle.test.js +116 -0
  43. package/dist/logger.d.ts +12 -0
  44. package/dist/logger.js +31 -0
  45. package/dist/notifier.d.ts +6 -0
  46. package/dist/notifier.js +37 -0
  47. package/dist/orchestrator.d.ts +35 -0
  48. package/dist/orchestrator.js +791 -0
  49. package/dist/resource-governor.d.ts +54 -0
  50. package/dist/resource-governor.js +57 -0
  51. package/dist/resource-governor.test.d.ts +1 -0
  52. package/dist/resource-governor.test.js +33 -0
  53. package/dist/resume-pointer.d.ts +36 -0
  54. package/dist/resume-pointer.js +116 -0
  55. package/dist/roadmap-parser.d.ts +24 -0
  56. package/dist/roadmap-parser.js +105 -0
  57. package/dist/roadmap-parser.test.d.ts +1 -0
  58. package/dist/roadmap-parser.test.js +57 -0
  59. package/dist/session-log.d.ts +53 -0
  60. package/dist/session-log.js +92 -0
  61. package/dist/session-log.test.d.ts +1 -0
  62. package/dist/session-log.test.js +146 -0
  63. package/dist/state-index.d.ts +5 -0
  64. package/dist/state-index.js +31 -0
  65. package/dist/state-parser.d.ts +13 -0
  66. package/dist/state-parser.js +82 -0
  67. package/dist/state-parser.test.d.ts +1 -0
  68. package/dist/state-parser.test.js +228 -0
  69. package/dist/state-types.d.ts +20 -0
  70. package/dist/state-types.js +1 -0
  71. package/dist/state-watcher.d.ts +49 -0
  72. package/dist/state-watcher.js +148 -0
  73. package/dist/status-server.d.ts +112 -0
  74. package/dist/status-server.js +379 -0
  75. package/dist/status-server.test.d.ts +1 -0
  76. package/dist/status-server.test.js +206 -0
  77. package/dist/stream-events.d.ts +423 -0
  78. package/dist/stream-events.js +87 -0
  79. package/dist/stream-events.test.d.ts +1 -0
  80. package/dist/stream-events.test.js +304 -0
  81. package/dist/todos-api.d.ts +5 -0
  82. package/dist/todos-api.js +35 -0
  83. 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
+ }