create-pmos 0.34.0 → 0.37.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/bin/create-pmos.js +15 -3
- package/package.json +1 -1
- package/payload/AGENTS.md +2 -0
- package/payload/KIT-VERSION +2 -2
- package/payload/okf/core/concepts/agent-harness.md +2 -2
- package/payload/okf/core/concepts/design-loop.md +5 -5
- package/payload/okf/core/concepts/eval-driven-pm.md +1 -1
- package/payload/okf/core/concepts/okr-anti-optimism.md +1 -1
- package/payload/okf/core/concepts/okr-tree.md +2 -2
- package/payload/okf/core/concepts/output-eval.md +2 -2
- package/payload/okf/core/concepts/sdlc-loop.md +1 -1
- package/payload/okf/core/concepts/self-refining-harness.md +3 -3
- package/payload/okf/core/templates/content-brief-template.md +1 -1
- package/payload/okf/core/templates/design-brief-template.md +1 -1
- package/payload/okf/core/templates/eval-rubric-template.md +1 -1
- package/payload/okf/core/templates/log.md +1 -1
- package/payload/okf/core/templates/prd-template.md +1 -1
- package/payload/okf/core/templates/spike-brief-template.md +1 -1
- package/payload/scripts/frontier.sh +6 -2
- package/payload/scripts/gate-initiative.sh +24 -3
- package/payload/scripts/metrics.py +20 -4
- package/payload/scripts/read-jsonl.js +53 -0
- package/payload/scripts/workflow-state.js +144 -35
- package/payload/scripts/workflow-sweep.js +39 -22
- package/payload/skills/SKILL-FORMAT.md +1 -1
- package/payload/skills/copywriting.skill +1 -1
- package/payload/skills/correction-signal-derive.skill +1 -1
- package/payload/skills/design-brief.skill +4 -4
- package/payload/skills/design-reconcile.skill +1 -1
- package/payload/skills/discovery.skill +3 -3
- package/payload/skills/eval-rubric.skill +4 -4
- package/payload/skills/evaluator.skill +1 -1
- package/payload/skills/explain-run.skill +1 -1
- package/payload/skills/explain-surface.skill +6 -6
- package/payload/skills/harness-refine.skill +1 -1
- package/payload/skills/ingest-repo.skill +6 -6
- package/payload/skills/okr.skill +2 -2
- package/payload/skills/pmos-router.skill +8 -8
- package/payload/skills/prd.skill +5 -5
- package/payload/skills/prototype-grill.skill +8 -8
- package/payload/skills/prototype.skill +7 -7
- package/payload/skills/skill-creator.skill +1 -1
- package/payload/skills/source-to-concept.skill +1 -1
- package/payload/skills/ste-term-map.md +69 -0
- package/payload/skills/strategic-brief.skill +4 -4
- package/payload/skills/technical-writing-ste.skill +176 -0
- package/payload/skills-native/copywriting/SKILL.md +1 -1
- package/payload/skills-native/correction-signal-derive/SKILL.md +1 -1
- package/payload/skills-native/design-brief/SKILL.md +4 -4
- package/payload/skills-native/design-reconcile/SKILL.md +1 -1
- package/payload/skills-native/discovery/SKILL.md +3 -3
- package/payload/skills-native/eval-rubric/SKILL.md +4 -4
- package/payload/skills-native/evaluator/SKILL.md +1 -1
- package/payload/skills-native/explain-run/SKILL.md +1 -1
- package/payload/skills-native/explain-surface/SKILL.md +6 -6
- package/payload/skills-native/harness-refine/SKILL.md +1 -1
- package/payload/skills-native/ingest-repo/SKILL.md +6 -6
- package/payload/skills-native/okr/SKILL.md +2 -2
- package/payload/skills-native/pmos-router/SKILL.md +8 -8
- package/payload/skills-native/prd/SKILL.md +5 -5
- package/payload/skills-native/prototype/SKILL.md +7 -7
- package/payload/skills-native/prototype-grill/SKILL.md +8 -8
- package/payload/skills-native/skill-creator/SKILL.md +1 -1
- package/payload/skills-native/source-to-concept/SKILL.md +1 -1
- package/payload/skills-native/strategic-brief/SKILL.md +4 -4
- package/payload/skills-native/technical-writing-ste/SKILL.md +178 -0
- package/workspace/bin/pmos.js +9 -3
- package/workspace/lib/engine/read-jsonl.js +53 -0
- package/workspace/lib/engine/workflow-state.js +144 -35
- package/workspace/lib/file-mode-parse.js +19 -4
- package/workspace/lib/tree-share.js +69 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/board-read.js +38 -0
- package/workspace/public/pm/data.js +102 -1
- package/workspace/public/pm/modes.js +50 -28
- package/workspace/public/pm/panel-detail.js +2 -17
- package/workspace/public/pm/panel-initiatives.js +3 -20
- package/workspace/public/pm/panel-newsess.js +14 -1
- package/workspace/public/pm/panel-tabs.js +53 -14
- package/workspace/public/pm/tab-term.js +30 -2
- package/workspace/server.js +95 -70
- package/workspace/test-run.js +51 -9
package/workspace/server.js
CHANGED
|
@@ -687,9 +687,19 @@ function budgetsFor(A, root) {
|
|
|
687
687
|
// or a DECISIONS.md gets the initiative lens + curated knowledge; anything else is a plain document
|
|
688
688
|
// tree. Detection is nested-aware so a repo that vendors a PMOS bundle under a sub-folder still
|
|
689
689
|
// shows its initiatives (the reported defect) — including a kit whose records are its whole bundle.
|
|
690
|
+
// Memoised for the same reason repoIdentity is: wsList() calls this for every workspace on every
|
|
691
|
+
// push, and a cold answer for a plain repo costs up to four depth-2 walks. A repo that gains its
|
|
692
|
+
// first planning/ dir reads as 'plain' for at most one TTL — the staleness class the branch chip
|
|
693
|
+
// already accepts.
|
|
694
|
+
const wsShapeCache = new Map(); // root -> { at, shape }
|
|
690
695
|
function wsShape(root) {
|
|
691
|
-
|
|
696
|
+
const hit = wsShapeCache.get(root);
|
|
697
|
+
const now = Date.now();
|
|
698
|
+
if (hit && now - hit.at < REPO_KEY_TTL_MS) return hit.shape;
|
|
699
|
+
const shape = planningRoots(root).length || initiativeDirs(root).length
|
|
692
700
|
|| fs.existsSync(path.join(root, 'DECISIONS.md')) ? 'pmos' : 'plain';
|
|
701
|
+
wsShapeCache.set(root, { at: now, shape });
|
|
702
|
+
return shape;
|
|
693
703
|
}
|
|
694
704
|
|
|
695
705
|
// ── Library: the file tree the sidebar browses. ───────────────────────────────────────
|
|
@@ -1400,18 +1410,14 @@ function deriveState(ev) {
|
|
|
1400
1410
|
if (ev.runs) return { state: 'building', why: `${ev.runs} run${ev.runs > 1 ? 's' : ''} logged, nothing merged yet` };
|
|
1401
1411
|
return { state: 'planning', why: 'no run, no merge, no acceptance — nothing has happened yet' };
|
|
1402
1412
|
}
|
|
1403
|
-
// BOTH
|
|
1404
|
-
//
|
|
1405
|
-
//
|
|
1406
|
-
//
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
//
|
|
1411
|
-
// acceptances. `deriveNeeds` read evals.jsonl, acceptances.jsonl AND outcome_checks.jsonl from the
|
|
1412
|
-
// state dir alone, so the supervision surface under-reported everything pmos-self records in
|
|
1413
|
-
// planning/runs/ — the same "one field, several readers, none agreeing" shape, three ledgers wide.
|
|
1414
|
-
// One helper with three call sites, so the fourth ledger added later cannot reintroduce it.
|
|
1413
|
+
// BOTH ledger locations, always: the workspace writes to its state dir, while the ladder and every
|
|
1414
|
+
// hand-recorded acceptance in pmos-self live in planning/runs/. Reading only the state dir is why
|
|
1415
|
+
// the initiative surface reported 0 accepted against 23 real acceptance rows, and the defect was
|
|
1416
|
+
// never specific to acceptances: `deriveNeeds` read evals.jsonl, acceptances.jsonl AND
|
|
1417
|
+
// outcome_checks.jsonl from the state dir alone, so the supervision surface under-reported
|
|
1418
|
+
// everything pmos-self records in planning/runs/ — the same "one field, several readers, none
|
|
1419
|
+
// agreeing" shape, three ledgers wide. One helper on every ledger read, so the fourth ledger
|
|
1420
|
+
// added later cannot reintroduce it.
|
|
1415
1421
|
function ledgerFilesFor(w, basename) {
|
|
1416
1422
|
const out = [];
|
|
1417
1423
|
const d = stateDirFor(w);
|
|
@@ -1435,33 +1441,28 @@ function readLedger(w, basename) {
|
|
|
1435
1441
|
}
|
|
1436
1442
|
function readAcceptances(w) {
|
|
1437
1443
|
const acc = new Map(); // initiative_id -> { action, at }
|
|
1438
|
-
for (const
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
const prev = acc.get(r.initiative_id);
|
|
1461
|
-
if (prev && prev.at && !at) continue;
|
|
1462
|
-
acc.set(r.initiative_id, { action, at });
|
|
1463
|
-
} catch {}
|
|
1464
|
-
}
|
|
1444
|
+
for (const r of readLedger(w, 'acceptances.jsonl')) {
|
|
1445
|
+
// Tolerate both shapes: this server's {action} and the kit's {accepted} (D54 adapter).
|
|
1446
|
+
// THE BOOLEAN OUTRANKS THE LABEL. `action` is an open vocabulary describing what the PM did
|
|
1447
|
+
// — this corpus holds accept, merge, publish, return — while `accepted` is the D12 verdict
|
|
1448
|
+
// itself. Reading `action` first (the previous behaviour) meant every row labelled `merge` or
|
|
1449
|
+
// `publish` carried `accepted: true` and was still not counted: 7 of 16 real acceptances went
|
|
1450
|
+
// missing, and the surface reported those initiatives as merely `building`.
|
|
1451
|
+
if (!r.initiative_id) continue;
|
|
1452
|
+
const label = String(r.action || '').toLowerCase();
|
|
1453
|
+
const action = r.accepted === true ? 'accept'
|
|
1454
|
+
: r.accepted === false ? 'return'
|
|
1455
|
+
: label === 'return' ? 'return'
|
|
1456
|
+
: ['accept', 'approve', 'merge', 'publish'].includes(label) ? 'accept'
|
|
1457
|
+
: null;
|
|
1458
|
+
const at = r.at || r.date || null;
|
|
1459
|
+
// Last write wins, judged by timestamp — the two ledgers arrive in FILE order, not time order,
|
|
1460
|
+
// so a dated earlier row must yield only to a LATER date (ISO strings compare lexicographically;
|
|
1461
|
+
// a bare YYYY-MM-DD loses same-day ties to a full stamp). An undated row never displaces a dated
|
|
1462
|
+
// one; between undated rows, last read wins.
|
|
1463
|
+
const prev = acc.get(r.initiative_id);
|
|
1464
|
+
if (prev && prev.at && (!at || at < prev.at)) continue;
|
|
1465
|
+
acc.set(r.initiative_id, { action, at });
|
|
1465
1466
|
}
|
|
1466
1467
|
return acc;
|
|
1467
1468
|
}
|
|
@@ -1499,7 +1500,8 @@ function surfaceInitiatives(root, w) {
|
|
|
1499
1500
|
const a = acc.get(g.id) || {};
|
|
1500
1501
|
const deliveredBy = !del.ok ? null : del.byBranch.has(g.id) ? 'branch' : del.byMention.has(g.id) ? 'mention' : null;
|
|
1501
1502
|
const { state, why } = deriveState({
|
|
1502
|
-
|
|
1503
|
+
// readAcceptances already folds every accepting label (approve/merge/publish) into 'accept'.
|
|
1504
|
+
accepted: a.action === 'accept', acceptedAt: a.at,
|
|
1503
1505
|
returned: a.action === 'return',
|
|
1504
1506
|
needs: needsBy.get(g.id) || 0,
|
|
1505
1507
|
delivery: del.ok ? 'known' : 'unknown',
|
|
@@ -1606,15 +1608,16 @@ function surfaceOkr(root) {
|
|
|
1606
1608
|
let files = [];
|
|
1607
1609
|
try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')); } catch { continue; }
|
|
1608
1610
|
for (const f of files) {
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1611
|
+
// The LEADING frontmatter block only — body text must never be able to fake a declaration —
|
|
1612
|
+
// and the same reader surfaceSpine uses, so the two panels cannot disagree on one record.
|
|
1613
|
+
// A present-but-empty `parent_kr:` still reaches resolveAnchor and counts as `empty`.
|
|
1614
|
+
const fm = leadingFrontmatter(path.join(dir, f));
|
|
1615
|
+
if (fm.parent_kr === undefined) continue;
|
|
1616
|
+
const r = A.resolveAnchor(fm.parent_kr, index, budgets);
|
|
1614
1617
|
if (r.kind === 'empty') { empty++; continue; }
|
|
1615
1618
|
checked++;
|
|
1616
1619
|
if (r.status === 'unresolvable' || r.status === 'unknown') {
|
|
1617
|
-
unresolvable.push({ initiative: f.replace(/\.md$/, ''), raw: String(
|
|
1620
|
+
unresolvable.push({ initiative: f.replace(/\.md$/, ''), raw: String(fm.parent_kr).trim().slice(0, 80), reason: r.reason, status: r.status });
|
|
1618
1621
|
}
|
|
1619
1622
|
}
|
|
1620
1623
|
}
|
|
@@ -1818,7 +1821,10 @@ function reportToMarkdown(surface) {
|
|
|
1818
1821
|
function fireSchedule(sched) {
|
|
1819
1822
|
const w = workspaces.get(sched.ws) || homeWorkspace();
|
|
1820
1823
|
const root = w ? w.root : SELF_ROOT;
|
|
1821
|
-
|
|
1824
|
+
// One corpus load probing both keys — taskById would re-read every .skill file per probe.
|
|
1825
|
+
const tasks = loadTasks();
|
|
1826
|
+
const byId = (id) => tasks.find((t) => t.id === id || t.skill === id) || null;
|
|
1827
|
+
const task = byId(sched.task) || byId(sched.skill);
|
|
1822
1828
|
// AGENT task — fire as a real PTY session running its skill (async; the skill files its own
|
|
1823
1829
|
// deliverable). The workspace starts it + stamps 'started'; run-now and the tick share this path.
|
|
1824
1830
|
if (task && task.kind === 'agent') {
|
|
@@ -2049,7 +2055,9 @@ function broadcast(obj) {
|
|
|
2049
2055
|
}
|
|
2050
2056
|
// The agent process behind a session. tmux spawns the command directly (see ensureTmuxSession's
|
|
2051
2057
|
// `-- cmd`, no shell), so pane_pid IS the agent — one hop, no guessing. Cached because a pid
|
|
2052
|
-
// cannot change for the life of a session
|
|
2058
|
+
// cannot change for the life of a session — but a FAILED lookup is not a pid: tmux can be
|
|
2059
|
+
// transiently busy, and caching its null would disable the harness drill-in until restart. The
|
|
2060
|
+
// retry costs one shell-out per transcript re-resolution, already throttled to ~2s by txFoundAt.
|
|
2053
2061
|
function agentPid(s) {
|
|
2054
2062
|
if (s.agentPid !== undefined) return s.agentPid;
|
|
2055
2063
|
let pid = null;
|
|
@@ -2060,7 +2068,7 @@ function agentPid(s) {
|
|
|
2060
2068
|
} else if (s.pty && s.pty.pid) {
|
|
2061
2069
|
pid = s.pty.pid;
|
|
2062
2070
|
}
|
|
2063
|
-
s.agentPid = pid;
|
|
2071
|
+
if (pid != null) s.agentPid = pid;
|
|
2064
2072
|
return pid;
|
|
2065
2073
|
}
|
|
2066
2074
|
|
|
@@ -2557,14 +2565,6 @@ function exitedSession(base, workdir, why) {
|
|
|
2557
2565
|
sessions.set(s.id, s); persist(s); pushSessions(); pushWorkspaces(); return s;
|
|
2558
2566
|
}
|
|
2559
2567
|
|
|
2560
|
-
// Live (non-exited) sessions belonging to a workspace. Used to decide whether isolating a branch
|
|
2561
|
-
// would actually protect anyone, and to refuse removing a worktree somebody is working in.
|
|
2562
|
-
function liveSessionCount(wsIdent) {
|
|
2563
|
-
let n = 0;
|
|
2564
|
-
for (const s of sessions.values()) if (s.ws === wsIdent && s.state !== 'exited') n++;
|
|
2565
|
-
return n;
|
|
2566
|
-
}
|
|
2567
|
-
|
|
2568
2568
|
// The next unused session id. `seq` is a per-process counter, so it can only be trusted once it
|
|
2569
2569
|
// has been floored by what is already on disk and in memory (see restorePersisted) — this loop is
|
|
2570
2570
|
// the last guard: a fresh session must never take an id another session's record already owns,
|
|
@@ -2737,12 +2737,14 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
|
|
|
2737
2737
|
if (!pres.ok) return { ok: false, error: pres.error };
|
|
2738
2738
|
let targetWs = w.id;
|
|
2739
2739
|
let notice = null;
|
|
2740
|
-
// Isolation exists to protect OTHER sessions. Alone in
|
|
2741
|
-
// a plain checkout costs nothing and leaves no folder behind — which is the
|
|
2742
|
-
// "a worktree when it earns its keep" and a directory per branch forever.
|
|
2743
|
-
//
|
|
2744
|
-
//
|
|
2745
|
-
|
|
2740
|
+
// Isolation exists to protect OTHER sessions. Alone in the working TREE there are none to
|
|
2741
|
+
// protect, so a plain checkout costs nothing and leaves no folder behind — which is the
|
|
2742
|
+
// difference between "a worktree when it earns its keep" and a directory per branch forever.
|
|
2743
|
+
// Counted by tree, not workspace id, for the reason worktreeReport states: a session in a
|
|
2744
|
+
// workspace registered at a SUBDIRECTORY of this tree is one this checkout would switch branches
|
|
2745
|
+
// under — two ids, one checkout. The client sends what the PM actually saw; this default only
|
|
2746
|
+
// decides for a caller that expressed no preference (an older client, a scheduled run).
|
|
2747
|
+
if (isolate === undefined || isolate === null) isolate = treeTenants(treeForWs(w.id), null).length > 0;
|
|
2746
2748
|
|
|
2747
2749
|
if (branch) {
|
|
2748
2750
|
if (!gitInfo.isRepo(w.root)) {
|
|
@@ -3165,8 +3167,10 @@ function needsResult(w) {
|
|
|
3165
3167
|
question: n.question || n.t || '(unstated)', derived: !!n.derived, doc: n.doc || null,
|
|
3166
3168
|
deliverable: n.deliverable || null,
|
|
3167
3169
|
}));
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
+
// `all` is the RAW combined queue — what readNeeds(w) computes — carried so /api/needs can serve
|
|
3171
|
+
// both the strict state and the queue from one derivation instead of deriving twice per request.
|
|
3172
|
+
if (!items.length) return { ok: true, empty: true, all };
|
|
3173
|
+
return { ok: true, empty: false, data: { items }, all };
|
|
3170
3174
|
}
|
|
3171
3175
|
// R12 — the per-tree aggregate the collapsed sidebar rows need, and R10's honesty rule applied to
|
|
3172
3176
|
// the count itself. It deliberately routes through needsResult(), the STRICT reader: readNeeds()
|
|
@@ -4223,11 +4227,15 @@ async function handleRequest(req, res) {
|
|
|
4223
4227
|
if (p === '/api/schedules' && req.method === 'GET') {
|
|
4224
4228
|
const wsFilter = getWs(url.searchParams.get('ws'));
|
|
4225
4229
|
const id = wsFilter ? wsFilter.id : null;
|
|
4230
|
+
// One skills-corpus read per request, not one per schedule row — taskById re-reads every
|
|
4231
|
+
// .skill file, and the PM's forty-five schedules made that forty-five reads per GET.
|
|
4232
|
+
const tasks = loadTasks();
|
|
4233
|
+
const taskOf = (tid) => tasks.find((t) => t.id === tid || t.skill === tid) || null;
|
|
4226
4234
|
const list = readSchedules().filter((s) => !id || s.ws === id).map((s) => {
|
|
4227
4235
|
// `profile_applies` is the difference between "no profile was chosen" and "a profile could
|
|
4228
4236
|
// not apply here at all": a skill-kind task runs IN THIS PROCESS and never creates a PTY, so
|
|
4229
4237
|
// the detail row states that instead of offering a control that would do nothing (R8.3).
|
|
4230
|
-
const t =
|
|
4238
|
+
const t = taskOf(s.task || s.skill);
|
|
4231
4239
|
return { ...s, next: scheduleLogic.nextRun(s),
|
|
4232
4240
|
profile: s.profile == null ? null : s.profile,
|
|
4233
4241
|
profile_applies: !!(t && t.kind === 'agent') };
|
|
@@ -4413,8 +4421,18 @@ async function handleRequest(req, res) {
|
|
|
4413
4421
|
if (p === '/api/needs') {
|
|
4414
4422
|
const w = getWs(url.searchParams.get('ws'));
|
|
4415
4423
|
const strict = needsResult(w);
|
|
4424
|
+
// One derivation per request, not three: the strict read already holds the combined queue
|
|
4425
|
+
// (`all` — identical to readNeeds when the read succeeds; on a corrupt file readNeeds keeps its
|
|
4426
|
+
// lenient derived-only fallback), and the by_ws cache is seeded from the same read so the
|
|
4427
|
+
// selected tree is not derived again — fresher than any cache hit, so R10 still holds.
|
|
4428
|
+
if (w) {
|
|
4429
|
+
let sig; try { sig = needsSig(w); } catch { sig = 'unreadable'; }
|
|
4430
|
+
needsAgg.set(w.id, { at: Date.now(), sig, value: strict.ok
|
|
4431
|
+
? { count: strict.empty ? 0 : strict.data.items.length, error: null }
|
|
4432
|
+
: { count: null, error: strict.error } });
|
|
4433
|
+
}
|
|
4416
4434
|
return json(res, {
|
|
4417
|
-
needs: readNeeds(w),
|
|
4435
|
+
needs: strict.ok ? strict.all : readNeeds(w),
|
|
4418
4436
|
state: strict.ok ? { ok: true } : { ok: false, error: strict.error },
|
|
4419
4437
|
by_ws: needsByWorkspace(),
|
|
4420
4438
|
});
|
|
@@ -4804,14 +4822,21 @@ async function handleRequest(req, res) {
|
|
|
4804
4822
|
let changedText = '';
|
|
4805
4823
|
try { changedText = fs.readFileSync(full, 'utf8'); } catch {}
|
|
4806
4824
|
const docs = [];
|
|
4825
|
+
let oversized = 0;
|
|
4807
4826
|
for (const f of markdownDocs(w.root)) {
|
|
4808
4827
|
if (f === rel) continue;
|
|
4809
4828
|
const fp = path.resolve(w.root, f);
|
|
4810
4829
|
if (!insideRoot(w.root, fp)) continue;
|
|
4811
|
-
|
|
4830
|
+
// Same per-file bound as the search corpus: a pathological multi-MB document is skipped and
|
|
4831
|
+
// COUNTED (the honesty contract — `considered` must not silently shrink), never read whole
|
|
4832
|
+
// onto the thread that carries every PTY byte.
|
|
4833
|
+
try {
|
|
4834
|
+
if (fs.statSync(fp).size > SEARCH_CONTENT_BYTES) { oversized++; continue; }
|
|
4835
|
+
docs.push({ path: f, text: fs.readFileSync(fp, 'utf8') });
|
|
4836
|
+
} catch {}
|
|
4812
4837
|
}
|
|
4813
4838
|
const cands = relatedDocs.candidates({ changedPath: rel, changedText, removed: last.before, docs });
|
|
4814
|
-
return json(res, { ok: true, change: last, candidates: cands.slice(0, 12), considered: docs.length, total: cands.length });
|
|
4839
|
+
return json(res, { ok: true, change: last, candidates: cands.slice(0, 12), considered: docs.length, skipped_oversized: oversized, total: cands.length });
|
|
4815
4840
|
}
|
|
4816
4841
|
if (p === '/api/align/start' && req.method === 'POST') {
|
|
4817
4842
|
// Hand the change and its candidates to an agent, which DRAFTS the realigning edits (R5). The
|
package/workspace/test-run.js
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
const fs = require('fs');
|
|
28
28
|
const os = require('os');
|
|
29
29
|
const path = require('path');
|
|
30
|
-
const { execFileSync } = require('child_process');
|
|
30
|
+
const { execFileSync, spawnSync } = require('child_process');
|
|
31
31
|
|
|
32
32
|
const HOST = '127.0.0.1';
|
|
33
33
|
|
|
@@ -42,6 +42,28 @@ function claimEphemeralPort() {
|
|
|
42
42
|
return n;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Poll a child server until it answers 200, instead of sleeping a fixed interval. A fixed sleep
|
|
47
|
+
* that expires early is a suite that aborts on its first request — observed as a lost race on a
|
|
48
|
+
* loaded machine (the first page.goto got ECONNREFUSED and the run stopped partway), and an
|
|
49
|
+
* aborted run's count is not a coverage claim. Booting is slower than a plain HTTP server:
|
|
50
|
+
* node-pty loads and sessions are restored before the port is bound. server.js does all of that
|
|
51
|
+
* (including the first-run shell spawn) BEFORE it listens, so a 200 means the server is fully up.
|
|
52
|
+
*
|
|
53
|
+
* `probe` must be a path every child serves regardless of workspace shape; `/pm/tokens.css` is
|
|
54
|
+
* routed unconditionally. The poll is read-only, so it changes no server state (the F-restart
|
|
55
|
+
* block in test.js relies on that: no tmux reset between its two servers).
|
|
56
|
+
*/
|
|
57
|
+
async function waitForServer(base, { deadline = 30000, probe = '/pm/tokens.css', step = 150 } = {}) {
|
|
58
|
+
const t0 = Date.now();
|
|
59
|
+
for (;;) {
|
|
60
|
+
const r = await fetch(base + probe).catch(() => null);
|
|
61
|
+
if (r && r.status === 200) return;
|
|
62
|
+
if (Date.now() - t0 > deadline) throw new Error(`server did not start on ${base} within ${Math.round(deadline / 1000)}s`);
|
|
63
|
+
await new Promise((res) => setTimeout(res, step));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
45
67
|
/**
|
|
46
68
|
* Arm a suite. MUST be called before `require('./server')` and before any child is spawned —
|
|
47
69
|
* server.js resolves its state root at require time, and a child inherits the environment as it
|
|
@@ -51,10 +73,23 @@ function claimEphemeralPort() {
|
|
|
51
73
|
*/
|
|
52
74
|
function configure({ name = 'run', port = null } = {}) {
|
|
53
75
|
if (state) return state;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
76
|
+
// DELETE the ambient values rather than adopt them. PMOS_STATE_DIR, PMOS_WS_PORT and
|
|
77
|
+
// PMOS_TMUX_SOCK are the PM's documented REAL-workspace overrides (workspace/README.md,
|
|
78
|
+
// bin/pmos.js, lib/state-root.js even advises exporting PMOS_STATE_DIR to resolve a state
|
|
79
|
+
// conflict). Adopting them here pointed the suite at live state: resetState() then rm -rf'd the
|
|
80
|
+
// PM's own sessions/registry, an exported port made the boot poll answer 200 from the PM's REAL
|
|
81
|
+
// server and drive it, and killTmux() killed their daemon. Same fix as PMOS_SELF_ROOT in
|
|
82
|
+
// test.js: the only value meaning "this run's own" is the one minted here.
|
|
83
|
+
delete process.env.PMOS_STATE_DIR;
|
|
84
|
+
delete process.env.PMOS_WS_PORT;
|
|
85
|
+
delete process.env.PMOS_TMUX_SOCK;
|
|
86
|
+
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), `pmos-state-${name}-`));
|
|
87
|
+
// Remove the scratch tree when the suite exits, green or red — nothing did, and every run left
|
|
88
|
+
// a pmos-state-* directory (scrollback, registry JSON) in tmpdir. Registered beside the mkdtemp
|
|
89
|
+
// above so it can only ever remove a directory THIS run made; removal at process exit only, so
|
|
90
|
+
// state survives between child servers within a run (the F-restart block needs that). `exit`
|
|
91
|
+
// handlers are synchronous-only — rmSync qualifies. A SIGKILLed suite still leaks, as before.
|
|
92
|
+
process.on('exit', () => { try { fs.rmSync(stateDir, { recursive: true, force: true }); } catch {} });
|
|
58
93
|
// In the environment BEFORE server.js is required here and before any child inherits it. Every
|
|
59
94
|
// boot site in every suite goes through process.env, so this is a chokepoint by construction
|
|
60
95
|
// rather than a convention each call site has to remember — and a convention that must be
|
|
@@ -62,7 +97,8 @@ function configure({ name = 'run', port = null } = {}) {
|
|
|
62
97
|
// which is exactly how this class of defect got in.
|
|
63
98
|
process.env.PMOS_STATE_DIR = stateDir;
|
|
64
99
|
// A private tmux socket, never the PM's own ('pmos'), so a suite cannot reach their sessions.
|
|
65
|
-
|
|
100
|
+
const tmuxSock = `pmos-${name}-${process.pid}`;
|
|
101
|
+
process.env.PMOS_TMUX_SOCK = tmuxSock;
|
|
66
102
|
|
|
67
103
|
const chosen = Number(port) > 0 ? Number(port) : claimEphemeralPort();
|
|
68
104
|
|
|
@@ -74,11 +110,17 @@ function configure({ name = 'run', port = null } = {}) {
|
|
|
74
110
|
},
|
|
75
111
|
/** A port nobody else holds, for a suite that needs a second server. */
|
|
76
112
|
port: claimEphemeralPort,
|
|
77
|
-
/**
|
|
78
|
-
|
|
113
|
+
/** Poll a spawned server until it answers — see waitForServer above. */
|
|
114
|
+
waitForServer,
|
|
115
|
+
/** Kill the tmux daemon this run minted, with every pane on it. Safe by construction: the
|
|
116
|
+
* socket is private and per-pid, so this can never reach the PM's own ('pmos'). Every suite
|
|
117
|
+
* that spawns the server must call this on its way out — SIGKILLing the server child leaves
|
|
118
|
+
* the daemon and its shells running indefinitely, and per-pid sockets accumulate without
|
|
119
|
+
* bound. */
|
|
120
|
+
killTmux() { try { spawnSync('tmux', ['-L', tmuxSock, 'kill-server'], { stdio: 'ignore' }); } catch {} },
|
|
79
121
|
};
|
|
80
122
|
Object.assign(module.exports, state);
|
|
81
123
|
return state;
|
|
82
124
|
}
|
|
83
125
|
|
|
84
|
-
module.exports = { configure, claimEphemeralPort, HOST };
|
|
126
|
+
module.exports = { configure, claimEphemeralPort, waitForServer, HOST };
|