@jhizzard/termdeck 0.8.0 → 0.10.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/docs/orchestrator-guide.md +335 -0
- package/package.json +3 -1
- package/packages/cli/src/index.js +26 -3
- package/packages/cli/src/init-project.js +213 -0
- package/packages/cli/src/templates.js +84 -0
- package/packages/cli/templates/.claude-settings.json.tmpl +32 -0
- package/packages/cli/templates/.gitignore.tmpl +28 -0
- package/packages/cli/templates/CLAUDE.md.tmpl +35 -0
- package/packages/cli/templates/CONTRADICTIONS.md.tmpl +30 -0
- package/packages/cli/templates/README.md.tmpl +15 -0
- package/packages/cli/templates/RESTART-PROMPT.md.tmpl +38 -0
- package/packages/cli/templates/docs-orchestration-README.md.tmpl +29 -0
- package/packages/cli/templates/project_facts.md.tmpl +39 -0
- package/packages/client/public/app.js +781 -0
- package/packages/client/public/graph.html +104 -0
- package/packages/client/public/graph.js +683 -0
- package/packages/client/public/index.html +145 -0
- package/packages/client/public/style.css +1185 -0
- package/packages/server/src/graph-routes.js +555 -0
- package/packages/server/src/index.js +158 -5
- package/packages/server/src/orchestration-preview.js +256 -0
- package/packages/server/src/preflight.js +82 -0
- package/packages/server/src/rag.js +138 -0
- package/packages/server/src/setup/mnestra-migrations/009_memory_relationship_metadata.sql +126 -0
- package/packages/server/src/setup/mnestra-migrations/010_memory_recall_graph.sql +147 -0
- package/packages/server/src/setup/rumen/migrations/003_graph_inference_schedule.sql +49 -0
- package/packages/server/src/sprint-inject.js +156 -0
- package/packages/server/src/sprint-routes.js +503 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Sprint runner endpoints — the in-dashboard 4+1 sprint runner (Sprint 37 T4).
|
|
4
|
+
//
|
|
5
|
+
// Endpoints:
|
|
6
|
+
// POST /api/sprints create + scaffold + spawn 4 panels + inject
|
|
7
|
+
// GET /api/sprints list known sprints under a project
|
|
8
|
+
// GET /api/sprints/:name/status per-lane FINDING/FIX-PROPOSED/DONE counts
|
|
9
|
+
// GET /api/sprints/:name/tail?lines=N raw tail of STATUS.md
|
|
10
|
+
//
|
|
11
|
+
// All endpoints scope to a single project (passed via `project` query/body), so
|
|
12
|
+
// sprint paths resolve to `<project_path>/docs/sprint-<N>-<name>/`.
|
|
13
|
+
//
|
|
14
|
+
// Worktree isolation is opt-in (`--isolation=worktree` equivalent). When the
|
|
15
|
+
// caller passes `worktree: true`, each lane spawns a `git worktree add` rooted
|
|
16
|
+
// at `<sprint_dir>/worktrees/T<n>`. Off-by-default for v0.9.0; orchestrator
|
|
17
|
+
// merges + removes worktrees at sprint close (Sprint 38+).
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
const { execFileSync } = require('child_process');
|
|
23
|
+
|
|
24
|
+
const { injectSprintPrompts } = require('./sprint-inject');
|
|
25
|
+
|
|
26
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,40}$/;
|
|
27
|
+
|
|
28
|
+
function expandHome(p) {
|
|
29
|
+
if (!p) return p;
|
|
30
|
+
if (p.startsWith('~')) return path.join(os.homedir(), p.slice(1));
|
|
31
|
+
return p;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resolveProjectPath(config, projectName) {
|
|
35
|
+
if (!projectName) throw new Error('project is required');
|
|
36
|
+
const proj = (config && config.projects && config.projects[projectName]) || null;
|
|
37
|
+
if (!proj || !proj.path) {
|
|
38
|
+
throw new Error(`unknown project: ${projectName}`);
|
|
39
|
+
}
|
|
40
|
+
return path.resolve(expandHome(proj.path));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function listExistingSprints(projectPath) {
|
|
44
|
+
const docsDir = path.join(projectPath, 'docs');
|
|
45
|
+
if (!fs.existsSync(docsDir)) return [];
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const entry of fs.readdirSync(docsDir)) {
|
|
48
|
+
const m = entry.match(/^sprint-(\d+)-(.+)$/);
|
|
49
|
+
if (!m) continue;
|
|
50
|
+
const dir = path.join(docsDir, entry);
|
|
51
|
+
if (!fs.statSync(dir).isDirectory()) continue;
|
|
52
|
+
out.push({
|
|
53
|
+
number: parseInt(m[1], 10),
|
|
54
|
+
slug: m[2],
|
|
55
|
+
dir,
|
|
56
|
+
relDir: path.join('docs', entry),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return out.sort((a, b) => a.number - b.number);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function nextSprintNumber(projectPath) {
|
|
63
|
+
const all = listExistingSprints(projectPath);
|
|
64
|
+
if (all.length === 0) return 1;
|
|
65
|
+
return all[all.length - 1].number + 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function planningTemplate({ name, number, targetVersion, goal, lanes }) {
|
|
69
|
+
const laneRows = lanes
|
|
70
|
+
.map(
|
|
71
|
+
(l, i) =>
|
|
72
|
+
`| **T${i + 1} — ${l.name}** | ${l.goal} |`,
|
|
73
|
+
)
|
|
74
|
+
.join('\n');
|
|
75
|
+
return `# Sprint ${number} — ${name}
|
|
76
|
+
|
|
77
|
+
**Status:** Planned.
|
|
78
|
+
**Target version:** ${targetVersion || '(unset)'}
|
|
79
|
+
|
|
80
|
+
## Goal
|
|
81
|
+
|
|
82
|
+
${goal || '(unset)'}
|
|
83
|
+
|
|
84
|
+
## Lanes
|
|
85
|
+
|
|
86
|
+
| Lane | Goal |
|
|
87
|
+
|---|---|
|
|
88
|
+
${laneRows}
|
|
89
|
+
|
|
90
|
+
## Sprint contract
|
|
91
|
+
|
|
92
|
+
Append-only STATUS.md, lane discipline, no version bumps in lane.
|
|
93
|
+
`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function laneTemplate({ name, number, sprintName, lane, laneNumber, project, goal, worktree }) {
|
|
97
|
+
const worktreeNote = worktree
|
|
98
|
+
? `\n## Isolation\n\nThis lane runs in a git worktree at \`docs/sprint-${number}-${sprintName}/worktrees/T${laneNumber}\` on branch \`sprint-${sprintName}-T${laneNumber}\`. Stay in that tree; the orchestrator merges at sprint close.\n`
|
|
99
|
+
: '';
|
|
100
|
+
return `# Sprint ${number} — T${laneNumber}: ${lane}
|
|
101
|
+
|
|
102
|
+
**Lane goal:** ${goal || '(unset)'}
|
|
103
|
+
|
|
104
|
+
## Sprint contract
|
|
105
|
+
|
|
106
|
+
Append FINDING / FIX-PROPOSED / DONE entries to \`docs/sprint-${number}-${sprintName}/STATUS.md\` under \`## T${laneNumber}\`. No version bumps. No CHANGELOG. No commits. Stay in your lane.
|
|
107
|
+
${worktreeNote}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function statusTemplate({ name, number, lanes }) {
|
|
111
|
+
const laneSections = lanes
|
|
112
|
+
.map(
|
|
113
|
+
(l, i) =>
|
|
114
|
+
`## T${i + 1} — ${l.name}\n\n_(awaiting first entry)_\n\n---\n`,
|
|
115
|
+
)
|
|
116
|
+
.join('\n');
|
|
117
|
+
return `# Sprint ${number} — STATUS
|
|
118
|
+
|
|
119
|
+
Append-only. Each lane posts FINDING / FIX-PROPOSED / DONE entries. Do NOT edit other lanes' entries.
|
|
120
|
+
|
|
121
|
+
Format:
|
|
122
|
+
\`\`\`
|
|
123
|
+
## T<n> — <lane name>
|
|
124
|
+
|
|
125
|
+
### FINDING — YYYY-MM-DD HH:MM ET
|
|
126
|
+
<what you found>
|
|
127
|
+
|
|
128
|
+
### FIX-PROPOSED — YYYY-MM-DD HH:MM ET
|
|
129
|
+
<what you intend to do>
|
|
130
|
+
|
|
131
|
+
### DONE — YYYY-MM-DD HH:MM ET
|
|
132
|
+
<files changed, line counts, anything follow-up sprints need to know>
|
|
133
|
+
\`\`\`
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
${laneSections}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function bootPromptTemplate({ project, number, sprintName, laneNumber, laneName, goal }) {
|
|
141
|
+
// Standard 4+1 boot prompt. Format mirrors ~/.claude/CLAUDE.md § 4+1 mandate.
|
|
142
|
+
return `You are T${laneNumber} in Sprint ${number} (${sprintName}). Boot sequence:
|
|
143
|
+
1. memory_recall(project="${project}", query="${sprintName} ${laneName}")
|
|
144
|
+
2. memory_recall(query="recent decisions and bugs")
|
|
145
|
+
3. Read ~/.claude/CLAUDE.md and ./CLAUDE.md
|
|
146
|
+
4. Read docs/sprint-${number}-${sprintName}/PLANNING.md
|
|
147
|
+
5. Read docs/sprint-${number}-${sprintName}/STATUS.md
|
|
148
|
+
6. Read docs/sprint-${number}-${sprintName}/T${laneNumber}-${slugify(laneName)}.md (your full briefing)
|
|
149
|
+
|
|
150
|
+
Lane goal: ${goal}
|
|
151
|
+
|
|
152
|
+
Then begin. Stay in your lane. Post FINDING / FIX-PROPOSED / DONE in STATUS.md. Don't bump versions, don't touch CHANGELOG, don't commit.`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function slugify(s) {
|
|
156
|
+
return String(s || 'lane')
|
|
157
|
+
.toLowerCase()
|
|
158
|
+
.trim()
|
|
159
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
160
|
+
.replace(/^-+|-+$/g, '')
|
|
161
|
+
.slice(0, 40) || 'lane';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Parse STATUS.md → per-lane counts of FINDING/FIX-PROPOSED/DONE entries +
|
|
165
|
+
// most recent timestamp seen in any header. Cheap regex; no markdown AST.
|
|
166
|
+
function parseStatusMd(text) {
|
|
167
|
+
const out = { lanes: {}, lastEntryAt: null };
|
|
168
|
+
if (!text) return out;
|
|
169
|
+
|
|
170
|
+
// Split by `## T<n>` headers. Anything before the first such header is
|
|
171
|
+
// preamble and ignored.
|
|
172
|
+
const sections = text.split(/^##\s+T(\d+)\s*[—\-:]?\s*([^\n]*)$/m);
|
|
173
|
+
// sections[0] = preamble; then triples of (number, headline, body).
|
|
174
|
+
for (let i = 1; i + 1 < sections.length; i += 3) {
|
|
175
|
+
const num = sections[i];
|
|
176
|
+
const headline = (sections[i + 1] || '').trim();
|
|
177
|
+
const body = sections[i + 2] || '';
|
|
178
|
+
const finding = (body.match(/^###\s+FINDING\s+—/gm) || []).length;
|
|
179
|
+
const fixProposed = (body.match(/^###\s+FIX-PROPOSED\s+—/gm) || []).length;
|
|
180
|
+
const done = (body.match(/^###\s+DONE\s+—/gm) || []).length;
|
|
181
|
+
|
|
182
|
+
// Last timestamp in any `### <KIND> — YYYY-MM-DD HH:MM` header for this lane.
|
|
183
|
+
let lastEntryAt = null;
|
|
184
|
+
const tsRe = /^###\s+(?:FINDING|FIX-PROPOSED|DONE)\s+—\s+(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(?::\d{2})?)/gm;
|
|
185
|
+
let m;
|
|
186
|
+
while ((m = tsRe.exec(body)) !== null) {
|
|
187
|
+
if (!lastEntryAt || m[1] > lastEntryAt) lastEntryAt = m[1];
|
|
188
|
+
}
|
|
189
|
+
if (lastEntryAt && (!out.lastEntryAt || lastEntryAt > out.lastEntryAt)) {
|
|
190
|
+
out.lastEntryAt = lastEntryAt;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
out.lanes[`T${num}`] = {
|
|
194
|
+
name: headline,
|
|
195
|
+
finding,
|
|
196
|
+
fixProposed,
|
|
197
|
+
done,
|
|
198
|
+
lastEntryAt,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function findSprintDir(projectPath, sprintName) {
|
|
205
|
+
const all = listExistingSprints(projectPath);
|
|
206
|
+
return all.find((s) => s.slug === sprintName) || null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function gitWorktreeAdd({ projectPath, worktreePath, branch }) {
|
|
210
|
+
// Create the worktree directory if absent (git creates it, but parent dirs
|
|
211
|
+
// must exist).
|
|
212
|
+
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
|
|
213
|
+
execFileSync('git', ['-C', projectPath, 'worktree', 'add', worktreePath, '-b', branch], {
|
|
214
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function createSprintRoutes({ app, config, spawnTerminalSession, getSession }) {
|
|
219
|
+
if (!app) throw new Error('app required');
|
|
220
|
+
if (typeof spawnTerminalSession !== 'function') {
|
|
221
|
+
throw new Error('spawnTerminalSession callback required');
|
|
222
|
+
}
|
|
223
|
+
if (typeof getSession !== 'function') {
|
|
224
|
+
throw new Error('getSession callback required');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
228
|
+
|
|
229
|
+
// POST /api/sprints — create scaffolding, spawn 4 panels, inject prompts.
|
|
230
|
+
app.post('/api/sprints', async (req, res) => {
|
|
231
|
+
const body = req.body || {};
|
|
232
|
+
const {
|
|
233
|
+
project,
|
|
234
|
+
name,
|
|
235
|
+
targetVersion,
|
|
236
|
+
goal,
|
|
237
|
+
lanes,
|
|
238
|
+
worktree = false,
|
|
239
|
+
autoInject = true,
|
|
240
|
+
command,
|
|
241
|
+
sprintNumber,
|
|
242
|
+
} = body;
|
|
243
|
+
|
|
244
|
+
if (!name || !SLUG_RE.test(name)) {
|
|
245
|
+
return res.status(400).json({
|
|
246
|
+
error: 'name must be a slug (lowercase a-z0-9 + hyphens, ≤40 chars)',
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
if (!Array.isArray(lanes) || lanes.length !== 4) {
|
|
250
|
+
return res.status(400).json({ error: 'exactly 4 lanes are required' });
|
|
251
|
+
}
|
|
252
|
+
for (let i = 0; i < lanes.length; i++) {
|
|
253
|
+
const l = lanes[i] || {};
|
|
254
|
+
if (!l.name || typeof l.name !== 'string') {
|
|
255
|
+
return res.status(400).json({ error: `lane T${i + 1} missing name` });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let projectPath;
|
|
260
|
+
try {
|
|
261
|
+
projectPath = resolveProjectPath(config, project);
|
|
262
|
+
} catch (err) {
|
|
263
|
+
return res.status(400).json({ error: err.message });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const number =
|
|
267
|
+
Number.isInteger(sprintNumber) && sprintNumber > 0
|
|
268
|
+
? sprintNumber
|
|
269
|
+
: nextSprintNumber(projectPath);
|
|
270
|
+
|
|
271
|
+
const sprintDir = path.join(projectPath, 'docs', `sprint-${number}-${name}`);
|
|
272
|
+
if (fs.existsSync(sprintDir)) {
|
|
273
|
+
return res.status(409).json({ error: `sprint dir already exists: ${sprintDir}` });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Scaffold sprint files.
|
|
277
|
+
try {
|
|
278
|
+
fs.mkdirSync(sprintDir, { recursive: true });
|
|
279
|
+
fs.writeFileSync(
|
|
280
|
+
path.join(sprintDir, 'PLANNING.md'),
|
|
281
|
+
planningTemplate({ name, number, targetVersion, goal, lanes }),
|
|
282
|
+
'utf8',
|
|
283
|
+
);
|
|
284
|
+
fs.writeFileSync(
|
|
285
|
+
path.join(sprintDir, 'STATUS.md'),
|
|
286
|
+
statusTemplate({ name, number, lanes }),
|
|
287
|
+
'utf8',
|
|
288
|
+
);
|
|
289
|
+
for (let i = 0; i < lanes.length; i++) {
|
|
290
|
+
const lane = lanes[i];
|
|
291
|
+
const laneNumber = i + 1;
|
|
292
|
+
const filename = `T${laneNumber}-${slugify(lane.name)}.md`;
|
|
293
|
+
fs.writeFileSync(
|
|
294
|
+
path.join(sprintDir, filename),
|
|
295
|
+
laneTemplate({
|
|
296
|
+
name,
|
|
297
|
+
number,
|
|
298
|
+
sprintName: name,
|
|
299
|
+
lane: lane.name,
|
|
300
|
+
laneNumber,
|
|
301
|
+
project,
|
|
302
|
+
goal: lane.goal,
|
|
303
|
+
worktree,
|
|
304
|
+
}),
|
|
305
|
+
'utf8',
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
} catch (err) {
|
|
309
|
+
return res.status(500).json({ error: `scaffold failed: ${err.message}` });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Optional worktree creation per lane.
|
|
313
|
+
const worktreePaths = [];
|
|
314
|
+
if (worktree) {
|
|
315
|
+
try {
|
|
316
|
+
for (let i = 0; i < 4; i++) {
|
|
317
|
+
const wtPath = path.join(sprintDir, 'worktrees', `T${i + 1}`);
|
|
318
|
+
const branch = `sprint-${name}-T${i + 1}`;
|
|
319
|
+
gitWorktreeAdd({ projectPath, worktreePath: wtPath, branch });
|
|
320
|
+
worktreePaths.push(wtPath);
|
|
321
|
+
}
|
|
322
|
+
} catch (err) {
|
|
323
|
+
return res.status(500).json({
|
|
324
|
+
error: `git worktree add failed: ${err.message}`,
|
|
325
|
+
partialWorktrees: worktreePaths,
|
|
326
|
+
sprintDir,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Spawn 4 sessions.
|
|
332
|
+
const sessionIds = {};
|
|
333
|
+
const prompts = [];
|
|
334
|
+
const panelSessions = [];
|
|
335
|
+
for (let i = 0; i < 4; i++) {
|
|
336
|
+
const lane = lanes[i];
|
|
337
|
+
const laneNumber = i + 1;
|
|
338
|
+
const cwd = worktree ? worktreePaths[i] : projectPath;
|
|
339
|
+
const label = `Sprint ${number} · T${laneNumber} · ${lane.name}`;
|
|
340
|
+
let session;
|
|
341
|
+
try {
|
|
342
|
+
session = spawnTerminalSession({
|
|
343
|
+
command: command || 'claude',
|
|
344
|
+
cwd,
|
|
345
|
+
project,
|
|
346
|
+
label,
|
|
347
|
+
type: 'claude',
|
|
348
|
+
reason: `sprint-${number}-${name} T${laneNumber}`,
|
|
349
|
+
});
|
|
350
|
+
} catch (err) {
|
|
351
|
+
return res.status(500).json({
|
|
352
|
+
error: `spawn failed for T${laneNumber}: ${err.message}`,
|
|
353
|
+
sprintDir,
|
|
354
|
+
sessionIds,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
panelSessions.push(session);
|
|
358
|
+
sessionIds[`T${laneNumber}`] = session.id;
|
|
359
|
+
prompts.push(
|
|
360
|
+
bootPromptTemplate({
|
|
361
|
+
project,
|
|
362
|
+
number,
|
|
363
|
+
sprintName: name,
|
|
364
|
+
laneNumber,
|
|
365
|
+
laneName: lane.name,
|
|
366
|
+
goal: lane.goal,
|
|
367
|
+
}),
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Inject prompts (two-stage submit + verify-and-poke). Off → user kicks
|
|
372
|
+
// off later via /api/sprints/:name/inject (not yet implemented for v0.9.0).
|
|
373
|
+
let injectResult = null;
|
|
374
|
+
if (autoInject) {
|
|
375
|
+
const writeBytes = async (sessionId, bytes) => {
|
|
376
|
+
const sess = getSession(sessionId);
|
|
377
|
+
if (!sess || !sess.pty) throw new Error(`session ${sessionId} has no PTY`);
|
|
378
|
+
sess.pty.write(bytes);
|
|
379
|
+
if (typeof sess.trackInput === 'function') sess.trackInput(bytes);
|
|
380
|
+
return { bytes: bytes.length };
|
|
381
|
+
};
|
|
382
|
+
const getStatus = async (sessionId) => {
|
|
383
|
+
const sess = getSession(sessionId);
|
|
384
|
+
if (!sess) return null;
|
|
385
|
+
return { status: sess.meta && sess.meta.status, statusDetail: sess.meta && sess.meta.statusDetail };
|
|
386
|
+
};
|
|
387
|
+
try {
|
|
388
|
+
injectResult = await injectSprintPrompts({
|
|
389
|
+
sessionIds: panelSessions.map((s) => s.id),
|
|
390
|
+
prompts,
|
|
391
|
+
writeBytes,
|
|
392
|
+
getStatus,
|
|
393
|
+
sleep,
|
|
394
|
+
});
|
|
395
|
+
} catch (err) {
|
|
396
|
+
injectResult = { ok: false, error: err.message };
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
res.status(201).json({
|
|
401
|
+
ok: true,
|
|
402
|
+
sprintDir,
|
|
403
|
+
sprintNumber: number,
|
|
404
|
+
sprintName: name,
|
|
405
|
+
sessionIds,
|
|
406
|
+
worktreePaths,
|
|
407
|
+
worktree,
|
|
408
|
+
autoInject,
|
|
409
|
+
inject: injectResult,
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// GET /api/sprints?project=foo — list sprints in a project.
|
|
414
|
+
app.get('/api/sprints', (req, res) => {
|
|
415
|
+
const project = req.query.project;
|
|
416
|
+
if (!project) return res.status(400).json({ error: 'project query param required' });
|
|
417
|
+
let projectPath;
|
|
418
|
+
try {
|
|
419
|
+
projectPath = resolveProjectPath(config, project);
|
|
420
|
+
} catch (err) {
|
|
421
|
+
return res.status(400).json({ error: err.message });
|
|
422
|
+
}
|
|
423
|
+
const sprints = listExistingSprints(projectPath).map((s) => ({
|
|
424
|
+
number: s.number,
|
|
425
|
+
slug: s.slug,
|
|
426
|
+
relDir: s.relDir,
|
|
427
|
+
}));
|
|
428
|
+
res.json({ project, sprints });
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// GET /api/sprints/:name/status?project=foo — parse STATUS.md per-lane.
|
|
432
|
+
app.get('/api/sprints/:name/status', (req, res) => {
|
|
433
|
+
const project = req.query.project;
|
|
434
|
+
const sprintName = req.params.name;
|
|
435
|
+
if (!project) return res.status(400).json({ error: 'project query param required' });
|
|
436
|
+
let projectPath;
|
|
437
|
+
try {
|
|
438
|
+
projectPath = resolveProjectPath(config, project);
|
|
439
|
+
} catch (err) {
|
|
440
|
+
return res.status(400).json({ error: err.message });
|
|
441
|
+
}
|
|
442
|
+
const sprint = findSprintDir(projectPath, sprintName);
|
|
443
|
+
if (!sprint) return res.status(404).json({ error: `sprint not found: ${sprintName}` });
|
|
444
|
+
const statusPath = path.join(sprint.dir, 'STATUS.md');
|
|
445
|
+
if (!fs.existsSync(statusPath)) {
|
|
446
|
+
return res.status(404).json({ error: 'STATUS.md not found' });
|
|
447
|
+
}
|
|
448
|
+
const text = fs.readFileSync(statusPath, 'utf8');
|
|
449
|
+
const parsed = parseStatusMd(text);
|
|
450
|
+
const stat = fs.statSync(statusPath);
|
|
451
|
+
res.json({
|
|
452
|
+
project,
|
|
453
|
+
sprintName,
|
|
454
|
+
sprintNumber: sprint.number,
|
|
455
|
+
lanes: parsed.lanes,
|
|
456
|
+
lastEntryAt: parsed.lastEntryAt,
|
|
457
|
+
lastModifiedAt: stat.mtime.toISOString(),
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// GET /api/sprints/:name/tail?project=foo&lines=N — raw tail of STATUS.md.
|
|
462
|
+
app.get('/api/sprints/:name/tail', (req, res) => {
|
|
463
|
+
const project = req.query.project;
|
|
464
|
+
const sprintName = req.params.name;
|
|
465
|
+
const lines = Math.max(1, Math.min(2000, parseInt(req.query.lines, 10) || 100));
|
|
466
|
+
if (!project) return res.status(400).json({ error: 'project query param required' });
|
|
467
|
+
let projectPath;
|
|
468
|
+
try {
|
|
469
|
+
projectPath = resolveProjectPath(config, project);
|
|
470
|
+
} catch (err) {
|
|
471
|
+
return res.status(400).json({ error: err.message });
|
|
472
|
+
}
|
|
473
|
+
const sprint = findSprintDir(projectPath, sprintName);
|
|
474
|
+
if (!sprint) return res.status(404).json({ error: `sprint not found: ${sprintName}` });
|
|
475
|
+
const statusPath = path.join(sprint.dir, 'STATUS.md');
|
|
476
|
+
if (!fs.existsSync(statusPath)) {
|
|
477
|
+
return res.status(404).json({ error: 'STATUS.md not found' });
|
|
478
|
+
}
|
|
479
|
+
const text = fs.readFileSync(statusPath, 'utf8');
|
|
480
|
+
const all = text.split(/\r?\n/);
|
|
481
|
+
const tail = all.slice(-lines).join('\n');
|
|
482
|
+
res.json({
|
|
483
|
+
project,
|
|
484
|
+
sprintName,
|
|
485
|
+
sprintNumber: sprint.number,
|
|
486
|
+
lines: tail.split('\n').length,
|
|
487
|
+
tail,
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
module.exports = {
|
|
493
|
+
createSprintRoutes,
|
|
494
|
+
// Exported for tests + reuse:
|
|
495
|
+
parseStatusMd,
|
|
496
|
+
planningTemplate,
|
|
497
|
+
laneTemplate,
|
|
498
|
+
statusTemplate,
|
|
499
|
+
bootPromptTemplate,
|
|
500
|
+
slugify,
|
|
501
|
+
listExistingSprints,
|
|
502
|
+
nextSprintNumber,
|
|
503
|
+
};
|