create-claude-workspace 1.1.151 → 2.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 +33 -1
- package/dist/index.js +29 -56
- package/dist/scheduler/agents/health-checker.mjs +98 -0
- package/dist/scheduler/agents/health-checker.spec.js +143 -0
- package/dist/scheduler/agents/orchestrator.mjs +149 -0
- package/dist/scheduler/agents/orchestrator.spec.js +87 -0
- package/dist/scheduler/agents/prompt-builder.mjs +204 -0
- package/dist/scheduler/agents/prompt-builder.spec.js +240 -0
- package/dist/scheduler/agents/worker-pool.mjs +137 -0
- package/dist/scheduler/agents/worker-pool.spec.js +45 -0
- package/dist/scheduler/git/ci-watcher.mjs +93 -0
- package/dist/scheduler/git/ci-watcher.spec.js +35 -0
- package/dist/scheduler/git/manager.mjs +228 -0
- package/dist/scheduler/git/manager.spec.js +198 -0
- package/dist/scheduler/git/release.mjs +117 -0
- package/dist/scheduler/git/release.spec.js +175 -0
- package/dist/scheduler/index.mjs +309 -0
- package/dist/scheduler/index.spec.js +72 -0
- package/dist/scheduler/integration.spec.js +289 -0
- package/dist/scheduler/loop.mjs +435 -0
- package/dist/scheduler/loop.spec.js +139 -0
- package/dist/scheduler/state/session.mjs +14 -0
- package/dist/scheduler/state/session.spec.js +36 -0
- package/dist/scheduler/state/state.mjs +102 -0
- package/dist/scheduler/state/state.spec.js +175 -0
- package/dist/scheduler/tasks/inbox.mjs +98 -0
- package/dist/scheduler/tasks/inbox.spec.js +168 -0
- package/dist/scheduler/tasks/parser.mjs +228 -0
- package/dist/scheduler/tasks/parser.spec.js +303 -0
- package/dist/scheduler/tasks/queue.mjs +152 -0
- package/dist/scheduler/tasks/queue.spec.js +223 -0
- package/dist/scheduler/types.mjs +20 -0
- package/dist/{scripts/lib → scheduler/ui}/tui.mjs +84 -41
- package/dist/{scripts/lib → scheduler/ui}/tui.spec.js +56 -0
- package/dist/scheduler/util/memory.mjs +126 -0
- package/dist/scheduler/util/memory.spec.js +165 -0
- package/dist/template/.claude/{profiles/angular.md → agents/angular-engineer.md} +9 -4
- package/dist/template/.claude/{profiles/react.md → agents/react-engineer.md} +9 -4
- package/dist/template/.claude/{profiles/svelte.md → agents/svelte-engineer.md} +9 -4
- package/dist/template/.claude/{profiles/vue.md → agents/vue-engineer.md} +9 -4
- package/package.json +3 -4
- package/dist/scripts/autonomous.mjs +0 -492
- package/dist/scripts/autonomous.spec.js +0 -46
- package/dist/scripts/docker-run.mjs +0 -462
- package/dist/scripts/integration.spec.js +0 -108
- package/dist/scripts/lib/formatter.mjs +0 -309
- package/dist/scripts/lib/formatter.spec.js +0 -262
- package/dist/scripts/lib/state.mjs +0 -44
- package/dist/scripts/lib/state.spec.js +0 -59
- package/dist/template/.claude/docker/.dockerignore +0 -8
- package/dist/template/.claude/docker/Dockerfile +0 -54
- package/dist/template/.claude/docker/docker-compose.yml +0 -22
- package/dist/template/.claude/docker/docker-entrypoint.sh +0 -101
- /package/dist/{scripts/lib/types.mjs → scheduler/shared-types.mjs} +0 -0
- /package/dist/{scripts/lib → scheduler/util}/idle-poll.mjs +0 -0
- /package/dist/{scripts/lib → scheduler/util}/idle-poll.spec.js +0 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildGraph, getParallelBatches, isPhaseComplete, getNextPhase, isProjectComplete, getBlockedTasks } from './queue.mjs';
|
|
3
|
+
import { parseTodoMd } from './parser.mjs';
|
|
4
|
+
// ─── Fixtures ───
|
|
5
|
+
const MULTI_PHASE_TODO = `# TODO.md
|
|
6
|
+
|
|
7
|
+
## Phase 0: Foundation
|
|
8
|
+
|
|
9
|
+
- [x] **Init workspace** — scaffolding
|
|
10
|
+
- Type: fullstack
|
|
11
|
+
- Complexity: S
|
|
12
|
+
- Depends on: nothing
|
|
13
|
+
|
|
14
|
+
- [ ] **Configure ESLint** — code quality
|
|
15
|
+
- Type: fullstack
|
|
16
|
+
- Complexity: S
|
|
17
|
+
- Depends on: nothing
|
|
18
|
+
|
|
19
|
+
## Phase 1: Auth
|
|
20
|
+
|
|
21
|
+
- [ ] **Auth types** — interfaces
|
|
22
|
+
- Type: backend
|
|
23
|
+
- Complexity: S
|
|
24
|
+
- Depends on: Phase 0
|
|
25
|
+
|
|
26
|
+
- [ ] **Login API** — endpoint
|
|
27
|
+
- Type: backend
|
|
28
|
+
- Complexity: M
|
|
29
|
+
- Depends on: Auth types
|
|
30
|
+
|
|
31
|
+
- [ ] **Login form** — UI
|
|
32
|
+
- Type: frontend
|
|
33
|
+
- Complexity: S
|
|
34
|
+
- Depends on: Auth types
|
|
35
|
+
|
|
36
|
+
## Phase 2: Dashboard
|
|
37
|
+
|
|
38
|
+
- [ ] **Dashboard layout** — grid
|
|
39
|
+
- Type: frontend
|
|
40
|
+
- Complexity: L
|
|
41
|
+
- Depends on: Phase 1
|
|
42
|
+
`;
|
|
43
|
+
const CYCLIC_TODO = `## Phase 0: Cycle
|
|
44
|
+
|
|
45
|
+
- [ ] **Task A** — first
|
|
46
|
+
- Type: backend
|
|
47
|
+
- Complexity: S
|
|
48
|
+
- Depends on: Task C
|
|
49
|
+
|
|
50
|
+
- [ ] **Task B** — second
|
|
51
|
+
- Type: backend
|
|
52
|
+
- Complexity: S
|
|
53
|
+
- Depends on: Task A
|
|
54
|
+
|
|
55
|
+
- [ ] **Task C** — third
|
|
56
|
+
- Type: backend
|
|
57
|
+
- Complexity: S
|
|
58
|
+
- Depends on: Task B
|
|
59
|
+
`;
|
|
60
|
+
const KIT_UPGRADE_TODO = `## Phase 1: Feature
|
|
61
|
+
|
|
62
|
+
- [ ] **Upgrade sharp** — major version
|
|
63
|
+
- Type: backend
|
|
64
|
+
- Complexity: S
|
|
65
|
+
- Kit-Upgrade: v2.0
|
|
66
|
+
|
|
67
|
+
- [ ] **Feature A** — new feature
|
|
68
|
+
- Type: backend
|
|
69
|
+
- Complexity: M
|
|
70
|
+
- Depends on: nothing
|
|
71
|
+
`;
|
|
72
|
+
// ─── buildGraph ───
|
|
73
|
+
describe('buildGraph', () => {
|
|
74
|
+
it('builds graph from parsed tasks', () => {
|
|
75
|
+
const tasks = parseTodoMd(MULTI_PHASE_TODO);
|
|
76
|
+
const graph = buildGraph(tasks);
|
|
77
|
+
expect(graph.tasks.size).toBe(6);
|
|
78
|
+
});
|
|
79
|
+
it('returns runnable tasks (deps satisfied)', () => {
|
|
80
|
+
const tasks = parseTodoMd(MULTI_PHASE_TODO);
|
|
81
|
+
const graph = buildGraph(tasks);
|
|
82
|
+
const runnable = graph.runnable();
|
|
83
|
+
// "Init workspace" is done, "Configure ESLint" has no deps → runnable
|
|
84
|
+
expect(runnable.map(t => t.title)).toContain('Configure ESLint');
|
|
85
|
+
// "Auth types" depends on Phase 0 (Init workspace is done, ESLint is not) → blocked
|
|
86
|
+
expect(runnable.map(t => t.title)).not.toContain('Auth types');
|
|
87
|
+
});
|
|
88
|
+
it('detects no cycle in valid graph', () => {
|
|
89
|
+
const tasks = parseTodoMd(MULTI_PHASE_TODO);
|
|
90
|
+
const graph = buildGraph(tasks);
|
|
91
|
+
expect(graph.findCycle()).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe('cycle detection', () => {
|
|
95
|
+
it('detects cycle in dependency graph', () => {
|
|
96
|
+
const tasks = parseTodoMd(CYCLIC_TODO);
|
|
97
|
+
const graph = buildGraph(tasks);
|
|
98
|
+
const cycle = graph.findCycle();
|
|
99
|
+
expect(cycle).not.toBeNull();
|
|
100
|
+
expect(cycle.length).toBeGreaterThan(1);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe('kit-upgrade priority', () => {
|
|
104
|
+
it('sorts kit-upgrade tasks before regular tasks', () => {
|
|
105
|
+
const tasks = parseTodoMd(KIT_UPGRADE_TODO);
|
|
106
|
+
const graph = buildGraph(tasks);
|
|
107
|
+
const runnable = graph.runnable();
|
|
108
|
+
expect(runnable.length).toBe(2);
|
|
109
|
+
expect(runnable[0].kitUpgrade).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
describe('complexity sorting', () => {
|
|
113
|
+
it('sorts S before M before L within same phase', () => {
|
|
114
|
+
const todo = `## Phase 0: X
|
|
115
|
+
- [ ] **Large task** — big
|
|
116
|
+
- Type: backend
|
|
117
|
+
- Complexity: L
|
|
118
|
+
- Depends on: nothing
|
|
119
|
+
- [ ] **Small task** — tiny
|
|
120
|
+
- Type: backend
|
|
121
|
+
- Complexity: S
|
|
122
|
+
- Depends on: nothing
|
|
123
|
+
- [ ] **Medium task** — mid
|
|
124
|
+
- Type: backend
|
|
125
|
+
- Complexity: M
|
|
126
|
+
- Depends on: nothing
|
|
127
|
+
`;
|
|
128
|
+
const tasks = parseTodoMd(todo);
|
|
129
|
+
const graph = buildGraph(tasks);
|
|
130
|
+
const runnable = graph.runnable();
|
|
131
|
+
expect(runnable[0].complexity).toBe('S');
|
|
132
|
+
expect(runnable[1].complexity).toBe('M');
|
|
133
|
+
expect(runnable[2].complexity).toBe('L');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
// ─── getParallelBatches ───
|
|
137
|
+
describe('getParallelBatches', () => {
|
|
138
|
+
it('returns empty for empty input', () => {
|
|
139
|
+
expect(getParallelBatches([])).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
it('returns single batch for single task', () => {
|
|
142
|
+
const tasks = parseTodoMd(`## Phase 0: X\n- [ ] **Solo** — one\n - Type: backend\n - Complexity: S\n`);
|
|
143
|
+
const graph = buildGraph(tasks);
|
|
144
|
+
const batches = getParallelBatches(graph.runnable());
|
|
145
|
+
expect(batches).toHaveLength(1);
|
|
146
|
+
expect(batches[0]).toHaveLength(1);
|
|
147
|
+
});
|
|
148
|
+
it('batches independent tasks from same phase', () => {
|
|
149
|
+
const todo = `## Phase 0: Setup
|
|
150
|
+
- [ ] **Task A** — first
|
|
151
|
+
- Type: backend
|
|
152
|
+
- Complexity: S
|
|
153
|
+
- Depends on: nothing
|
|
154
|
+
- [ ] **Task B** — second
|
|
155
|
+
- Type: frontend
|
|
156
|
+
- Complexity: S
|
|
157
|
+
- Depends on: nothing
|
|
158
|
+
`;
|
|
159
|
+
const tasks = parseTodoMd(todo);
|
|
160
|
+
const graph = buildGraph(tasks);
|
|
161
|
+
const batches = getParallelBatches(graph.runnable());
|
|
162
|
+
expect(batches).toHaveLength(1);
|
|
163
|
+
expect(batches[0]).toHaveLength(2);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
// ─── Phase helpers ───
|
|
167
|
+
describe('isPhaseComplete', () => {
|
|
168
|
+
it('returns true when all tasks in phase are done/skipped', () => {
|
|
169
|
+
const tasks = [
|
|
170
|
+
{ id: 'p0-1', title: 'A', phase: 0, type: 'backend', complexity: 'S', status: 'done', dependsOn: [], issueMarker: null, kitUpgrade: false, lineNumber: 1, changelog: 'added' },
|
|
171
|
+
{ id: 'p0-2', title: 'B', phase: 0, type: 'backend', complexity: 'S', status: 'skipped', dependsOn: [], issueMarker: null, kitUpgrade: false, lineNumber: 2, changelog: 'none' },
|
|
172
|
+
];
|
|
173
|
+
expect(isPhaseComplete(tasks, 0)).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
it('returns false when a task is still todo', () => {
|
|
176
|
+
const tasks = [
|
|
177
|
+
{ id: 'p0-1', title: 'A', phase: 0, type: 'backend', complexity: 'S', status: 'done', dependsOn: [], issueMarker: null, kitUpgrade: false, lineNumber: 1, changelog: 'added' },
|
|
178
|
+
{ id: 'p0-2', title: 'B', phase: 0, type: 'backend', complexity: 'S', status: 'todo', dependsOn: [], issueMarker: null, kitUpgrade: false, lineNumber: 2, changelog: 'added' },
|
|
179
|
+
];
|
|
180
|
+
expect(isPhaseComplete(tasks, 0)).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
it('returns false for nonexistent phase', () => {
|
|
183
|
+
expect(isPhaseComplete([], 99)).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
describe('getNextPhase', () => {
|
|
187
|
+
it('returns lowest phase with todo tasks', () => {
|
|
188
|
+
const tasks = parseTodoMd(MULTI_PHASE_TODO);
|
|
189
|
+
expect(getNextPhase(tasks)).toBe(0); // ESLint is still todo
|
|
190
|
+
});
|
|
191
|
+
it('returns null when all tasks are done', () => {
|
|
192
|
+
const tasks = [
|
|
193
|
+
{ id: 'p0-1', title: 'A', phase: 0, type: 'backend', complexity: 'S', status: 'done', dependsOn: [], issueMarker: null, kitUpgrade: false, lineNumber: 1, changelog: 'added' },
|
|
194
|
+
];
|
|
195
|
+
expect(getNextPhase(tasks)).toBeNull();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
describe('isProjectComplete', () => {
|
|
199
|
+
it('returns true when all tasks are done/skipped', () => {
|
|
200
|
+
const tasks = [
|
|
201
|
+
{ id: 'p0-1', title: 'A', phase: 0, type: 'backend', complexity: 'S', status: 'done', dependsOn: [], issueMarker: null, kitUpgrade: false, lineNumber: 1, changelog: 'added' },
|
|
202
|
+
{ id: 'p1-1', title: 'B', phase: 1, type: 'backend', complexity: 'S', status: 'skipped', dependsOn: [], issueMarker: null, kitUpgrade: false, lineNumber: 2, changelog: 'none' },
|
|
203
|
+
];
|
|
204
|
+
expect(isProjectComplete(tasks)).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
it('returns false when tasks remain', () => {
|
|
207
|
+
const tasks = parseTodoMd(MULTI_PHASE_TODO);
|
|
208
|
+
expect(isProjectComplete(tasks)).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
it('returns false for empty task list', () => {
|
|
211
|
+
expect(isProjectComplete([])).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
describe('getBlockedTasks', () => {
|
|
215
|
+
it('identifies blocked tasks', () => {
|
|
216
|
+
const tasks = parseTodoMd(MULTI_PHASE_TODO);
|
|
217
|
+
const graph = buildGraph(tasks);
|
|
218
|
+
const blocked = getBlockedTasks(graph);
|
|
219
|
+
// Auth types, Login API, Login form, Dashboard are blocked
|
|
220
|
+
expect(blocked.length).toBeGreaterThan(0);
|
|
221
|
+
expect(blocked.some(t => t.title === 'Auth types')).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// ─── Multi-agent scheduler types ───
|
|
2
|
+
export const SCHEDULER_DEFAULTS = {
|
|
3
|
+
projectDir: process.cwd(),
|
|
4
|
+
concurrency: 1,
|
|
5
|
+
maxIterations: 50,
|
|
6
|
+
maxTurns: 50,
|
|
7
|
+
skipPermissions: false,
|
|
8
|
+
resume: false,
|
|
9
|
+
resumeSession: null,
|
|
10
|
+
logFile: '.claude/scheduler/scheduler.log',
|
|
11
|
+
noPull: false,
|
|
12
|
+
noLock: false,
|
|
13
|
+
dryRun: false,
|
|
14
|
+
notifyCommand: null,
|
|
15
|
+
idlePollInterval: 5 * 60_000,
|
|
16
|
+
maxIdleTime: 0,
|
|
17
|
+
interactive: false,
|
|
18
|
+
delay: 5_000,
|
|
19
|
+
cooldown: 60_000,
|
|
20
|
+
};
|
|
@@ -22,6 +22,8 @@ const DIM = '\x1b[2m';
|
|
|
22
22
|
const HIDE_CURSOR = '\x1b[?25l';
|
|
23
23
|
const SHOW_CURSOR = '\x1b[?25h';
|
|
24
24
|
const CLEAR_LINE = '\r\x1b[2K';
|
|
25
|
+
const CLEAR_TO_END = '\x1b[J'; // clear from cursor to end of screen
|
|
26
|
+
const CURSOR_UP = (n) => n > 0 ? `\x1b[${n}A` : '';
|
|
25
27
|
// ─── Tool icons ───
|
|
26
28
|
const ICONS = {
|
|
27
29
|
Bash: '⚡', Read: '📖', Write: '✏️ ', Edit: '🔧', Glob: '🔍', Grep: '🔎',
|
|
@@ -39,7 +41,7 @@ export class TUI {
|
|
|
39
41
|
onInput = null;
|
|
40
42
|
onHotkey = null;
|
|
41
43
|
state;
|
|
42
|
-
|
|
44
|
+
statusBarLines = 0;
|
|
43
45
|
statusTimer = null;
|
|
44
46
|
stdinHandler = null;
|
|
45
47
|
topAgent = null;
|
|
@@ -50,6 +52,7 @@ export class TUI {
|
|
|
50
52
|
iteration: 0, maxIter: 0, loopStart: Date.now(), iterStart: 0,
|
|
51
53
|
tools: 0, tokensIn: 0, tokensOut: 0, agents: [], taskName: '',
|
|
52
54
|
tasksDone: 0, tasksTotal: 0, paused: false, inputBuf: '',
|
|
55
|
+
pendingInputs: [],
|
|
53
56
|
};
|
|
54
57
|
if (this.interactive) {
|
|
55
58
|
// Hide cursor to prevent flickering
|
|
@@ -122,10 +125,10 @@ export class TUI {
|
|
|
122
125
|
}
|
|
123
126
|
destroy() {
|
|
124
127
|
if (this.interactive) {
|
|
125
|
-
// Clear status
|
|
126
|
-
if (this.
|
|
127
|
-
process.stdout.write(
|
|
128
|
-
this.
|
|
128
|
+
// Clear status area
|
|
129
|
+
if (this.statusBarLines > 0) {
|
|
130
|
+
process.stdout.write(CURSOR_UP(this.statusBarLines - 1) + '\r' + CLEAR_TO_END);
|
|
131
|
+
this.statusBarLines = 0;
|
|
129
132
|
}
|
|
130
133
|
// Show cursor
|
|
131
134
|
process.stdout.write(SHOW_CURSOR);
|
|
@@ -151,6 +154,19 @@ export class TUI {
|
|
|
151
154
|
setInputHandler(h) { this.onInput = h; }
|
|
152
155
|
setHotkeyHandler(h) { this.onHotkey = h; }
|
|
153
156
|
isPaused() { return this.state.paused; }
|
|
157
|
+
addPendingInput(text) {
|
|
158
|
+
this.state.pendingInputs.push(text);
|
|
159
|
+
this.log(` ${ANSI_COLORS.gray}${ts()}${RESET} ${ANSI_COLORS.magenta}▲${RESET} ${ANSI_COLORS.white}Queued:${RESET} ${trunc(text, 60)}`);
|
|
160
|
+
this.renderStatusBar();
|
|
161
|
+
}
|
|
162
|
+
consumePendingInput() {
|
|
163
|
+
const item = this.state.pendingInputs.shift();
|
|
164
|
+
if (item) {
|
|
165
|
+
this.log(` ${ANSI_COLORS.gray}${ts()}${RESET} ${ANSI_COLORS.green}▼${RESET} ${ANSI_COLORS.white}Processing:${RESET} ${trunc(item, 60)}`);
|
|
166
|
+
this.renderStatusBar();
|
|
167
|
+
}
|
|
168
|
+
return item;
|
|
169
|
+
}
|
|
154
170
|
/** Set the top-level agent name so all log lines show it from the start. */
|
|
155
171
|
setTopAgent(name) {
|
|
156
172
|
this.topAgent = name;
|
|
@@ -158,69 +174,92 @@ export class TUI {
|
|
|
158
174
|
this.state.agents.push(name);
|
|
159
175
|
}
|
|
160
176
|
}
|
|
161
|
-
// ─── Status
|
|
162
|
-
|
|
177
|
+
// ─── Status area (multi-line: pending queue + input + stats) ───
|
|
178
|
+
buildStatusLines() {
|
|
163
179
|
const s = this.state;
|
|
180
|
+
const lines = [];
|
|
181
|
+
// Line 1: Pending input queue (only if non-empty)
|
|
182
|
+
if (s.pendingInputs.length > 0) {
|
|
183
|
+
const nums = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨'];
|
|
184
|
+
const items = s.pendingInputs.slice(0, 5).map((t, i) => `${nums[i] || '•'} ${trunc(t, 25)}`).join(' ');
|
|
185
|
+
const more = s.pendingInputs.length > 5 ? ` +${s.pendingInputs.length - 5}` : '';
|
|
186
|
+
lines.push(` ${ANSI_COLORS.magenta}[${s.pendingInputs.length} queued]${RESET} ${ANSI_COLORS.gray}${items}${more}${RESET}`);
|
|
187
|
+
}
|
|
188
|
+
// Line 2: Input prompt
|
|
189
|
+
if (s.inputBuf) {
|
|
190
|
+
lines.push(` ${ANSI_COLORS.white}› ${s.inputBuf}${RESET}`);
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
lines.push(` ${ANSI_COLORS.gray}› type to send input${RESET}`);
|
|
194
|
+
}
|
|
195
|
+
// Line 3: Status bar (stats)
|
|
164
196
|
const elapsed = fmtDur(Date.now() - s.loopStart);
|
|
165
197
|
const iterTime = s.iterStart ? fmtDur(Date.now() - s.iterStart) : '—';
|
|
166
198
|
const pct = s.maxIter > 0 ? Math.round((s.iteration / s.maxIter) * 100) : 0;
|
|
167
199
|
const filled = Math.round((pct / 100) * 8);
|
|
168
|
-
const bar = '\u2588'.repeat(filled)
|
|
200
|
+
const bar = `${ANSI_COLORS.green}${'\u2588'.repeat(filled)}${ANSI_COLORS.gray}${'\u2591'.repeat(8 - filled)}${RESET}`;
|
|
169
201
|
const tok = fmtTok(s.tokensIn + s.tokensOut);
|
|
170
202
|
const cur = s.agents.length > 0 ? s.agents[s.agents.length - 1] : '';
|
|
171
|
-
let
|
|
172
|
-
if (cur)
|
|
173
|
-
|
|
203
|
+
let stats = `\x1b[7m ${elapsed} | Iter ${s.iteration}/${s.maxIter} \x1b[27m ${bar} \x1b[7m ${iterTime} | ${ANSI_COLORS.cyan}${s.tools}${RESET}\x1b[7m tools | ${ANSI_COLORS.yellow}${tok}${RESET}\x1b[7m tok`;
|
|
204
|
+
if (cur) {
|
|
205
|
+
const col = ANSI_COLORS[agentColor(cur)] || '';
|
|
206
|
+
stats += ` | ${col}${BOLD}${cur}${RESET}\x1b[7m`;
|
|
207
|
+
}
|
|
174
208
|
if (s.taskName)
|
|
175
|
-
|
|
209
|
+
stats += ` | ${ANSI_COLORS.cyan}${trunc(s.taskName, 20)}${RESET}\x1b[7m`;
|
|
176
210
|
if (s.paused)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (line.length > cols)
|
|
182
|
-
line = line.slice(0, cols - 1) + '…';
|
|
183
|
-
return line;
|
|
211
|
+
stats += ` | ${ANSI_COLORS.yellow}⏸ PAUSED${RESET}\x1b[7m`;
|
|
212
|
+
stats += ` ${RESET}`;
|
|
213
|
+
lines.push(stats);
|
|
214
|
+
return lines;
|
|
184
215
|
}
|
|
185
|
-
|
|
216
|
+
/** Plain-text version for testing / non-ANSI contexts. */
|
|
217
|
+
buildPlainStatusLines() {
|
|
186
218
|
const s = this.state;
|
|
219
|
+
const lines = [];
|
|
220
|
+
if (s.pendingInputs.length > 0) {
|
|
221
|
+
const items = s.pendingInputs.slice(0, 5).map((t, i) => `${i + 1}. ${trunc(t, 25)}`).join(' ');
|
|
222
|
+
const more = s.pendingInputs.length > 5 ? ` +${s.pendingInputs.length - 5}` : '';
|
|
223
|
+
lines.push(` [${s.pendingInputs.length} queued] ${items}${more}`);
|
|
224
|
+
}
|
|
225
|
+
lines.push(s.inputBuf ? ` › ${s.inputBuf}` : ' › type to send input');
|
|
187
226
|
const elapsed = fmtDur(Date.now() - s.loopStart);
|
|
188
227
|
const iterTime = s.iterStart ? fmtDur(Date.now() - s.iterStart) : '—';
|
|
189
228
|
const pct = s.maxIter > 0 ? Math.round((s.iteration / s.maxIter) * 100) : 0;
|
|
190
229
|
const filled = Math.round((pct / 100) * 8);
|
|
191
|
-
const bar =
|
|
230
|
+
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(8 - filled);
|
|
192
231
|
const tok = fmtTok(s.tokensIn + s.tokensOut);
|
|
193
232
|
const cur = s.agents.length > 0 ? s.agents[s.agents.length - 1] : '';
|
|
194
|
-
let
|
|
195
|
-
if (cur)
|
|
196
|
-
|
|
197
|
-
line += ` | ${col}${BOLD}${cur}${RESET}\x1b[7m`;
|
|
198
|
-
}
|
|
233
|
+
let stats = ` ${elapsed} | Iter ${s.iteration}/${s.maxIter} ${bar} | ${iterTime} | ${s.tools} tools | ${tok} tok`;
|
|
234
|
+
if (cur)
|
|
235
|
+
stats += ` | ${cur}`;
|
|
199
236
|
if (s.taskName)
|
|
200
|
-
|
|
237
|
+
stats += ` | ${trunc(s.taskName, 20)}`;
|
|
201
238
|
if (s.paused)
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
239
|
+
stats += ' | PAUSED';
|
|
240
|
+
lines.push(stats);
|
|
241
|
+
return lines;
|
|
242
|
+
}
|
|
243
|
+
clearStatusArea() {
|
|
244
|
+
if (this.statusBarLines > 0) {
|
|
245
|
+
// Move cursor up to first status line, then clear everything below
|
|
246
|
+
process.stdout.write(CURSOR_UP(this.statusBarLines - 1) + '\r' + CLEAR_TO_END);
|
|
208
247
|
}
|
|
209
|
-
line += ` ${RESET}`;
|
|
210
|
-
return line;
|
|
211
248
|
}
|
|
212
249
|
renderStatusBar() {
|
|
213
250
|
if (!this.interactive)
|
|
214
251
|
return;
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
252
|
+
this.clearStatusArea();
|
|
253
|
+
const lines = this.buildStatusLines();
|
|
254
|
+
process.stdout.write(lines.join('\n'));
|
|
255
|
+
this.statusBarLines = lines.length;
|
|
218
256
|
}
|
|
219
257
|
// ─── Log output (stdout — preserves scroll history) ───
|
|
220
258
|
log(formatted, raw) {
|
|
221
|
-
if (this.interactive && this.
|
|
222
|
-
// Clear
|
|
223
|
-
|
|
259
|
+
if (this.interactive && this.statusBarLines > 0) {
|
|
260
|
+
// Clear status area, write the log line, then re-render status
|
|
261
|
+
this.clearStatusArea();
|
|
262
|
+
process.stdout.write(formatted + '\n');
|
|
224
263
|
this.renderStatusBar();
|
|
225
264
|
}
|
|
226
265
|
else {
|
|
@@ -299,6 +338,10 @@ export class TUI {
|
|
|
299
338
|
this.onAssistant(message);
|
|
300
339
|
break;
|
|
301
340
|
case 'user':
|
|
341
|
+
// Detect echoed user input (string content) vs tool results (array content)
|
|
342
|
+
if (typeof message.message?.content === 'string' && this.state.pendingInputs.length > 0) {
|
|
343
|
+
this.consumePendingInput();
|
|
344
|
+
}
|
|
302
345
|
this.onToolResult(message);
|
|
303
346
|
break;
|
|
304
347
|
case 'system':
|
|
@@ -288,3 +288,59 @@ describe('TUI — message handling', () => {
|
|
|
288
288
|
expect(true).toBe(true);
|
|
289
289
|
});
|
|
290
290
|
});
|
|
291
|
+
describe('TUI — pending input queue', () => {
|
|
292
|
+
it('addPendingInput logs the queued message', () => {
|
|
293
|
+
const tui = new TUI(LOG_FILE, false);
|
|
294
|
+
tui.addPendingInput('fix login bug');
|
|
295
|
+
expect(readLog()).toContain('Queued:');
|
|
296
|
+
expect(readLog()).toContain('fix login bug');
|
|
297
|
+
});
|
|
298
|
+
it('consumePendingInput returns and logs first item', () => {
|
|
299
|
+
const tui = new TUI(LOG_FILE, false);
|
|
300
|
+
tui.addPendingInput('first');
|
|
301
|
+
tui.addPendingInput('second');
|
|
302
|
+
const consumed = tui.consumePendingInput();
|
|
303
|
+
expect(consumed).toBe('first');
|
|
304
|
+
expect(readLog()).toContain('Processing:');
|
|
305
|
+
expect(readLog()).toContain('first');
|
|
306
|
+
});
|
|
307
|
+
it('consumePendingInput returns undefined when empty', () => {
|
|
308
|
+
const tui = new TUI(LOG_FILE, false);
|
|
309
|
+
expect(tui.consumePendingInput()).toBeUndefined();
|
|
310
|
+
});
|
|
311
|
+
it('echoed user message consumes from pending queue', () => {
|
|
312
|
+
const tui = new TUI(LOG_FILE, false);
|
|
313
|
+
tui.addPendingInput('add tests');
|
|
314
|
+
// Simulate SDK echoing back the user message (string content, not tool_result array)
|
|
315
|
+
tui.handleMessage(msg({
|
|
316
|
+
type: 'user',
|
|
317
|
+
message: { role: 'user', content: 'add tests' },
|
|
318
|
+
}));
|
|
319
|
+
const log = readLog();
|
|
320
|
+
expect(log).toContain('Queued:');
|
|
321
|
+
expect(log).toContain('Processing:');
|
|
322
|
+
// Queue should be empty now
|
|
323
|
+
expect(tui.consumePendingInput()).toBeUndefined();
|
|
324
|
+
});
|
|
325
|
+
it('tool_result user messages do not consume from queue', () => {
|
|
326
|
+
const tui = new TUI(LOG_FILE, false);
|
|
327
|
+
tui.addPendingInput('pending task');
|
|
328
|
+
// Tool result has array content, should NOT consume
|
|
329
|
+
tui.handleMessage(msg({
|
|
330
|
+
type: 'user',
|
|
331
|
+
message: { content: [{ type: 'tool_result', content: 'ok', is_error: false }] },
|
|
332
|
+
}));
|
|
333
|
+
// Queue should still have the item
|
|
334
|
+
expect(tui.consumePendingInput()).toBe('pending task');
|
|
335
|
+
});
|
|
336
|
+
it('multiple pending inputs are consumed in FIFO order', () => {
|
|
337
|
+
const tui = new TUI(LOG_FILE, false);
|
|
338
|
+
tui.addPendingInput('first');
|
|
339
|
+
tui.addPendingInput('second');
|
|
340
|
+
tui.addPendingInput('third');
|
|
341
|
+
// Consume via echoed user messages
|
|
342
|
+
tui.handleMessage(msg({ type: 'user', message: { role: 'user', content: 'first' } }));
|
|
343
|
+
tui.handleMessage(msg({ type: 'user', message: { role: 'user', content: 'second' } }));
|
|
344
|
+
expect(tui.consumePendingInput()).toBe('third');
|
|
345
|
+
});
|
|
346
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// ─── Pure string transformations for MEMORY.md ───
|
|
2
|
+
// All functions are pure: string in → string out. No file I/O.
|
|
3
|
+
/**
|
|
4
|
+
* Read a top-level section value from MEMORY.md.
|
|
5
|
+
* Sections are h2 headings (`## Section Name`), value is everything until the next heading.
|
|
6
|
+
*/
|
|
7
|
+
export function readField(content, field) {
|
|
8
|
+
const sections = splitSections(content);
|
|
9
|
+
const section = sections.find(s => s.heading === field);
|
|
10
|
+
if (!section)
|
|
11
|
+
return null;
|
|
12
|
+
const trimmed = section.body.trim();
|
|
13
|
+
return trimmed || null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Write a top-level section value. Creates the section if it doesn't exist.
|
|
17
|
+
*/
|
|
18
|
+
export function writeField(content, field, value) {
|
|
19
|
+
const sections = splitSections(content);
|
|
20
|
+
const idx = sections.findIndex(s => s.heading === field);
|
|
21
|
+
if (idx >= 0) {
|
|
22
|
+
sections[idx] = { heading: field, body: value + '\n' };
|
|
23
|
+
return assembleSections(content, sections);
|
|
24
|
+
}
|
|
25
|
+
// Append new section
|
|
26
|
+
const trimmed = content.trimEnd();
|
|
27
|
+
return `${trimmed}\n\n## ${field}\n${value}\n`;
|
|
28
|
+
}
|
|
29
|
+
function splitSections(content) {
|
|
30
|
+
const sections = [];
|
|
31
|
+
const lines = content.split('\n');
|
|
32
|
+
let currentHeading = null;
|
|
33
|
+
let bodyLines = [];
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
const match = line.match(/^## (.+)$/);
|
|
36
|
+
if (match) {
|
|
37
|
+
if (currentHeading !== null) {
|
|
38
|
+
sections.push({ heading: currentHeading, body: bodyLines.join('\n') });
|
|
39
|
+
}
|
|
40
|
+
currentHeading = match[1].trim();
|
|
41
|
+
bodyLines = [];
|
|
42
|
+
}
|
|
43
|
+
else if (currentHeading !== null) {
|
|
44
|
+
bodyLines.push(line);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (currentHeading !== null) {
|
|
48
|
+
sections.push({ heading: currentHeading, body: bodyLines.join('\n') });
|
|
49
|
+
}
|
|
50
|
+
return sections;
|
|
51
|
+
}
|
|
52
|
+
function assembleSections(original, sections) {
|
|
53
|
+
// Preserve everything before the first ## heading
|
|
54
|
+
let preamble = '';
|
|
55
|
+
if (original.startsWith('## ')) {
|
|
56
|
+
preamble = '';
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
const idx = original.indexOf('\n## ');
|
|
60
|
+
preamble = idx >= 0 ? original.slice(0, idx + 1) : '';
|
|
61
|
+
}
|
|
62
|
+
const body = sections
|
|
63
|
+
.map(s => `## ${s.heading}\n${s.body}`)
|
|
64
|
+
.join('\n');
|
|
65
|
+
return preamble + body;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Append a completed task to the "Done" section.
|
|
69
|
+
*/
|
|
70
|
+
export function addDoneItem(content, taskTitle, date) {
|
|
71
|
+
const done = readField(content, 'Done');
|
|
72
|
+
if (!done || done === '(nothing yet)') {
|
|
73
|
+
return writeField(content, 'Done', `- ${taskTitle} (${date})`);
|
|
74
|
+
}
|
|
75
|
+
return writeField(content, 'Done', `${done}\n- ${taskTitle} (${date})`);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Set the "Next" section to a specific task.
|
|
79
|
+
*/
|
|
80
|
+
export function setNextItem(content, taskTitle) {
|
|
81
|
+
return writeField(content, 'Next', `- ${taskTitle}`);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Read Session Config as key-value pairs.
|
|
85
|
+
*/
|
|
86
|
+
export function readSessionConfig(content) {
|
|
87
|
+
const section = readField(content, 'Session Config');
|
|
88
|
+
if (!section)
|
|
89
|
+
return {};
|
|
90
|
+
const config = {};
|
|
91
|
+
for (const line of section.split('\n')) {
|
|
92
|
+
const match = line.match(/^-\s*(\w[\w_\s]*\w|\w+):\s*(.+)$/);
|
|
93
|
+
if (match) {
|
|
94
|
+
config[match[1].trim()] = match[2].trim();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return config;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Check if the project is marked as complete.
|
|
101
|
+
*/
|
|
102
|
+
export function isProjectComplete(content) {
|
|
103
|
+
return /^Current Phase:.*PROJECT COMPLETE/mi.test(content)
|
|
104
|
+
|| /^Project_Status:\s*complete/mi.test(content)
|
|
105
|
+
|| /## Current Phase\s*\n.*PROJECT COMPLETE/mi.test(content);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Read iteration count from MEMORY.md.
|
|
109
|
+
*/
|
|
110
|
+
export function readIterationCount(content) {
|
|
111
|
+
const value = readField(content, 'Iterations This Session');
|
|
112
|
+
if (!value)
|
|
113
|
+
return 0;
|
|
114
|
+
const n = parseInt(value, 10);
|
|
115
|
+
return isNaN(n) ? 0 : n;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Increment iteration count.
|
|
119
|
+
*/
|
|
120
|
+
export function incrementIterations(content) {
|
|
121
|
+
const current = readIterationCount(content);
|
|
122
|
+
return writeField(content, 'Iterations This Session', String(current + 1));
|
|
123
|
+
}
|
|
124
|
+
function escapeRegex(str) {
|
|
125
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
126
|
+
}
|