create-pmos 0.38.7 → 0.39.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/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/README.md +64 -1
- package/workspace/bin/pmos.js +10 -1
- package/workspace/guide/index.md +38 -0
- package/workspace/guide/initiatives-and-next-steps.md +72 -0
- package/workspace/guide/layout.md +59 -0
- package/workspace/guide/scheduled-tasks-and-cli.md +41 -0
- package/workspace/guide/statuses.md +53 -0
- package/workspace/guide/validate-with-typesafe.md +47 -0
- package/workspace/lib/initiative-files.js +134 -0
- package/workspace/lib/judgment.js +65 -0
- package/workspace/lib/mobile-nav.js +1 -1
- package/workspace/lib/next-step-actions.js +139 -0
- package/workspace/lib/next-steps.js +133 -0
- package/workspace/lib/prompt-input.js +18 -0
- package/workspace/lib/schedule-cli.js +259 -0
- package/workspace/lib/schedule-logic.js +23 -1
- package/workspace/lib/secret-scan.js +28 -0
- package/workspace/lib/session-budget.js +35 -0
- package/workspace/lib/skill-store.js +11 -3
- package/workspace/lib/status-groups.js +109 -0
- package/workspace/lib/transcripts.js +73 -0
- package/workspace/lib/typesafe-settings.js +119 -0
- package/workspace/lib/typesafe.js +124 -0
- package/workspace/lib/validate.js +304 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +3 -3
- package/workspace/public/index.html +2 -0
- package/workspace/public/pm/app.js +53 -18
- package/workspace/public/pm/data.js +41 -5
- package/workspace/public/pm/mobile.js +1 -1
- package/workspace/public/pm/modes-live.js +171 -0
- package/workspace/public/pm/modes.js +25 -0
- package/workspace/public/pm/next-step-ui.js +185 -0
- package/workspace/public/pm/panel-detail.js +121 -1
- package/workspace/public/pm/panel-initiatives.js +61 -3
- package/workspace/public/pm/panel-repos.js +2 -2
- package/workspace/public/pm/panel-tabs.js +38 -2
- package/workspace/public/pm/panel-trees.js +9 -2
- package/workspace/public/pm/state.js +9 -4
- package/workspace/public/pm/status-banner.js +87 -0
- package/workspace/public/pm/tab-doc.js +11 -6
- package/workspace/server.js +434 -8
package/workspace/server.js
CHANGED
|
@@ -62,7 +62,18 @@ ensureSpawnHelperExecutable();
|
|
|
62
62
|
|
|
63
63
|
const { checkSurface } = require('./lib/surface-contract');
|
|
64
64
|
const { createSinkGuard } = require('./lib/sink.js');
|
|
65
|
+
const tsSettings = require('./lib/typesafe-settings.js');
|
|
66
|
+
const tsClient = require('./lib/typesafe.js');
|
|
67
|
+
const validateLib = require('./lib/validate.js');
|
|
68
|
+
tsSettings.adoptEnv(process.env); // D79 — take the env key into memory and delete it, before anything is spawned
|
|
65
69
|
const scheduleLogic = require('./lib/schedule-logic');
|
|
70
|
+
const { safeFirstInput } = require('./lib/prompt-input');
|
|
71
|
+
const transcriptsLib = require('./lib/transcripts');
|
|
72
|
+
const initiativeFiles = require('./lib/initiative-files');
|
|
73
|
+
const { deriveNextSteps } = require('./lib/next-steps');
|
|
74
|
+
const nextStepActions = require('./lib/next-step-actions');
|
|
75
|
+
const sessionBudget = require('./lib/session-budget');
|
|
76
|
+
const judgmentLib = require('./lib/judgment');
|
|
66
77
|
const harness = require('./lib/harness-parse');
|
|
67
78
|
const transcript = require('./lib/transcript-read');
|
|
68
79
|
const skillStore = require('./lib/skill-store');
|
|
@@ -256,6 +267,9 @@ function sessionEnv() {
|
|
|
256
267
|
for (const k of INHERITED_SESSION_MARKERS) delete env[k];
|
|
257
268
|
// also drop any other CLAUDE_CODE_* the CLI adds to mark a nested run
|
|
258
269
|
for (const k of Object.keys(env)) if (/^CLAUDE_CODE_/.test(k)) delete env[k];
|
|
270
|
+
// D79 — the TypeSafe key is the workspace server's own credential. A terminal runs an agent that can
|
|
271
|
+
// print its environment, so the key is never handed to one, whether it came from settings or the shell.
|
|
272
|
+
delete env[tsSettings.ENV_NAME];
|
|
259
273
|
return env;
|
|
260
274
|
}
|
|
261
275
|
|
|
@@ -1642,6 +1656,13 @@ function readLifecycle(w) {
|
|
|
1642
1656
|
}
|
|
1643
1657
|
return latest;
|
|
1644
1658
|
}
|
|
1659
|
+
// The whole lifecycle history of one initiative, oldest first, so the detail can say how it got where it is.
|
|
1660
|
+
function lifecycleHistory(w, id) {
|
|
1661
|
+
return readLedger(w, 'lifecycle.jsonl')
|
|
1662
|
+
.filter((r) => r && r.initiative_id === id && (r.state === 'active' || STOPPED_STATES.includes(r.state)))
|
|
1663
|
+
.map((r) => ({ state: r.state, reason: r.reason || '', pm: r.pm || '', at: r.at || '' }))
|
|
1664
|
+
.sort((a, b) => String(a.at).localeCompare(String(b.at))).slice(-20);
|
|
1665
|
+
}
|
|
1645
1666
|
function readAcceptances(w) {
|
|
1646
1667
|
const acc = new Map(); // initiative_id -> { action, at }
|
|
1647
1668
|
for (const r of readLedger(w, 'acceptances.jsonl')) {
|
|
@@ -2040,6 +2061,13 @@ const SEED_SETS = {
|
|
|
2040
2061
|
function readSchedules() {
|
|
2041
2062
|
try { const a = JSON.parse(fs.readFileSync(schedFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; }
|
|
2042
2063
|
}
|
|
2064
|
+
// R5 — count a fire against the schedule's own limits. A schedule that has run `max_runs` times, or whose `until`
|
|
2065
|
+
// day is over, turns itself off and says why; nothing about a schedule without either changes.
|
|
2066
|
+
function countRun(cur, now = new Date()) {
|
|
2067
|
+
cur.runs = (+cur.runs || 0) + 1;
|
|
2068
|
+
const why = scheduleLogic.finishReason(cur, now);
|
|
2069
|
+
if (why) { cur.enabled = false; cur.endedReason = why; cur.nextRun = null; }
|
|
2070
|
+
}
|
|
2043
2071
|
function writeSchedules(list) {
|
|
2044
2072
|
try { fs.writeFileSync(schedFile(), JSON.stringify(list)); }
|
|
2045
2073
|
catch (e) { warnWriteFail('schedules.json (scheduled tasks)', e); }
|
|
@@ -2088,7 +2116,7 @@ function fireSchedule(sched) {
|
|
|
2088
2116
|
modelAsk: { scheduleTier: sched.tier || null, skillTier: task.tier || null, skill: task.skill } });
|
|
2089
2117
|
const list = readSchedules(); const cur = list.find((x) => x.id === sched.id);
|
|
2090
2118
|
const started = s.state !== 'exited';
|
|
2091
|
-
if (cur) { cur.lastRun = nowISO(); cur.lastStatus = started ? 'started' : 'fail'; cur.nextRun = scheduleLogic.nextRun(cur); if (cur.cadence === 'once') cur.enabled = false; writeSchedules(list); }
|
|
2119
|
+
if (cur) { cur.lastRun = nowISO(); cur.lastStatus = started ? 'started' : 'fail'; cur.nextRun = scheduleLogic.nextRun(cur); if (cur.cadence === 'once') cur.enabled = false; countRun(cur); writeSchedules(list); }
|
|
2092
2120
|
// A run that started WITHOUT the profile it recorded is reported as such rather than as a
|
|
2093
2121
|
// clean start: the session is real, the environment is not the one the schedule declared.
|
|
2094
2122
|
return { ok: started, kind: 'agent', sessionId: s.id,
|
|
@@ -2110,7 +2138,7 @@ function fireSchedule(sched) {
|
|
|
2110
2138
|
// stamp lastRun/lastStatus/nextRun via the pure module
|
|
2111
2139
|
const list = readSchedules();
|
|
2112
2140
|
const cur = list.find((s) => s.id === sched.id);
|
|
2113
|
-
if (cur) { cur.lastRun = nowISO(); cur.lastStatus = ok ? 'ok' : 'fail'; cur.nextRun = scheduleLogic.nextRun(cur); writeSchedules(list); }
|
|
2141
|
+
if (cur) { cur.lastRun = nowISO(); cur.lastStatus = ok ? 'ok' : 'fail'; cur.nextRun = scheduleLogic.nextRun(cur); countRun(cur); writeSchedules(list); }
|
|
2114
2142
|
// surface it — the finished run reaches the PM through needs-you (R5), honest on failure
|
|
2115
2143
|
try {
|
|
2116
2144
|
const nf = needsFileFor(w);
|
|
@@ -2131,6 +2159,7 @@ function checkDueSchedules() {
|
|
|
2131
2159
|
try {
|
|
2132
2160
|
const list = readSchedules(); let changed = false;
|
|
2133
2161
|
for (const s of list) if (scheduleLogic.isMissed(s)) { s.enabled = false; s.lastStatus = 'missed'; s.nextRun = null; changed = true; }
|
|
2162
|
+
for (const s of list) { const why = s.enabled && scheduleLogic.finishReason(s); if (why) { s.enabled = false; s.endedReason = why; s.nextRun = null; changed = true; } }
|
|
2134
2163
|
if (changed) writeSchedules(list);
|
|
2135
2164
|
} catch {}
|
|
2136
2165
|
return fired;
|
|
@@ -2145,7 +2174,29 @@ const sessions = new Map();
|
|
|
2145
2174
|
const allClients = new Set();
|
|
2146
2175
|
let seq = 0;
|
|
2147
2176
|
|
|
2177
|
+
// The initiative a session belongs to, for the conversation record: the PM's or spawn's explicit word first,
|
|
2178
|
+
// else the `initiative/<id>` branch its working tree is on (the same join the initiative detail makes).
|
|
2179
|
+
function initiativeOfSession(s) {
|
|
2180
|
+
if (s && s.initiative) return String(s.initiative);
|
|
2181
|
+
try {
|
|
2182
|
+
const w = wsList().find((x) => x.id === (s && s.ws));
|
|
2183
|
+
const m = w && w.branch ? /^initiative\/(.+)$/.exec(String(w.branch)) : null;
|
|
2184
|
+
return m ? m[1] : null;
|
|
2185
|
+
} catch { return null; }
|
|
2186
|
+
}
|
|
2187
|
+
// Save the conversation of a session that belongs to an initiative, so ending or removing it no longer erases it.
|
|
2188
|
+
// Runtime state only, never the repository (PM ruling 2026-10-03). Quiet when the session is on no initiative.
|
|
2189
|
+
function recordConversation(s) {
|
|
2190
|
+
const initiative = initiativeOfSession(s);
|
|
2191
|
+
if (!initiative) return { ok: false, skipped: true };
|
|
2192
|
+
const r = transcriptsLib.save(STATE, { id: s.id, name: s.name, agent: s.agent, ws: s.ws, initiative,
|
|
2193
|
+
state: s.state, started: s.created ? new Date(s.created).toISOString() : null, text: s.buf });
|
|
2194
|
+
if (!r.ok) warnWriteFail('transcripts (conversation record)', new Error(r.error));
|
|
2195
|
+
return r;
|
|
2196
|
+
}
|
|
2148
2197
|
function persist(s) {
|
|
2198
|
+
if (s.state === 'exited' && !s._txSaved) { s._txSaved = true; try { recordConversation(s); } catch {} }
|
|
2199
|
+
else if (s.state !== 'exited') s._txSaved = false;
|
|
2149
2200
|
// note IS persisted — a restored session must still say why it exited. `root` is persisted
|
|
2150
2201
|
// so a session survives even if the workspace registry is lost (it re-registers its root).
|
|
2151
2202
|
const meta = { id: s.id, ws: s.ws, root: s.root, name: s.name, agent: s.agent, cwd: s.cwd,
|
|
@@ -2872,7 +2923,8 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
2872
2923
|
try { memoryLib.renderFor(root); } catch { /* memory is never a reason a session fails to start */ }
|
|
2873
2924
|
// a scheduled run tags the session with its origin, and feeds the skill invocation as first input
|
|
2874
2925
|
const tag = origin || null;
|
|
2875
|
-
|
|
2926
|
+
// safeFirstInput: a first input that begins with `/` is read by the agent as a slash command, not as the task.
|
|
2927
|
+
const feed = (s) => { if (initialInput && s && s.pty) { try { setTimeout(() => { try { s.pty.write(safeFirstInput(initialInput)); } catch {} }, 700); } catch {} } };
|
|
2876
2928
|
// shell gets a real fallback to `sh`; agent CLIs run as-is (harness untouched), NO fallback —
|
|
2877
2929
|
// falling back from `claude` to `sh` would be a lie.
|
|
2878
2930
|
const cmd = isShell ? (commandExists(SHELL) ? SHELL : 'sh') : agent;
|
|
@@ -3004,7 +3056,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
3004
3056
|
// A FAILED git operation in a real repo must never spawn a session on the wrong branch (rubric
|
|
3005
3057
|
// hard floor): we return {ok:false} and spawn nothing. Kept as its own function so the
|
|
3006
3058
|
// git-then-maybe-spawn contract is unit-testable without a WS client.
|
|
3007
|
-
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative, modelAsk }) {
|
|
3059
|
+
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative, modelAsk, origin, initialInput }) {
|
|
3008
3060
|
const w = getWs(ws);
|
|
3009
3061
|
if (!w) return { ok: false, error: 'no workspace' };
|
|
3010
3062
|
// The profile is checked BEFORE any git operation. A composer that picked a profile PMOS cannot
|
|
@@ -3051,7 +3103,7 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
|
|
|
3051
3103
|
broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
|
|
3052
3104
|
}
|
|
3053
3105
|
}
|
|
3054
|
-
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative, modelAsk });
|
|
3106
|
+
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative, modelAsk, origin, initialInput });
|
|
3055
3107
|
// The co-tenancy fact, on the ordinary path too. Note where this sits: OUTSIDE the `if (branch)`
|
|
3056
3108
|
// block above. The isolate default already computes that other sessions would be disturbed — and
|
|
3057
3109
|
// then the whole git block is skipped when no branch was picked, which is the common path and the
|
|
@@ -3154,7 +3206,7 @@ function startPreview({ initiative, repoKey, ws }) {
|
|
|
3154
3206
|
// One click. Resolves the policy, then hands the SAME seam spawnWithBranch already exposes the
|
|
3155
3207
|
// branch/isolate decision it made — so the inherited hard floor (a failed git operation spawns
|
|
3156
3208
|
// nothing) is enforced in exactly one place and is not re-implemented here.
|
|
3157
|
-
function startWork({ initiative, repoKey, ws, agent, name, profile }) {
|
|
3209
|
+
function startWork({ initiative, repoKey, ws, agent, name, profile, origin, initialInput }) {
|
|
3158
3210
|
const key = repoKeyFor({ repoKey, ws });
|
|
3159
3211
|
if (!key) return { ok: false, error: 'no repository selected' };
|
|
3160
3212
|
const trees = treesOfRepo(key);
|
|
@@ -3177,11 +3229,111 @@ function startWork({ initiative, repoKey, ws, agent, name, profile }) {
|
|
|
3177
3229
|
// arrived here, decided the branch and supplied a fallback display name, and was then dropped
|
|
3178
3230
|
// — so the one start path DESIGNED to know what it was starting work on never told the session.
|
|
3179
3231
|
initiative,
|
|
3232
|
+
// next-step-actions: a session started from a next step carries its origin (so the budget can count it) and its
|
|
3233
|
+
// prompt. Only the /api/next-step/go route passes either; the socket's `start` message never does.
|
|
3234
|
+
origin, initialInput,
|
|
3180
3235
|
});
|
|
3181
3236
|
if (!r.ok) return r;
|
|
3182
3237
|
return { ok: true, session: r.session, notice: r.notice || plan.why, plan: plan.plan };
|
|
3183
3238
|
}
|
|
3184
3239
|
|
|
3240
|
+
// ── Next-step actions (initiative next-step-actions) ─────────────────────────────────────────────────────────────
|
|
3241
|
+
// A next step can be PREPARED (a read-only plan: branch, tree, prompt, sources, budget — nothing is created) and then
|
|
3242
|
+
// STARTED by the PM's press, with exactly the prompt they were shown. The session proposes only (lib/next-step-actions
|
|
3243
|
+
// RULES). Records live in the runtime state directory: what was started, and the PM's verdict on it.
|
|
3244
|
+
const NS_RUNS = () => path.join(STATE, 'next-step-runs.jsonl');
|
|
3245
|
+
const NS_REVIEWS = () => path.join(STATE, 'step-reviews.jsonl');
|
|
3246
|
+
const NS_SETTINGS = () => path.join(STATE, 'next-step-settings.json');
|
|
3247
|
+
function nsRead(file) {
|
|
3248
|
+
let text = ''; try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
3249
|
+
const out = [];
|
|
3250
|
+
for (const line of text.split('\n')) { if (!line.trim()) continue; try { out.push(JSON.parse(line)); } catch { /* a torn line is skipped */ } }
|
|
3251
|
+
return out;
|
|
3252
|
+
}
|
|
3253
|
+
function nsAppend(file, rec, what) {
|
|
3254
|
+
try { fs.mkdirSync(path.dirname(file), { recursive: true }); createSinkGuard({ sink: file, what }).appendLine(file, rec); return { ok: true }; }
|
|
3255
|
+
catch (e) { warnWriteFail(path.basename(file), e); return { ok: false, error: 'could not record — nothing was saved' }; }
|
|
3256
|
+
}
|
|
3257
|
+
function nsLimits() {
|
|
3258
|
+
try { const v = JSON.parse(fs.readFileSync(NS_SETTINGS(), 'utf8')); const n = sessionBudget.normalizeLimits(v && v.budget); if (n.ok) return n.limits; } catch { /* defaults */ }
|
|
3259
|
+
return { ...sessionBudget.DEFAULTS };
|
|
3260
|
+
}
|
|
3261
|
+
function nsBudget() {
|
|
3262
|
+
const today = new Date().toDateString();
|
|
3263
|
+
const startedToday = nsRead(NS_RUNS()).filter((r) => r && r.at && new Date(r.at).toDateString() === today).length;
|
|
3264
|
+
const running = sessionList().filter((x) => x.origin === 'next-step' && x.state !== 'exited').length;
|
|
3265
|
+
return sessionBudget.check({ running, startedToday, limits: nsLimits() });
|
|
3266
|
+
}
|
|
3267
|
+
// The first repository file a step's own text names (planning/… .md or .jsonl) that exists in this repository.
|
|
3268
|
+
function nextStepPathOf(root, it) {
|
|
3269
|
+
const m = /(planning\/[\w\-./]+\.(?:md|jsonl))/.exec(`${it.why || ''} ${it.action || ''}`);
|
|
3270
|
+
if (!m) return null;
|
|
3271
|
+
const full = path.resolve(root, m[1]);
|
|
3272
|
+
return insideRoot(root, full) && fs.existsSync(full) ? m[1] : null;
|
|
3273
|
+
}
|
|
3274
|
+
// The latest run started from each item, and the PM's latest verdict on it.
|
|
3275
|
+
function attachRuns(ns, initiativeId) {
|
|
3276
|
+
if (!ns || !Array.isArray(ns.items)) return;
|
|
3277
|
+
const runs = nsRead(NS_RUNS()).filter((r) => r && r.initiative_id === initiativeId);
|
|
3278
|
+
const reviews = nsRead(NS_REVIEWS()).filter((r) => r && r.initiative_id === initiativeId);
|
|
3279
|
+
for (const it of ns.items) {
|
|
3280
|
+
const run = runs.filter((r) => r.item_id === it.id).pop();
|
|
3281
|
+
if (!run) continue;
|
|
3282
|
+
const s = sessions.get(run.session_id);
|
|
3283
|
+
const rv = reviews.filter((r) => r.session_id === run.session_id).pop() || null;
|
|
3284
|
+
it.run = { session_id: run.session_id, at: run.at, branch: run.branch || null, state: s ? (s.state === 'exited' ? 'ended' : 'live') : 'ended',
|
|
3285
|
+
review: rv ? { verdict: rv.verdict, note: rv.note || '', at: rv.at } : null };
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
function nextStepsFor(w, id) {
|
|
3289
|
+
const root = w ? w.root : SELF_ROOT;
|
|
3290
|
+
const engine = consoleState.loadEngine();
|
|
3291
|
+
if (!engine.ok) return { ok: false, error: engine.error };
|
|
3292
|
+
const L = engine.engine.layout(root);
|
|
3293
|
+
const rec = fourPanel.readRecord({ root, dir: L.initiatives, id, parse: engine.engine.parseLeadingFrontmatter });
|
|
3294
|
+
if (!rec.ok || rec.absent) return { ok: false, error: `no initiative record "${id}" in ${w ? w.name : 'this repository'}`, status: 404 };
|
|
3295
|
+
let ladder;
|
|
3296
|
+
try { ladder = consoleState.ladder({ root, id }); } catch (e) { ladder = { ok: false, error: 'the ladder could not be derived: ' + String((e && e.message) || 'error').slice(0, 140) }; }
|
|
3297
|
+
const sessRows = sessionList();
|
|
3298
|
+
const liveIds = new Set(sessRows.filter((x) => x.state !== 'exited' && initiativeOfSession(sessions.get(x.id)) === id).map((x) => x.id));
|
|
3299
|
+
const acc = readAcceptances(w).get(id) || {};
|
|
3300
|
+
const lc = readLifecycle(w).get(id) || null;
|
|
3301
|
+
const ns = deriveNextSteps({ id, accepted: acc.action === 'accept', returned: acc.action === 'return',
|
|
3302
|
+
lifecycle: lc && lc.state !== 'active' ? lc : null, ladder, needs: (readNeeds(w) || []).filter((n) => n && n.initiative_id === id),
|
|
3303
|
+
sessions: sessRows.filter((x) => x.initiative === id || liveIds.has(x.id)) });
|
|
3304
|
+
nextStepActions.annotate(ns, (it) => nextStepPathOf(root, it));
|
|
3305
|
+
attachRuns(ns, id);
|
|
3306
|
+
return { ok: true, ns, title: (rec.frontmatter && rec.frontmatter.title) || id };
|
|
3307
|
+
}
|
|
3308
|
+
// Everything a prepare shows, computed WITHOUT creating or writing anything. `go` recomputes it and compares the hash.
|
|
3309
|
+
function prepareNextStep(body) {
|
|
3310
|
+
const w = getWs(body.ws);
|
|
3311
|
+
if (!w) return { ok: false, status: 400, error: 'no workspace' };
|
|
3312
|
+
const id = String(body.initiative_id || '');
|
|
3313
|
+
if (!fourPanel.validId(id)) return { ok: false, status: 400, error: 'an initiative id is required, and it must be a record name' };
|
|
3314
|
+
const got = nextStepsFor(w, id);
|
|
3315
|
+
if (!got.ok) return { ok: false, status: got.status || 500, error: got.error };
|
|
3316
|
+
const item = got.ns.items.find((x) => x.id === String(body.item_id || ''));
|
|
3317
|
+
if (!item) return { ok: false, status: 404, error: 'no such step — the list may have changed; reload the initiative' };
|
|
3318
|
+
const agent = body.agent === 'shell' ? 'shell' : 'claude';
|
|
3319
|
+
const judgmentOn = body.judgment === true;
|
|
3320
|
+
const budget = nsBudget();
|
|
3321
|
+
const head = { ok: true, ws: w.id, initiative_id: id, agent, item: { id: item.id, key: item.key, kind: item.kind, text: item.text, owner: item.owner, act: item.act }, budget,
|
|
3322
|
+
existing_sessions: sessionList().filter((x) => x.state !== 'exited' && initiativeOfSession(sessions.get(x.id)) === id).map((x) => ({ id: x.id, name: x.name, origin: x.origin || null })) };
|
|
3323
|
+
const ref = nextStepActions.refusal(item, got.ns);
|
|
3324
|
+
if (ref) return { ...head, refused: ref, plan: null, prompt: null, prompt_sha: null, sources: [], judgment: { on: judgmentOn } };
|
|
3325
|
+
const preview = startPreview({ initiative: id, ws: w.id });
|
|
3326
|
+
if (!preview.ok) return { ...head, refused: { code: 'no-plan', why: preview.error || 'no branch and tree could be chosen' }, plan: null, prompt: null, prompt_sha: null, sources: [], judgment: { on: judgmentOn } };
|
|
3327
|
+
let mcp = [];
|
|
3328
|
+
if (body.profile) { try { const rp = require('./lib/profiles').readProfiles(os.homedir()); const pr = rp.ok && (rp.profiles || []).find((x) => x.id === body.profile); if (pr && Array.isArray(pr.mcp)) mcp = pr.mcp; } catch { mcp = []; } }
|
|
3329
|
+
const sources = nextStepActions.sourcesFor(item, mcp);
|
|
3330
|
+
const prompt = nextStepActions.buildPrompt({ initiative: id, title: got.title, item, branch: preview.branch || null, sources, judgment: judgmentLib.fragment(judgmentOn) });
|
|
3331
|
+
const sha = nextStepActions.planHash({ prompt, branch: preview.branch, wsId: preview.wsId, plan: preview.plan, judgment: judgmentOn });
|
|
3332
|
+
return { ...head, refused: null, plan: { plan: preview.plan, why: preview.why, dir: preview.dir, branch: preview.branch, wsId: preview.wsId }, prompt, prompt_sha: sha, sources,
|
|
3333
|
+
profile: body.profile || null,
|
|
3334
|
+
judgment: { on: judgmentOn, outside_service: judgmentOn, needs_key: judgmentOn, note: judgmentOn ? 'The question text leaves this machine for the Typesafe service. It needs TYPESAFE_API_KEY in the session.' : 'No outside service is used.' } };
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3185
3337
|
// ── Worktree housekeeping: the app that creates the folders shows and clears them ──────────────
|
|
3186
3338
|
// Isolation buys "your other sessions keep running" at the price of a directory per branch. That
|
|
3187
3339
|
// price is only fair if the PM can see the folders and reclaim them without leaving the workspace
|
|
@@ -3302,6 +3454,7 @@ function removeSession(id) {
|
|
|
3302
3454
|
const s = sessions.get(id);
|
|
3303
3455
|
if (!s || s.pty) return false;
|
|
3304
3456
|
if (s.tmux) tmuxKillSession(s.tmux); // best-effort: never orphan a detached tmux session
|
|
3457
|
+
try { recordConversation(s); } catch {} // the conversation outlives the session (surface-batch R5)
|
|
3305
3458
|
forgetScratchpad(s); // the tab's staging text goes with the tab
|
|
3306
3459
|
// Drop a claim this session held. Correctness does not depend on this — treeShare.writerOf only
|
|
3307
3460
|
// believes a claim whose holder is still live, so a stale entry already reads as vacant — but a
|
|
@@ -4199,6 +4352,28 @@ function serveFile(res, file, type, noCache) {
|
|
|
4199
4352
|
res.end(buf);
|
|
4200
4353
|
});
|
|
4201
4354
|
}
|
|
4355
|
+
// D79 — why a validation may not start, or null. The same words on the plan and the run, so the browser
|
|
4356
|
+
// never has to guess. No key means no request; no consent means this repository is never sent.
|
|
4357
|
+
const validating = new Set();
|
|
4358
|
+
function validationRefusal(w) {
|
|
4359
|
+
if (!tsSettings.status(STATE).present) return 'No TypeSafe key is set. Add one in Settings; without a key nothing is sent.';
|
|
4360
|
+
if (!tsSettings.getRepo(STATE, w.root).consent) return 'Validation is off for this repository. Switch it on first; until then nothing from it is sent.';
|
|
4361
|
+
return null;
|
|
4362
|
+
}
|
|
4363
|
+
// Findings are file-mode state: a PMOS-shaped repository keeps them beside its other ledgers, so they travel
|
|
4364
|
+
// with it. Any other repository keeps them in the per-user runtime directory, never inside the repository.
|
|
4365
|
+
function validationFindingsFile(w) {
|
|
4366
|
+
let d = null;
|
|
4367
|
+
if (isDir(path.join(w.root, 'planning', 'initiatives'))) d = path.join(w.root, 'planning', 'runs');
|
|
4368
|
+
else if (isDir(path.join(w.root, 'pmos', 'state'))) d = path.join(w.root, 'pmos', 'state');
|
|
4369
|
+
else {
|
|
4370
|
+
d = path.join(STATE, 'validation');
|
|
4371
|
+
try { fs.mkdirSync(d, { recursive: true }); } catch { /* the sink guard reports it */ }
|
|
4372
|
+
return path.join(d, require('crypto').createHash('sha1').update(w.root).digest('hex').slice(0, 12) + '.jsonl');
|
|
4373
|
+
}
|
|
4374
|
+
try { fs.mkdirSync(d, { recursive: true }); } catch { /* the sink guard reports it */ }
|
|
4375
|
+
return path.join(d, 'validation-findings.jsonl');
|
|
4376
|
+
}
|
|
4202
4377
|
function readBody(req) {
|
|
4203
4378
|
return new Promise((resolve) => { let b = ''; req.on('data', (c) => (b += c)); req.on('end', () => resolve(b)); });
|
|
4204
4379
|
}
|
|
@@ -4229,6 +4404,7 @@ async function handleRequest(req, res) {
|
|
|
4229
4404
|
// is the same two-hopper lib/four-panel.js makes per initiative on the server; the board makes it
|
|
4230
4405
|
// once over every session, from this module, so 178 rows cost no requests.
|
|
4231
4406
|
if (p === '/lib/working-set.js') return serveFile(res, path.join(__dirname, 'lib', 'working-set.js'));
|
|
4407
|
+
if (p === '/lib/status-groups.js') return serveFile(res, path.join(__dirname, 'lib', 'status-groups.js'));
|
|
4232
4408
|
// The editor computes its live diff with the SAME module the server saves through, so what the PM
|
|
4233
4409
|
// sees while typing and what gets written cannot disagree. NB: /lib/ is an explicit allowlist, not
|
|
4234
4410
|
// a directory route — a <script src> for a file missing from this list 404s silently, and the
|
|
@@ -4840,6 +5016,29 @@ async function handleRequest(req, res) {
|
|
|
4840
5016
|
if (err) return json(res, { ok: false, error: err }, 500);
|
|
4841
5017
|
return json(res, { ok: true, prefs });
|
|
4842
5018
|
}
|
|
5019
|
+
// One recorded conversation (R5): the live session's text, or the saved record once it has ended. Read-only.
|
|
5020
|
+
if (p === '/api/transcript' && req.method === 'GET') {
|
|
5021
|
+
const id = String(url.searchParams.get('id') || '');
|
|
5022
|
+
const live = sessions.get(id);
|
|
5023
|
+
if (live) {
|
|
5024
|
+
const initiative = initiativeOfSession(live);
|
|
5025
|
+
return json(res, { ok: true, id, name: live.name, initiative, state: live.state, live: live.state !== 'exited',
|
|
5026
|
+
stored: 'the runtime state directory — not the repository, and never sent anywhere',
|
|
5027
|
+
text: transcriptsLib.stripAnsi(live.buf).slice(-transcriptsLib.MAX_CHARS) });
|
|
5028
|
+
}
|
|
5029
|
+
const rec = transcriptsLib.read(STATE, id);
|
|
5030
|
+
if (!rec) return json(res, { ok: false, error: 'no recorded conversation with that id' }, 404);
|
|
5031
|
+
return json(res, { ok: true, ...rec, live: false, stored: 'the runtime state directory — not the repository, and never sent anywhere' });
|
|
5032
|
+
}
|
|
5033
|
+
// The tail of one session's terminal text, escape codes stripped, so a CLI can turn "what I just did" into a skill.
|
|
5034
|
+
// Read-only, local, and capped; it names the session it read or says there is none.
|
|
5035
|
+
if (p === '/api/sessions/transcript' && req.method === 'GET') {
|
|
5036
|
+
const sess = sessions.get(String(url.searchParams.get('id') || ''));
|
|
5037
|
+
if (!sess) return json(res, { ok: false, error: 'no such session' }, 404);
|
|
5038
|
+
const max = Math.min(60000, Math.max(500, +url.searchParams.get('max') || 20000));
|
|
5039
|
+
const text = transcriptsLib.stripAnsi(sess.buf).slice(-max);
|
|
5040
|
+
return json(res, { ok: true, id: sess.id, name: sess.name, chars: text.length, text });
|
|
5041
|
+
}
|
|
4843
5042
|
if (p === '/api/schedules' && req.method === 'GET') {
|
|
4844
5043
|
// `all=1` (overhaul-r4 D1): every repository's schedules — the drawer lists `scheduled` under "all
|
|
4845
5044
|
// repositories", so filtering to the home workspace there would hide the PM's other schedules
|
|
@@ -4905,8 +5104,32 @@ async function handleRequest(req, res) {
|
|
|
4905
5104
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
4906
5105
|
const list = readSchedules(); const s = list.find((x) => x.id === body.id);
|
|
4907
5106
|
if (!s) return json(res, { ok: false, error: 'no such schedule' }, 404);
|
|
4908
|
-
|
|
5107
|
+
// R5 — `until` and `max_runs` are validated by the same rules a new schedule is, and null/'' clears one. A refused value
|
|
5108
|
+
// returns BEFORE anything is changed, so a bad edit leaves the record as it was.
|
|
5109
|
+
const lim = {};
|
|
5110
|
+
for (const k of ['until', 'max_runs']) if (k in body) lim[k] = body[k];
|
|
5111
|
+
if (Object.keys(lim).length) {
|
|
5112
|
+
const chk = scheduleLogic.normalizeSchedule({ name: s.name, cadence: s.cadence === 'once' ? 'daily' : s.cadence, skill: s.skill, ...lim });
|
|
5113
|
+
if (!chk.ok) return json(res, { ok: false, error: chk.error }, 400);
|
|
5114
|
+
for (const k of Object.keys(lim)) { if (lim[k] == null || lim[k] === '') delete s[k]; else s[k] = chk.schedule[k]; }
|
|
5115
|
+
}
|
|
5116
|
+
if (typeof body.enabled === 'boolean') { s.enabled = body.enabled; if (body.enabled) delete s.endedReason; }
|
|
5117
|
+
// Every field an edit may set is checked BEFORE any is applied (R2): a refused edit names the allowed values and
|
|
5118
|
+
// leaves the record untouched. `skill` must be one that loads for this schedule's repository.
|
|
5119
|
+
const bad = (m) => json(res, { ok: false, error: m }, 400);
|
|
5120
|
+
if (body.cadence != null && !scheduleLogic.CADENCES.includes(body.cadence)) return bad(`cadence must be one of ${scheduleLogic.CADENCES.join(', ')}`);
|
|
5121
|
+
if (body.day != null && !['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].includes(body.day)) return bad('day must be one of Mon, Tue, Wed, Thu, Fri, Sat, Sun');
|
|
5122
|
+
if (body.dayOfMonth != null && !(Number.isInteger(+body.dayOfMonth) && +body.dayOfMonth >= 1 && +body.dayOfMonth <= 28)) return bad('dayOfMonth must be a whole number from 1 to 28');
|
|
5123
|
+
if (body.time != null && !/^([01]?\d|2[0-3]):[0-5]\d$/.test(String(body.time))) return bad('time must be HH:MM, 24-hour, this machine\'s clock');
|
|
5124
|
+
if (body.date != null && !/^\d{4}-\d{2}-\d{2}$/.test(String(body.date))) return bad('date must be YYYY-MM-DD');
|
|
5125
|
+
if (body.name != null && !String(body.name).trim()) return bad('name cannot be empty');
|
|
5126
|
+
if (body.args != null && String(body.args).length > 2000) return bad('args is limited to 2000 characters');
|
|
5127
|
+
for (const k of ['skill', 'task']) if (body[k] != null) {
|
|
5128
|
+
const ws0 = workspaces.get(s.ws);
|
|
5129
|
+
if (!taskById(body[k], ws0 ? ws0.root : null)) return bad(`no skill named "${body[k]}" is available to this schedule`);
|
|
5130
|
+
}
|
|
4909
5131
|
for (const k of ['name', 'cadence', 'day', 'dayOfMonth', 'time', 'task', 'skill', 'date', 'args']) if (body[k] != null) s[k] = body[k];
|
|
5132
|
+
if ('args' in body && (body.args === null || body.args === '')) delete s.args;
|
|
4910
5133
|
if ('tier' in body) { if (body.tier === null || body.tier === '') delete s.tier; else if (/^(fast|balanced|deep)$/.test(String(body.tier))) s.tier = String(body.tier); else return json(res, { ok: false, error: 'tier must be fast, balanced or deep' }, 400); }
|
|
4911
5134
|
// `profile` is settable to null (clear it) as well as to an id, so it is checked for PRESENCE
|
|
4912
5135
|
// of the key rather than for a non-null value — the other fields above cannot be cleared.
|
|
@@ -5052,8 +5275,39 @@ async function handleRequest(req, res) {
|
|
|
5052
5275
|
const doc = readLayouts(w);
|
|
5053
5276
|
return json(res, { ok: true, layouts: doc.layouts, currentId: doc.currentId, stored: doc.stored });
|
|
5054
5277
|
}
|
|
5278
|
+
if (p === '/api/guide' && req.method === 'GET') {
|
|
5279
|
+
// The user guide ships with the app (workspace/guide/*.md), so it is read from the app directory, not from a
|
|
5280
|
+
// registered repository. A page name is a bare token, never a path. Read-only.
|
|
5281
|
+
const gdir = path.join(__dirname, 'guide');
|
|
5282
|
+
const page = url.searchParams.get('page');
|
|
5283
|
+
if (page) {
|
|
5284
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(page)) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('no such guide page'); }
|
|
5285
|
+
try { const text = fs.readFileSync(path.join(gdir, page + '.md'), 'utf8'); res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8', 'X-Doc-Kind': 'markdown' }); return res.end(text); }
|
|
5286
|
+
catch { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('no such guide page'); }
|
|
5287
|
+
}
|
|
5288
|
+
let names = [];
|
|
5289
|
+
try { names = fs.readdirSync(gdir).filter((n) => /^[a-z0-9][a-z0-9-]*\.md$/.test(n)).map((n) => n.slice(0, -3)); }
|
|
5290
|
+
catch (e) { return json(res, { ok: false, error: 'the guide could not be read — ' + String((e && e.message) || 'error').slice(0, 120) }); }
|
|
5291
|
+
const order = ['index', 'layout', 'statuses', 'initiatives-and-next-steps', 'scheduled-tasks-and-cli'];
|
|
5292
|
+
names.sort((a, b) => (order.indexOf(a) < 0 ? 99 : order.indexOf(a)) - (order.indexOf(b) < 0 ? 99 : order.indexOf(b)) || a.localeCompare(b));
|
|
5293
|
+
const pages = names.map((n) => {
|
|
5294
|
+
let title = n;
|
|
5295
|
+
try { const m = /^title:\s*"?([^"\n]+)"?/m.exec(fs.readFileSync(path.join(gdir, n + '.md'), 'utf8').slice(0, 600)); if (m) title = m[1].trim(); } catch { /* keep the name */ }
|
|
5296
|
+
return { page: n, title };
|
|
5297
|
+
});
|
|
5298
|
+
return json(res, { ok: true, pages });
|
|
5299
|
+
}
|
|
5055
5300
|
if (p === '/api/doc') {
|
|
5056
5301
|
const w = getWs(url.searchParams.get('ws'));
|
|
5302
|
+
// `ref` — a file that exists only on an initiative's own branch (status-clarity R5). Read through git, never the
|
|
5303
|
+
// working tree, and only from an initiative branch; the reason for a refusal travels in the body.
|
|
5304
|
+
const ref = url.searchParams.get('ref');
|
|
5305
|
+
if (ref && w) {
|
|
5306
|
+
const at = initiativeFiles.readAtRef(w.root, ref, url.searchParams.get('path') || '');
|
|
5307
|
+
if (!at.ok) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end(at.reason); }
|
|
5308
|
+
res.writeHead(200, { 'Content-Type': at.kind === 'markdown' ? 'text/markdown; charset=utf-8' : 'text/plain; charset=utf-8', 'X-Doc-Kind': at.kind });
|
|
5309
|
+
return res.end(at.text);
|
|
5310
|
+
}
|
|
5057
5311
|
const doc = w ? readTextDoc(w.root, url.searchParams.get('path') || '') : null;
|
|
5058
5312
|
if (doc == null) { res.writeHead(404); return res.end('not a readable text document in this workspace'); }
|
|
5059
5313
|
// HTML is served as text/plain — the client renders it sandboxed on demand, never as live markup.
|
|
@@ -5189,7 +5443,35 @@ async function handleRequest(req, res) {
|
|
|
5189
5443
|
// Discriminated READ results stay 200 — the body carries the reason, the transport code cannot.
|
|
5190
5444
|
let lanes = null;
|
|
5191
5445
|
try { if (typeof engine.engine.lanesFor === 'function') lanes = engine.engine.lanesFor(root); } catch { lanes = null; }
|
|
5192
|
-
|
|
5446
|
+
// R4/R5/R6 — what remains to reach acceptance, the conversations that belong to this initiative, and the
|
|
5447
|
+
// files git says were changed for it. Each is read, never guessed; a failed read says so.
|
|
5448
|
+
const sessRows = sessionList();
|
|
5449
|
+
const liveConv = sessRows.filter((x) => initiativeOfSession(sessions.get(x.id)) === id)
|
|
5450
|
+
.map((x) => ({ id: x.id, name: x.name, state: x.state === 'exited' ? 'exited' : 'live', live: x.state !== 'exited', ws: x.ws }));
|
|
5451
|
+
const savedConv = transcriptsLib.listFor(STATE, id).filter((c) => !liveConv.some((l) => l.id === c.id))
|
|
5452
|
+
.map((c) => ({ id: c.id, name: c.name, state: 'ended', live: false, ws: c.ws, saved: c.saved, chars: c.chars, cut: c.cut }));
|
|
5453
|
+
const conversations = [...liveConv, ...savedConv];
|
|
5454
|
+
const listed = new Set(((out && out.artifacts) || []).map((a) => a && a.path).filter(Boolean));
|
|
5455
|
+
let files;
|
|
5456
|
+
try { files = initiativeFiles.filesFor(root, id, { exclude: listed }); }
|
|
5457
|
+
catch (e) { files = { ok: false, reason: 'the files could not be read — ' + String((e && e.message) || 'error').slice(0, 120) }; }
|
|
5458
|
+
let nextSteps = null;
|
|
5459
|
+
try {
|
|
5460
|
+
const acc = readAcceptances(w).get(id) || {};
|
|
5461
|
+
const lc = readLifecycle(w).get(id) || null;
|
|
5462
|
+
nextSteps = deriveNextSteps({
|
|
5463
|
+
id, accepted: acc.action === 'accept', returned: acc.action === 'return',
|
|
5464
|
+
lifecycle: lc && lc.state !== 'active' ? lc : null, ladder,
|
|
5465
|
+
needs: (readNeeds(w) || []).filter((n) => n && n.initiative_id === id),
|
|
5466
|
+
sessions: sessRows.filter((x) => (x.initiative === id) || conversations.some((c) => c.id === x.id && c.live)),
|
|
5467
|
+
});
|
|
5468
|
+
} catch (e) { nextSteps = { state: 'unreadable', summary: 'The next steps could not be derived — ' + String((e && e.message) || 'error').slice(0, 120), items: [] }; }
|
|
5469
|
+
// next-step-actions: each item says what it can do (`act`), names the repository file it points at when there is one, and
|
|
5470
|
+
// carries the run started from it and the PM's verdict on that run.
|
|
5471
|
+
try { nextStepActions.annotate(nextSteps, (it) => nextStepPathOf(root, it)); attachRuns(nextSteps, id); } catch (e) { void e; }
|
|
5472
|
+
let lcHistory = [];
|
|
5473
|
+
try { lcHistory = lifecycleHistory(w, id); } catch { lcHistory = []; }
|
|
5474
|
+
return json(res, { ws: w ? w.id : null, ...out, lanes, conversations, files, next_steps: nextSteps, lifecycle_history: lcHistory });
|
|
5193
5475
|
}
|
|
5194
5476
|
if (p === '/api/audit' && req.method === 'GET') {
|
|
5195
5477
|
/* The workflow audit (D77): the sweep run on one repository, read-only. It reports state that contradicts
|
|
@@ -5568,6 +5850,150 @@ async function handleRequest(req, res) {
|
|
|
5568
5850
|
return json(res, { ws: w.id, name: w.name, ...out });
|
|
5569
5851
|
} finally { syncing.delete(w.root); }
|
|
5570
5852
|
}
|
|
5853
|
+
/* ── next-step actions: prepare is read-only; go starts exactly what was shown; review records the PM's word ── */
|
|
5854
|
+
if (p === '/api/next-step/prepare' && req.method === 'POST') {
|
|
5855
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5856
|
+
const r = prepareNextStep(body);
|
|
5857
|
+
return json(res, r, r.ok ? 200 : (r.status || 400));
|
|
5858
|
+
}
|
|
5859
|
+
if (p === '/api/next-step/go' && req.method === 'POST') {
|
|
5860
|
+
// Only reached by the PM's press. It re-prepares, and starts a session only when the PM confirmed AND the prompt and plan
|
|
5861
|
+
// are the ones they were shown (the hash). Anything else starts nothing.
|
|
5862
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5863
|
+
if (body.confirm !== true) return json(res, { ok: false, error: 'confirm is required: this starts a session' }, 400);
|
|
5864
|
+
const r = prepareNextStep(body);
|
|
5865
|
+
if (!r.ok) return json(res, r, r.status || 400);
|
|
5866
|
+
if (r.refused) return json(res, { ok: false, refused: r.refused, error: r.refused.why }, 409);
|
|
5867
|
+
if (!body.prompt_sha || body.prompt_sha !== r.prompt_sha) return json(res, { ok: false, error: 'the plan changed since you reviewed it — prepare it again', prompt_sha: r.prompt_sha }, 409);
|
|
5868
|
+
if (!r.budget.allowed) return json(res, { ok: false, error: r.budget.reason, budget: r.budget }, 409);
|
|
5869
|
+
const started = startWork({ initiative: r.initiative_id, ws: r.ws, agent: r.agent, name: `${r.item.key} · ${r.initiative_id}`, profile: r.profile || undefined,
|
|
5870
|
+
origin: 'next-step', initialInput: r.prompt + '\r' });
|
|
5871
|
+
if (!started.ok) return json(res, { ok: false, error: started.error }, 409);
|
|
5872
|
+
const rec = { id: 'nsr-' + Date.now().toString(36), at: nowISO(), ws: r.ws, initiative_id: r.initiative_id, item_id: r.item.id, key: r.item.key, session_id: started.session.id,
|
|
5873
|
+
branch: r.plan.branch || null, plan: r.plan.plan, prompt_sha: r.prompt_sha, judgment: r.judgment.on, profile: r.profile || null };
|
|
5874
|
+
const wrote = nsAppend(NS_RUNS(), rec, 'next-step run');
|
|
5875
|
+
return json(res, { ok: true, session: { id: started.session.id, name: started.session.name }, notice: started.notice || null, plan: r.plan, run: wrote.ok ? rec.id : null, recorded: wrote.ok });
|
|
5876
|
+
}
|
|
5877
|
+
if (p === '/api/next-step/summary' && req.method === 'GET') {
|
|
5878
|
+
// What an ended (or running) next-step session did: the files git shows on its branch against trunk, the end of the
|
|
5879
|
+
// conversation, and every JUDGMENT line checked against its shape. Read-only.
|
|
5880
|
+
const sid = String(url.searchParams.get('session') || '');
|
|
5881
|
+
const run = nsRead(NS_RUNS()).filter((x) => x && x.session_id === sid).pop();
|
|
5882
|
+
if (!run) return json(res, { ok: false, error: 'that session was not started from a next step' }, 404);
|
|
5883
|
+
const live = sessions.get(sid);
|
|
5884
|
+
let text = '';
|
|
5885
|
+
if (live) text = transcriptsLib.stripAnsi(live.buf);
|
|
5886
|
+
else { const rec = transcriptsLib.read(STATE, sid); text = rec && rec.text ? String(rec.text) : ''; }
|
|
5887
|
+
const w = getWs(run.ws);
|
|
5888
|
+
let files = []; let filesError = null;
|
|
5889
|
+
if (w && gitInfo.isRepo(w.root)) {
|
|
5890
|
+
const trunk = ['origin/main', 'main', 'origin/master', 'master'].find((b) => spawnSync('git', ['-C', w.root, 'rev-parse', '--verify', '--quiet', b], { encoding: 'utf8' }).status === 0);
|
|
5891
|
+
const tree = live && live.root ? live.root : w.root;
|
|
5892
|
+
const branch = run.branch && spawnSync('git', ['-C', tree, 'rev-parse', '--verify', '--quiet', run.branch], { encoding: 'utf8' }).status === 0 ? run.branch : 'HEAD';
|
|
5893
|
+
if (trunk) {
|
|
5894
|
+
const d = spawnSync('git', ['-C', tree, 'diff', '--name-status', `${trunk}...${branch}`], { encoding: 'utf8', timeout: 20000 });
|
|
5895
|
+
if (d.status === 0) files = d.stdout.split('\n').filter(Boolean).slice(0, 100).map((l) => { const [st, ...rest] = l.split('\t'); return { status: st, path: rest.join('\t') }; });
|
|
5896
|
+
else filesError = 'git could not compare the branch with trunk';
|
|
5897
|
+
} else filesError = 'no trunk branch to compare against';
|
|
5898
|
+
} else filesError = 'the repository could not be read';
|
|
5899
|
+
const j = judgmentLib.extract(text);
|
|
5900
|
+
const reviews = nsRead(NS_REVIEWS()).filter((x) => x && x.session_id === sid);
|
|
5901
|
+
return json(res, { ok: true, session_id: sid, state: live ? (live.state === 'exited' ? 'ended' : 'live') : 'ended', branch: run.branch || null,
|
|
5902
|
+
files, files_error: filesError, tail: text.slice(-3000), judgments: j, review: reviews.pop() || null });
|
|
5903
|
+
}
|
|
5904
|
+
if (p === '/api/next-step/review' && req.method === 'POST') {
|
|
5905
|
+
// The PM's verdict on one step's run. It accepts or sends back THE STEP. It never records an acceptance of the
|
|
5906
|
+
// initiative, never changes a status and never touches a lifecycle line.
|
|
5907
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5908
|
+
const verdict = body.verdict === 'accept' ? 'accept' : body.verdict === 'send-back' ? 'send-back' : null;
|
|
5909
|
+
if (!verdict) return json(res, { ok: false, error: 'verdict must be accept or send-back' }, 400);
|
|
5910
|
+
const note = String(body.note || '').trim().slice(0, 500);
|
|
5911
|
+
if (verdict === 'send-back' && !note) return json(res, { ok: false, error: 'say what to change: a send-back needs a note' }, 400);
|
|
5912
|
+
const sid = String(body.session_id || '');
|
|
5913
|
+
const run = nsRead(NS_RUNS()).filter((x) => x && x.session_id === sid).pop();
|
|
5914
|
+
if (!run) return json(res, { ok: false, error: 'that session was not started from a next step' }, 404);
|
|
5915
|
+
const live = sessions.get(sid);
|
|
5916
|
+
if (live && live.state !== 'exited') return json(res, { ok: false, error: 'the session is still running — review it when it has ended' }, 409);
|
|
5917
|
+
const rec = { at: nowISO(), initiative_id: run.initiative_id, item_id: run.item_id, key: run.key, session_id: sid, verdict, note, pm: 'wawan' };
|
|
5918
|
+
const wrote = nsAppend(NS_REVIEWS(), rec, 'step review');
|
|
5919
|
+
if (!wrote.ok) return json(res, { ok: false, error: wrote.error }, 500);
|
|
5920
|
+
return json(res, { ok: true, recorded: rec });
|
|
5921
|
+
}
|
|
5922
|
+
if (p === '/api/next-step/settings') {
|
|
5923
|
+
if (req.method === 'GET') return json(res, { ok: true, budget: nsBudget() });
|
|
5924
|
+
if (req.method === 'POST') {
|
|
5925
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5926
|
+
const n = sessionBudget.normalizeLimits({ ...nsLimits(), ...(body.budget || {}) });
|
|
5927
|
+
if (!n.ok) return json(res, { ok: false, error: n.error }, 400);
|
|
5928
|
+
try { fs.mkdirSync(STATE, { recursive: true }); fs.writeFileSync(NS_SETTINGS(), JSON.stringify({ budget: n.limits }) + '\n'); }
|
|
5929
|
+
catch (e) { warnWriteFail('next-step-settings.json', e); return json(res, { ok: false, error: 'could not save — nothing changed' }, 500); }
|
|
5930
|
+
return json(res, { ok: true, budget: nsBudget() });
|
|
5931
|
+
}
|
|
5932
|
+
}
|
|
5933
|
+
// ── D79 — repository validation through TypeSafe ──────────────────────────────────────────────────
|
|
5934
|
+
// The ONLY routes that lead to an outbound request, and only through lib/typesafe.js. The key is never
|
|
5935
|
+
// in a response: `status` says present or absent. Nothing here edits a repository file.
|
|
5936
|
+
if (p === '/api/typesafe' && req.method === 'GET') {
|
|
5937
|
+
return json(res, { ok: true, key: tsSettings.status(STATE), host: tsClient.HOST });
|
|
5938
|
+
}
|
|
5939
|
+
if (p === '/api/typesafe/key' && (req.method === 'PUT' || req.method === 'DELETE')) {
|
|
5940
|
+
let body = {};
|
|
5941
|
+
if (req.method === 'PUT') {
|
|
5942
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
5943
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5944
|
+
}
|
|
5945
|
+
const out = req.method === 'PUT' ? tsSettings.setKey(STATE, body.key) : tsSettings.clearKey(STATE);
|
|
5946
|
+
if (!out.ok) return json(res, { ok: false, error: out.error }, req.method === 'PUT' && /look like a key/.test(out.error) ? 400 : 500);
|
|
5947
|
+
return json(res, { ok: true, key: tsSettings.status(STATE) });
|
|
5948
|
+
}
|
|
5949
|
+
if (p === '/api/validate/repo' && (req.method === 'GET' || req.method === 'POST')) {
|
|
5950
|
+
let body = {};
|
|
5951
|
+
if (req.method === 'POST') {
|
|
5952
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
5953
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5954
|
+
}
|
|
5955
|
+
const w = getWs(req.method === 'POST' ? body.ws : url.searchParams.get('ws'));
|
|
5956
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
5957
|
+
if (req.method === 'POST') {
|
|
5958
|
+
const out = tsSettings.setRepo(STATE, w.root, { consent: body.consent, exclude: body.exclude });
|
|
5959
|
+
if (!out.ok) return json(res, { ok: false, error: out.error }, 500);
|
|
5960
|
+
}
|
|
5961
|
+
return json(res, { ok: true, ws: w.id, name: w.name, ...tsSettings.getRepo(STATE, w.root), key: tsSettings.status(STATE) });
|
|
5962
|
+
}
|
|
5963
|
+
if (p === '/api/validate/plan' && req.method === 'POST') {
|
|
5964
|
+
let body = {};
|
|
5965
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
5966
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5967
|
+
const w = getWs(body.ws);
|
|
5968
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
5969
|
+
const refused = validationRefusal(w);
|
|
5970
|
+
if (refused) return json(res, { ok: false, error: refused }, 409);
|
|
5971
|
+
return json(res, { ok: true, ws: w.id, name: w.name, plan: validateLib.plan({ root: w.root, exclude: tsSettings.getRepo(STATE, w.root).exclude }) });
|
|
5972
|
+
}
|
|
5973
|
+
if (p === '/api/validate/run' && req.method === 'POST') {
|
|
5974
|
+
let body = {};
|
|
5975
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
5976
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5977
|
+
const w = getWs(body.ws);
|
|
5978
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
5979
|
+
const refused = validationRefusal(w);
|
|
5980
|
+
if (refused) return json(res, { ok: false, error: refused }, 409);
|
|
5981
|
+
if (validating.has(w.root)) return json(res, { ok: false, error: 'a validation is already running for this repository' }, 409);
|
|
5982
|
+
const file = validationFindingsFile(w);
|
|
5983
|
+
validating.add(w.root);
|
|
5984
|
+
try {
|
|
5985
|
+
const out = await validateLib.run({
|
|
5986
|
+
root: w.root, exclude: tsSettings.getRepo(STATE, w.root).exclude,
|
|
5987
|
+
key: tsSettings.getKey(STATE), findingsFile: file, expectRequests: body.expect_requests,
|
|
5988
|
+
});
|
|
5989
|
+
return json(res, { ws: w.id, name: w.name, ...out }, out.ok ? 200 : (out.conflict ? 409 : 500));
|
|
5990
|
+
} finally { validating.delete(w.root); }
|
|
5991
|
+
}
|
|
5992
|
+
if (p === '/api/validate/findings' && req.method === 'GET') {
|
|
5993
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
5994
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
5995
|
+
return json(res, { ok: true, ws: w.id, name: w.name, ...validateLib.readFindings(validationFindingsFile(w)) });
|
|
5996
|
+
}
|
|
5571
5997
|
if (p === '/api/lifecycle' && req.method === 'POST') {
|
|
5572
5998
|
// D70 — close, hold, cancel or reopen an initiative. Only reached by an explicit PM press. It
|
|
5573
5999
|
// appends ONE line to lifecycle.jsonl and never writes an acceptance.
|