create-pmos 0.37.0 → 0.38.1
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 +49 -9
- package/package.json +1 -1
- package/payload/AGENTS.md +4 -0
- package/payload/KIT-VERSION +2 -2
- package/payload/okf/core/concepts/frontmatter-format.md +27 -61
- package/payload/okf/core/templates/decision-record-template.md +34 -0
- package/payload/okf/core/templates/index.md +7 -2
- package/payload/okf/core/templates/pm-brief-template.md +34 -0
- package/payload/okf/core/templates/project-plan-template.md +43 -0
- package/payload/okf/core/templates/report-weekly-template.md +35 -0
- package/payload/okf/core/templates/strategy-long-range-template.md +37 -0
- package/payload/okf/core/templates/strategy-one-pager-template.md +34 -0
- package/payload/planning/evals/eval-calibration.md +8 -0
- package/payload/planning/okrs/health-budgets.md +8 -0
- package/payload/scripts/anchor-resolve.js +56 -6
- package/payload/scripts/gate-initiative.sh +32 -0
- package/payload/scripts/okf-upgrade.js +232 -0
- package/payload/scripts/pmos-memory.js +375 -0
- package/payload/scripts/pmos-model.js +239 -0
- package/payload/scripts/pmos-skills.js +274 -0
- package/payload/scripts/workflow-state.js +178 -10
- package/payload/scripts/workflow-sweep.js +2 -1
- package/payload/state/discoveries.md +8 -0
- package/payload/state/index.md +7 -0
- package/payload/state/log.md +7 -0
- package/workspace/README.md +61 -204
- package/workspace/bin/pmos.js +23 -4
- package/workspace/lib/board-cache.js +18 -8
- package/workspace/lib/console-state.js +2 -0
- package/workspace/lib/engine/anchor-resolve.js +56 -6
- package/workspace/lib/engine/pmos-memory.js +375 -0
- package/workspace/lib/engine/pmos-model.js +239 -0
- package/workspace/lib/engine/pmos-skills.js +274 -0
- package/workspace/lib/engine/workflow-state.js +178 -10
- package/workspace/lib/focus.js +5 -6
- package/workspace/lib/memory.js +54 -0
- package/workspace/lib/models.js +50 -0
- package/workspace/lib/preview-kind.js +12 -1
- package/workspace/lib/schedule-logic.js +31 -2
- package/workspace/lib/skill-store.js +5 -1
- package/workspace/lib/skills-registry.js +74 -0
- package/workspace/lib/sync.js +132 -0
- package/workspace/lib/tree-share.js +2 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +3 -3
- package/workspace/public/pm/app.js +32 -2
- package/workspace/public/pm/data.js +160 -10
- package/workspace/public/pm/lifecycle.js +105 -0
- package/workspace/public/pm/markdown.js +10 -1
- package/workspace/public/pm/modes-live.js +437 -0
- package/workspace/public/pm/modes.js +8 -5
- package/workspace/public/pm/panel-detail.js +22 -2
- package/workspace/public/pm/panel-initiatives.js +57 -12
- package/workspace/public/pm/panel-newsess.js +25 -0
- package/workspace/public/pm/panel-repos.js +130 -4
- package/workspace/public/pm/panel-tabs.js +49 -2
- package/workspace/public/pm/state.js +11 -1
- package/workspace/public/pm/tab-doc.js +160 -26
- package/workspace/public/pm/tab-term.js +2 -0
- package/workspace/public/pm/tab-web.js +89 -0
- package/workspace/public/pm/tokens.css +4 -0
- package/workspace/public/pm/xlsx-read.js +117 -0
- package/workspace/server.js +736 -42
- package/workspace/skills/gather-requirements.skill +53 -0
- package/workspace/skills/memory-groom.skill +46 -0
- package/workspace/skills/report-biweekly.skill +53 -0
- package/workspace/skills/report-daily.skill +53 -0
- package/workspace/skills/report-monthly.skill +53 -0
- package/workspace/skills/report-special.skill +49 -0
- package/workspace/skills/report-weekly.skill +53 -0
- package/workspace/skills/skill-discover.skill +49 -0
- package/workspace/skills/skill-generate.skill +33 -0
package/workspace/server.js
CHANGED
|
@@ -71,6 +71,10 @@ const rateLimit = require('./lib/rate-limit'); // per-connection token b
|
|
|
71
71
|
const consoleState = require('./lib/console-state'); // read-only console surfaces over file-mode state (D64 B)
|
|
72
72
|
const boardCache = require('./lib/board-cache'); // the workflow board, cached + derived off the event loop (R4)
|
|
73
73
|
const fmParse = require('./lib/file-mode-parse'); // canonical file-mode parsers, shared with metrics.py (D64 D)
|
|
74
|
+
const skillsReg = require('./lib/skills-registry'); // discovered and generated skills: quarantine, provenance, local approval (D76)
|
|
75
|
+
const modelLib = require('./lib/models'); // tier routing — one code path, shared with the agents' CLI (D75)
|
|
76
|
+
const memoryLib = require('./lib/memory'); // curated repository memory — one ledger code path, shared with the agents' CLI (D72)
|
|
77
|
+
const syncLib = require('./lib/sync'); // press-only fetch / integrate / commit records / push (D70)
|
|
74
78
|
const gitInfo = require('./lib/git-info'); // read-mostly branch awareness + select/create at session start
|
|
75
79
|
const startPolicy = require('./lib/start-policy'); // ONE stated rule for where a session starts (repo-first R1/R3)
|
|
76
80
|
const treeShare = require('./lib/tree-share'); // who shares a working tree, and who is DECLARED to be driving it
|
|
@@ -112,6 +116,7 @@ const HOME_ENABLED = EMBEDDED_SELF || (!!process.env.PMOS_SELF_ROOT && dirExists
|
|
|
112
116
|
const PUB = path.join(__dirname, 'public');
|
|
113
117
|
const PM_DIR = path.join(PUB, 'pm'); // the four-panel front end — served as a directory, guarded below
|
|
114
118
|
const XROOT = path.join(__dirname, 'node_modules', '@xterm');
|
|
119
|
+
const APP_VERSION = (() => { try { return require('./package.json').version || null; } catch { return null; } })();
|
|
115
120
|
// ── WHERE THE PM'S RUNTIME STATE LIVES (see lib/state-root.js for the whole argument). ─────────
|
|
116
121
|
// It is NOT `workspace/state/` any more. That directory is inside the application directory the
|
|
117
122
|
// test suite runs from, which put the PM's live sessions, workspace registry and authored skills
|
|
@@ -355,7 +360,7 @@ function loadWorkspaces() {
|
|
|
355
360
|
if (!w || !w.root) continue;
|
|
356
361
|
const root = path.resolve(w.root);
|
|
357
362
|
if (!isDir(root)) continue; // a moved/deleted repo drops out quietly
|
|
358
|
-
workspaces.set(wsId(root), { id: wsId(root), name: w.name || path.basename(root), root, addedAt: w.addedAt || nowISO() });
|
|
363
|
+
workspaces.set(wsId(root), { id: wsId(root), name: w.name || path.basename(root), root, addedAt: w.addedAt || nowISO(), ...(w.label ? { label: String(w.label) } : {}) });
|
|
359
364
|
}
|
|
360
365
|
}
|
|
361
366
|
}
|
|
@@ -378,13 +383,49 @@ function registerWorkspace(root, name) {
|
|
|
378
383
|
}
|
|
379
384
|
// Forgetting a workspace never touches the repo — it only stops hosting it here. Blocked
|
|
380
385
|
// while it still owns sessions, so scrollback is never orphaned by a click.
|
|
381
|
-
|
|
386
|
+
/**
|
|
387
|
+
* Why a workspace cannot be forgotten, or null when it can. The three causes used to share one
|
|
388
|
+
* message ("unknown workspace, the home workspace, or it still owns sessions"), which told the PM
|
|
389
|
+
* nothing they could act on (overhaul-r1 R3.3). Sessions are NAMED, live or exited, because an
|
|
390
|
+
* exited session still belongs to the row and would re-register it on restart (~recovery).
|
|
391
|
+
*/
|
|
392
|
+
function forgetBlocker(id) {
|
|
382
393
|
const w = workspaces.get(id);
|
|
383
|
-
if (!w
|
|
384
|
-
if (
|
|
394
|
+
if (!w) return { reason: 'unknown', error: 'unknown repository — it may already have been removed' };
|
|
395
|
+
if (HOME_ENABLED && w.root === SELF_ROOT) return { reason: 'home', error: `“${w.name}” is the home workspace and cannot be removed` };
|
|
396
|
+
const owned = [...sessions.values()].filter((s) => s.ws === id);
|
|
397
|
+
if (owned.length) {
|
|
398
|
+
const names = owned.map((s) => s.name || s.id);
|
|
399
|
+
return { reason: 'sessions', sessions: names,
|
|
400
|
+
error: `${owned.length} session${owned.length === 1 ? '' : 's'} still use${owned.length === 1 ? 's' : ''} “${w.name}”: ${names.join(', ')} — close or remove ${owned.length === 1 ? 'it' : 'them'} first` };
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
function forgetWorkspace(id) {
|
|
405
|
+
if (forgetBlocker(id)) return false;
|
|
385
406
|
workspaces.delete(id); saveWorkspaces(); pushWorkspaces();
|
|
386
407
|
return true;
|
|
387
408
|
}
|
|
409
|
+
/**
|
|
410
|
+
* Remove a REPOSITORY: its row and every linked-worktree row of the same repo (overhaul-r1 R3.2).
|
|
411
|
+
* All-or-nothing — if any row is blocked, nothing is forgotten and every blocker is reported. Never
|
|
412
|
+
* deletes a folder; reclaiming a worktree's folder stays with /api/git/worktrees/remove.
|
|
413
|
+
*/
|
|
414
|
+
function forgetRepository(id) {
|
|
415
|
+
const w = workspaces.get(id);
|
|
416
|
+
if (!w) return { ok: false, ...forgetBlocker(id) };
|
|
417
|
+
const fam = treeFamily(w);
|
|
418
|
+
const blocked = fam.map((x) => ({ x, b: forgetBlocker(x.id) })).filter((y) => y.b);
|
|
419
|
+
if (blocked.length) {
|
|
420
|
+
const sessionsNamed = blocked.flatMap((y) => y.b.sessions || []);
|
|
421
|
+
const first = blocked[0].b;
|
|
422
|
+
return { ok: false, reason: first.reason, sessions: sessionsNamed.length ? sessionsNamed : undefined,
|
|
423
|
+
error: blocked.map((y) => y.b.error).join('; ') };
|
|
424
|
+
}
|
|
425
|
+
for (const x of fam) workspaces.delete(x.id);
|
|
426
|
+
saveWorkspaces(); pushWorkspaces();
|
|
427
|
+
return { ok: true, removed: fam.map((x) => x.id) };
|
|
428
|
+
}
|
|
388
429
|
// The home is SELF_ROOT when enabled; otherwise (installed copy, no launcher env) there is no
|
|
389
430
|
// privileged home, so an unresolved ws id falls back to the first registered repo rather than
|
|
390
431
|
// resolving to the package dir.
|
|
@@ -501,6 +542,11 @@ function treeWriter(tree) {
|
|
|
501
542
|
* definition looking at and which persists in the session record. Every new session, until the two
|
|
502
543
|
* directories are resolved, because a notice that shows once is a notice that is missed once.
|
|
503
544
|
*/
|
|
545
|
+
// D75 — say which model was applied, in the pane's own record, when one was asked for. Silent for 'not asked'.
|
|
546
|
+
function announceModel(s) {
|
|
547
|
+
if (!s.model || s.model.state === 'not-asked') return;
|
|
548
|
+
appendBuf(s, `\x1b[38;5;244mpmos: ${modelLib.line(s.model)}\x1b[0m\r\n`);
|
|
549
|
+
}
|
|
504
550
|
function announceStateHealth(s) {
|
|
505
551
|
const h = stateHealthNow();
|
|
506
552
|
if (h.ok) return;
|
|
@@ -542,7 +588,7 @@ function wsList() {
|
|
|
542
588
|
return [...workspaces.values()].map((w) => {
|
|
543
589
|
const rid = repoIdentity(w.root);
|
|
544
590
|
return {
|
|
545
|
-
id: w.id, name: w.name, root: w.root, home: HOME_ENABLED && w.root === SELF_ROOT,
|
|
591
|
+
id: w.id, name: w.name, label: w.label || null, root: w.root, home: HOME_ENABLED && w.root === SELF_ROOT,
|
|
546
592
|
shape: wsShape(w.root), addedAt: w.addedAt,
|
|
547
593
|
sessions: [...sessions.values()].filter((s) => s.ws === w.id).length,
|
|
548
594
|
// the repository this working tree belongs to, and the branch it is on. `branch_code`
|
|
@@ -832,7 +878,7 @@ const SEARCH_LIMIT_DEFAULT = 40;
|
|
|
832
878
|
const SEARCH_LIMIT_MAX = 200;
|
|
833
879
|
const SEARCH_INDEX_TTL_MS = 4000; // one word of typing costs one walk, not one per keystroke
|
|
834
880
|
const SEARCH_SNIPPET = 160;
|
|
835
|
-
const SEARCH_OPENABLE = new Set(['markdown', 'text', 'html']); // what /api/doc will serve
|
|
881
|
+
const SEARCH_OPENABLE = new Set(['markdown', 'text', 'html', 'pdf', 'sheet']); // what /api/doc (or /api/asset, for pdf) will serve
|
|
836
882
|
const searchIndexCache = new Map(); // root -> { at, files, truncated, scanned }
|
|
837
883
|
|
|
838
884
|
/** Every file under `root`, flat and repo-relative, cached for SEARCH_INDEX_TTL_MS. */
|
|
@@ -949,6 +995,7 @@ function searchWorkspace(root, query, limit) {
|
|
|
949
995
|
* cannot click into an unknown. */
|
|
950
996
|
const st = fs.statSync(path.join(root, rel));
|
|
951
997
|
if (!st.isFile()) return false;
|
|
998
|
+
if (['pdf', 'sheet'].includes(preview.previewKind(rel))) return st.size > 0; // served by /api/asset, never read as text
|
|
952
999
|
/* Past the byte budget we do not READ the file — but "we did not look" is not "the reader
|
|
953
1000
|
* refuses it". Returning false there swapped one false label for its opposite: a legitimate
|
|
954
1001
|
* 316 KB markdown doc reported openable:false while /api/doc served it 200 with 324,017 bytes.
|
|
@@ -977,7 +1024,7 @@ function searchWorkspace(root, query, limit) {
|
|
|
977
1024
|
if (overflow) contentCut = true;
|
|
978
1025
|
if (!overflow) {
|
|
979
1026
|
for (const f of idx.files) {
|
|
980
|
-
if (seen.has(f.path) || !SEARCH_OPENABLE.has(f.kind)) continue;
|
|
1027
|
+
if (seen.has(f.path) || !SEARCH_OPENABLE.has(f.kind) || f.kind === 'pdf' || f.kind === 'sheet') continue; // a PDF body is not text
|
|
981
1028
|
if (read >= SEARCH_CONTENT_FILES) { contentCut = true; break; }
|
|
982
1029
|
const full = path.join(root, f.path);
|
|
983
1030
|
let st;
|
|
@@ -1015,6 +1062,86 @@ function searchWorkspace(root, query, limit) {
|
|
|
1015
1062
|
};
|
|
1016
1063
|
}
|
|
1017
1064
|
|
|
1065
|
+
/**
|
|
1066
|
+
* Every registered tree of the repository `w` belongs to — its LINES OF WORK (overhaul-r1 R1).
|
|
1067
|
+
* Derived per call from `repo_key`, the same identity `wsList()` publishes and the client folds
|
|
1068
|
+
* by, so the registry gains no parent pointer and there is still one source of truth. The main
|
|
1069
|
+
* tree (the one whose root IS the repo key) comes first; the rest keep registry order. A folder
|
|
1070
|
+
* with no git is a family of one, which is the truth.
|
|
1071
|
+
*/
|
|
1072
|
+
function treeFamily(w) {
|
|
1073
|
+
if (!w) return [];
|
|
1074
|
+
const key = repoIdentity(w.root).key;
|
|
1075
|
+
const fam = [...workspaces.values()].filter((x) => x.id === w.id || repoIdentity(x.root).key === key);
|
|
1076
|
+
fam.sort((a, b) => (b.root === key) - (a.root === key));
|
|
1077
|
+
return fam;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/** Content identity for the merge — full-body sha1, only within the content budget. A file past
|
|
1081
|
+
* the budget is NOT compared, so it is never merged on a guess: it stays one row per tree. */
|
|
1082
|
+
function contentDigest(root, rel) {
|
|
1083
|
+
try {
|
|
1084
|
+
const full = path.join(root, rel);
|
|
1085
|
+
const st = fs.statSync(full);
|
|
1086
|
+
if (!st.isFile() || st.size > SEARCH_CONTENT_BYTES) return null;
|
|
1087
|
+
return crypto.createHash('sha1').update(fs.readFileSync(full)).digest('hex');
|
|
1088
|
+
} catch { return null; }
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Search every line of work of a repository (overhaul-r1 R1.1–R1.5). Each hit carries the `ws` and
|
|
1093
|
+
* `branch` of the tree it came from, plus `branches` — every tree that holds the same path with
|
|
1094
|
+
* byte-identical content, which is then listed ONCE (PM choice, 2026-10-01). A path that differs
|
|
1095
|
+
* between trees, or exists on only some, is one hit per distinct tree. Walk bounds stay per tree
|
|
1096
|
+
* and are reported per tree in `trees[]`, so a capped walk on one branch cannot read as complete;
|
|
1097
|
+
* the top-level flags are the OR across trees. `only` narrows the search to one tree.
|
|
1098
|
+
*/
|
|
1099
|
+
function searchFamily(w, query, limit, only) {
|
|
1100
|
+
let fam = treeFamily(w);
|
|
1101
|
+
if (only) fam = fam.filter((x) => x.id === only);
|
|
1102
|
+
if (!fam.length) fam = [w];
|
|
1103
|
+
const branchOf = (x) => repoIdentity(x.root).branch || null;
|
|
1104
|
+
const per = fam.map((x) => ({ x, branch: branchOf(x), r: searchWorkspace(x.root, query, limit) }));
|
|
1105
|
+
const first = per[0].r;
|
|
1106
|
+
const cap = first.limit;
|
|
1107
|
+
const groups = new Map(); // path -> [{hit, x, branch, digest}]
|
|
1108
|
+
for (const t of per) {
|
|
1109
|
+
for (const h of t.r.hits || []) {
|
|
1110
|
+
if (!groups.has(h.path)) groups.set(h.path, []);
|
|
1111
|
+
groups.get(h.path).push({ h, x: t.x, branch: t.branch });
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
const hits = [];
|
|
1115
|
+
for (const list of groups.values()) {
|
|
1116
|
+
if (list.length === 1) {
|
|
1117
|
+
const e = list[0];
|
|
1118
|
+
hits.push({ ...e.h, ws: e.x.id, branch: e.branch, branches: [e.branch] });
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
const buckets = new Map(); // digest (or a per-tree token when uncomparable) -> entries
|
|
1122
|
+
for (const e of list) {
|
|
1123
|
+
const d = contentDigest(e.x.root, e.h.path) || ('uncompared:' + e.x.id);
|
|
1124
|
+
if (!buckets.has(d)) buckets.set(d, []);
|
|
1125
|
+
buckets.get(d).push(e);
|
|
1126
|
+
}
|
|
1127
|
+
for (const b of buckets.values()) {
|
|
1128
|
+
const e = b[0];
|
|
1129
|
+
hits.push({ ...e.h, ws: e.x.id, branch: e.branch, branches: b.map((y) => y.branch) });
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
const overflow = hits.length > cap;
|
|
1133
|
+
return {
|
|
1134
|
+
ok: per.every((t) => t.r.ok !== false), q: first.q, limit: cap,
|
|
1135
|
+
hits: hits.slice(0, cap),
|
|
1136
|
+
truncated: overflow || per.some((t) => t.r.truncated),
|
|
1137
|
+
content_truncated: per.some((t) => t.r.content_truncated),
|
|
1138
|
+
scanned: per.reduce((n, t) => n + (t.r.scanned || 0), 0),
|
|
1139
|
+
content_scanned: per.reduce((n, t) => n + (t.r.content_scanned || 0), 0),
|
|
1140
|
+
trees: per.map((t) => ({ ws: t.x.id, branch: t.branch, main: t.x.root === repoIdentity(w.root).key,
|
|
1141
|
+
truncated: !!t.r.truncated, content_truncated: !!t.r.content_truncated, scanned: t.r.scanned || 0 })),
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1018
1145
|
// Serve a TEXTUAL document — markdown (incl. .skill), plain-text/config source, or HTML source —
|
|
1019
1146
|
// per-workspace-guarded. Images are NOT served here (they stream through /api/asset); anything
|
|
1020
1147
|
// non-textual returns null so the reader shows it inert. A NUL byte ⇒ binary masquerading as text
|
|
@@ -1131,8 +1258,23 @@ function parseHead(full) {
|
|
|
1131
1258
|
const clean = (m) => m ? m[1].replace(/\*\*/g, '').split(/\s+[—–-]\s|\(/)[0].trim().slice(0, 48) : null;
|
|
1132
1259
|
const kr = head.match(/^\s*[-*]?\s*parent[_-]?kr\s*:\s*(.+?)\s*$/mi);
|
|
1133
1260
|
const lane = head.match(/^\s*[-*]?\s*lane\s*:\s*(.+?)\s*$/mi);
|
|
1261
|
+
// overhaul-r2 C1 — an artifact may serve SEVERAL initiatives: `initiatives: [a, b]`,
|
|
1262
|
+
// `initiative: a, b`, or a YAML list under `initiatives:`. Each named id lists the artifact; the
|
|
1263
|
+
// first is also its primary owner when no `initiative_id:` is declared.
|
|
1264
|
+
const also = [];
|
|
1265
|
+
const multi = head.match(/^\s*initiatives?\s*:[ \t]*(.*)$/m);
|
|
1266
|
+
if (multi) {
|
|
1267
|
+
const inline = multi[1].replace(/^\[|\]$/g, '').trim();
|
|
1268
|
+
if (inline) inline.split(',').forEach((x) => also.push(x.trim().replace(/^["']|["']$/g, '')));
|
|
1269
|
+
else {
|
|
1270
|
+
const after = head.slice(multi.index + multi[0].length).split('\n').slice(1);
|
|
1271
|
+
for (const ln of after) { const li = /^\s+-\s*["']?([A-Za-z0-9][\w.-]*)["']?\s*$/.exec(ln); if (!li) break; also.push(li[1]); }
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
const alsoIds = [...new Set(also.filter((x) => /^[A-Za-z0-9][\w.-]*$/.test(x)))];
|
|
1134
1275
|
return {
|
|
1135
|
-
initiative: id ? id[1] : null,
|
|
1276
|
+
initiative: id ? id[1] : (alsoIds[0] || null),
|
|
1277
|
+
also: alsoIds,
|
|
1136
1278
|
recordId: rid ? rid[1] : null,
|
|
1137
1279
|
title: h1 ? h1[1].replace(/^(PRD|Eval Rubric|Design Brief)\s*:\s*/i, '').trim() : null,
|
|
1138
1280
|
kr: clean(kr), lane: clean(lane),
|
|
@@ -1195,6 +1337,7 @@ function docMeta(root, full) {
|
|
|
1195
1337
|
mtimeMs: st.mtimeMs, path: rel, name: base, role,
|
|
1196
1338
|
// frontmatter wins; the filename convention is the fallback, never the override
|
|
1197
1339
|
initiative: head.initiative || (role === 'record' ? head.recordId : null) || null,
|
|
1340
|
+
also: role === 'record' ? [] : (head.also || []),
|
|
1198
1341
|
stem, title: head.title || null,
|
|
1199
1342
|
kr: head.kr || null, lane: head.lane || null,
|
|
1200
1343
|
dest: head.dest || null,
|
|
@@ -1288,6 +1431,13 @@ function initiativeIndex(root) {
|
|
|
1288
1431
|
if (!groups.has(k)) groups.set(k, { id: k, title: null, kr: null, lane: null, artifacts: [] });
|
|
1289
1432
|
const g = groups.get(k);
|
|
1290
1433
|
g.artifacts.push(art);
|
|
1434
|
+
// overhaul-r2 C1 — listed under every OTHER initiative it names too (it does not lend them a
|
|
1435
|
+
// title, an anchor or a lane: those stay the record's and its primary owner's)
|
|
1436
|
+
for (const other of m.also || []) {
|
|
1437
|
+
if (other === k) continue;
|
|
1438
|
+
if (!groups.has(other)) groups.set(other, { id: other, title: null, kr: null, lane: null, artifacts: [] });
|
|
1439
|
+
groups.get(other).artifacts.push({ ...art, shared: true });
|
|
1440
|
+
}
|
|
1291
1441
|
if (m.role === 'PRD' && m.title) g.title = m.title; // the PRD names the initiative
|
|
1292
1442
|
else if (!g.title && m.title) g.title = m.title;
|
|
1293
1443
|
// The anchor and lane are the RECORD's to declare (D56/D59 — it is what the gates read); the
|
|
@@ -1314,6 +1464,17 @@ function initiativeIndex(root) {
|
|
|
1314
1464
|
// groups planning/ into initiatives and carries the PMOS knowledge (okf/skills/build-skills,
|
|
1315
1465
|
// wherever they live) + governance. Its guarantee is that it never DROPS a planning document —
|
|
1316
1466
|
// not that it mirrors every file in the repo (the folder lens is for that).
|
|
1467
|
+
// R11 — rewrite a single-line `generated: {by: …, at: …}` in the LEADING frontmatter block to the PM as author.
|
|
1468
|
+
// Returns the text unchanged when there is no such line (never adds one, never touches a nested block).
|
|
1469
|
+
function stampEdit(text, at) {
|
|
1470
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---(?=\r?\n|$)/.exec(text);
|
|
1471
|
+
if (!m) return text;
|
|
1472
|
+
const fm = m[1];
|
|
1473
|
+
const re = /^generated:[ \t]*\{[^\n}]*\}[ \t]*$/m;
|
|
1474
|
+
if (!re.test(fm)) return text;
|
|
1475
|
+
const next = fm.replace(re, `generated: {by: human:wawan, at: "${at}"}`);
|
|
1476
|
+
return next === fm ? text : text.replace(fm, () => next);
|
|
1477
|
+
}
|
|
1317
1478
|
function library(root) {
|
|
1318
1479
|
const shape = wsShape(root);
|
|
1319
1480
|
const roots = libraryTree(root);
|
|
@@ -1323,7 +1484,15 @@ function library(root) {
|
|
|
1323
1484
|
.map((d) => tree(root, d, {}, 0, { n: TREE_MAX_NODES }))
|
|
1324
1485
|
.filter((t) => t.dirs.length || t.docs.length)
|
|
1325
1486
|
: [];
|
|
1326
|
-
|
|
1487
|
+
// D74/R11 — the notes PMOS and its agents write into a repository: requirement notes, report drafts and
|
|
1488
|
+
// definitions, memory views (and the portfolio report at the root). Listed for ANY repository shape, because a
|
|
1489
|
+
// PM repository is not a 'pmos'-shaped bundle and its notes were invisible to this lens.
|
|
1490
|
+
const NOTE_DIRS = ['pmos/state/requirements', 'pmos/state/reports', 'pmos/state/memory',
|
|
1491
|
+
'planning/requirements', 'planning/reports', 'planning/memory', 'reports'];
|
|
1492
|
+
const notes = NOTE_DIRS.map((d) => path.join(root, ...d.split('/'))).filter((d) => isDir(d))
|
|
1493
|
+
.map((d) => tree(root, d, {}, 0, { n: TREE_MAX_NODES }))
|
|
1494
|
+
.filter((t) => t.dirs.length || t.docs.length);
|
|
1495
|
+
return { shape, roots, initiatives, ungrouped, knowledge, notes, governance: looseDocs(root) };
|
|
1327
1496
|
}
|
|
1328
1497
|
|
|
1329
1498
|
// ── On-demand surfaces (D60 / the workspace-ondemand-surfaces PRD). ────────────────────────────
|
|
@@ -1334,7 +1503,7 @@ function library(root) {
|
|
|
1334
1503
|
// client renders it; a surface that can't be produced returns ok:false and the client shows an
|
|
1335
1504
|
// honest can't-produce state. RICE/effort/milestones/progress are NET-NEW state → emitted as null
|
|
1336
1505
|
// ("— not tracked"), never invented (D14/D40 honesty).
|
|
1337
|
-
const PHASE_OF = { planning: 'plan', building: 'deliver', blocked: 'deliver', delivered: 'report', accepted: 'report', unknown: 'plan' };
|
|
1506
|
+
const PHASE_OF = { stopped: 'plan', planning: 'plan', building: 'deliver', blocked: 'deliver', delivered: 'report', accepted: 'report', unknown: 'plan' };
|
|
1338
1507
|
|
|
1339
1508
|
// ── Delivery evidence (board-delivery-truth R-2/R-3; shared reader, workflow-status-sweep R-1). ──
|
|
1340
1509
|
// "Has this shipped?" answered from the trunk's own history rather than from anything anyone typed.
|
|
@@ -1399,6 +1568,7 @@ function deliveryIndex(root, ids) {
|
|
|
1399
1568
|
* one ordering departure, made because the alternative buries a returned initiative.)
|
|
1400
1569
|
*/
|
|
1401
1570
|
function deriveState(ev) {
|
|
1571
|
+
if (ev.stopped && !ev.accepted) return { state: 'stopped', why: `${ev.stopped.state}${ev.stopped.reason ? ` — ${ev.stopped.reason}` : ''} (lifecycle.jsonl, D70)` };
|
|
1402
1572
|
if (ev.accepted) return { state: 'accepted', why: `accepted by the PM${ev.acceptedAt ? ` on ${String(ev.acceptedAt).slice(0, 10)}` : ''}` };
|
|
1403
1573
|
if (ev.returned) return { state: 'blocked', why: 'the PM returned this at the Acceptance Gate' };
|
|
1404
1574
|
if (ev.needs) return { state: 'blocked', why: `${ev.needs} open item${ev.needs > 1 ? 's' : ''} in needs.json` };
|
|
@@ -1439,6 +1609,19 @@ function readLedger(w, basename) {
|
|
|
1439
1609
|
}
|
|
1440
1610
|
return out;
|
|
1441
1611
|
}
|
|
1612
|
+
// D70 — the lifecycle ledger: the latest line for an initiative wins; `active` reopens. The same rule
|
|
1613
|
+
// as scripts/workflow-state.js#readLifecycle (a parity case in workspace/test.js keeps them in step).
|
|
1614
|
+
const syncing = new Set(); // repositories with a sync in flight — one at a time
|
|
1615
|
+
const STOPPED_STATES = ['closed', 'on-hold', 'cancelled'];
|
|
1616
|
+
function readLifecycle(w) {
|
|
1617
|
+
const latest = new Map();
|
|
1618
|
+
for (const r of readLedger(w, 'lifecycle.jsonl')) {
|
|
1619
|
+
if (!r || typeof r.initiative_id !== 'string') continue;
|
|
1620
|
+
if (r.state !== 'active' && !STOPPED_STATES.includes(r.state)) continue;
|
|
1621
|
+
latest.set(r.initiative_id, { state: r.state, reason: r.reason || '', pm: r.pm || '', at: r.at || '' });
|
|
1622
|
+
}
|
|
1623
|
+
return latest;
|
|
1624
|
+
}
|
|
1442
1625
|
function readAcceptances(w) {
|
|
1443
1626
|
const acc = new Map(); // initiative_id -> { action, at }
|
|
1444
1627
|
for (const r of readLedger(w, 'acceptances.jsonl')) {
|
|
@@ -1490,6 +1673,7 @@ function readRunCounts(w) {
|
|
|
1490
1673
|
function surfaceInitiatives(root, w) {
|
|
1491
1674
|
const { initiatives } = initiativeIndex(root);
|
|
1492
1675
|
const acc = readAcceptances(w);
|
|
1676
|
+
const life = readLifecycle(w);
|
|
1493
1677
|
const runs = readRunCounts(w);
|
|
1494
1678
|
const needsRaw = readNeeds(w) || [];
|
|
1495
1679
|
const needsBy = new Map();
|
|
@@ -1502,6 +1686,7 @@ function surfaceInitiatives(root, w) {
|
|
|
1502
1686
|
const { state, why } = deriveState({
|
|
1503
1687
|
// readAcceptances already folds every accepting label (approve/merge/publish) into 'accept'.
|
|
1504
1688
|
accepted: a.action === 'accept', acceptedAt: a.at,
|
|
1689
|
+
stopped: (life.get(g.id) && life.get(g.id).state !== 'active') ? life.get(g.id) : null,
|
|
1505
1690
|
returned: a.action === 'return',
|
|
1506
1691
|
needs: needsBy.get(g.id) || 0,
|
|
1507
1692
|
delivery: del.ok ? 'known' : 'unknown',
|
|
@@ -1515,6 +1700,7 @@ function surfaceInitiatives(root, w) {
|
|
|
1515
1700
|
// the client renders `statusText`, which always names the artifact the state was read from.
|
|
1516
1701
|
stage: null, kr: g.kr || null, lane: g.lane || null,
|
|
1517
1702
|
accepted: state === 'accepted',
|
|
1703
|
+
lifecycle: (life.get(g.id) && life.get(g.id).state !== 'active') ? life.get(g.id) : null,
|
|
1518
1704
|
delivered: state === 'accepted' || state === 'delivered',
|
|
1519
1705
|
delivery_evidence: deliveredBy,
|
|
1520
1706
|
artifacts: g.artifacts.map((a2) => ({ role: a2.role, name: a2.name, path: a2.path, dest: a2.dest || null })),
|
|
@@ -1530,7 +1716,7 @@ function surfaceInitiatives(root, w) {
|
|
|
1530
1716
|
initiatives: items,
|
|
1531
1717
|
pulse: {
|
|
1532
1718
|
total: items.length, planning: by('planning'), building: by('building'),
|
|
1533
|
-
blocked: by('blocked'), delivered: by('delivered'), accepted: by('accepted'),
|
|
1719
|
+
blocked: by('blocked'), delivered: by('delivered'), accepted: by('accepted'), stopped: by('stopped'),
|
|
1534
1720
|
unknown: by('unknown'),
|
|
1535
1721
|
},
|
|
1536
1722
|
// R-3: a failed read is reported, never absorbed. The client renders this RED above the list
|
|
@@ -1664,6 +1850,7 @@ function surfaceSpine(root) {
|
|
|
1664
1850
|
|
|
1665
1851
|
const budgets = new Map(); // health:<id> groups — peers of objectives (D59), not a footnote
|
|
1666
1852
|
const unanchored = []; // R6 — never dropped to make the tree tidy
|
|
1853
|
+
const domains = new Map(); // domain:<id> groups (D67) — work that serves a domain, no OKR
|
|
1667
1854
|
|
|
1668
1855
|
for (const dir of initiativeDirs(root)) {
|
|
1669
1856
|
let files = [];
|
|
@@ -1691,6 +1878,13 @@ function surfaceSpine(root) {
|
|
|
1691
1878
|
budgets.get(key).initiatives.push(rec);
|
|
1692
1879
|
continue;
|
|
1693
1880
|
}
|
|
1881
|
+
// D67 — a domain is a peer group like a health budget: the standing goal it serves, no OKR
|
|
1882
|
+
if (r.kind === 'domain' && r.status === 'resolved') {
|
|
1883
|
+
const key = `domain:${r.domain}`;
|
|
1884
|
+
if (!domains.has(key)) domains.set(key, { id: key, name: r.domain, goal: r.domain_goal || null, initiatives: [] });
|
|
1885
|
+
domains.get(key).initiatives.push(rec);
|
|
1886
|
+
continue;
|
|
1887
|
+
}
|
|
1694
1888
|
if (r.kind === 'okr' && r.status === 'resolved') {
|
|
1695
1889
|
const kr = r.kr ? krByKey.get(`${A.objectiveId(r.objective)}::${A.krId(r.kr)}`) : null;
|
|
1696
1890
|
if (kr) { kr.initiatives.push(rec); continue; }
|
|
@@ -1714,6 +1908,7 @@ function surfaceSpine(root) {
|
|
|
1714
1908
|
return {
|
|
1715
1909
|
objectives,
|
|
1716
1910
|
budgets: [...budgets.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
|
1911
|
+
domains: [...domains.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
|
1717
1912
|
unanchored,
|
|
1718
1913
|
unreadable: null,
|
|
1719
1914
|
budgets_readable: !!(budgetsDoc && budgetsDoc.ok),
|
|
@@ -1759,6 +1954,9 @@ function buildSurface(name, root, w) {
|
|
|
1759
1954
|
// refused the move, takes effect on the next read instead of on the next restart.
|
|
1760
1955
|
function schedFile() { return schedStore().path; }
|
|
1761
1956
|
const DELIV_DIR = path.join(STATE, 'deliverables');
|
|
1957
|
+
// D76 — the PM's LOCAL approvals of discovered skills. Runtime state on this machine, never the repository: a commit or a
|
|
1958
|
+
// hand-edited registry must not be able to approve a skill for the PM.
|
|
1959
|
+
const SKILL_APPROVALS = path.join(STATE, 'skill-approvals.jsonl');
|
|
1762
1960
|
// Skills are file-driven (workspace-skill-authoring): built-ins ship committed under workspace/skills/,
|
|
1763
1961
|
// user-authored ones are scaffolded into state/skills/ by the New Skill flow. There is ONE user-facing
|
|
1764
1962
|
// concept — a skill — so the picker never exposes the internal `run`/`kind`; how a skill runs (server
|
|
@@ -1783,22 +1981,42 @@ function filingContract(w) {
|
|
|
1783
1981
|
}
|
|
1784
1982
|
// Read the skill files fresh each call so a just-created user skill appears with no restart. User dirs
|
|
1785
1983
|
// load FIRST and built-ins LAST, so a built-in's load-bearing deterministic fast-path can't be shadowed.
|
|
1786
|
-
|
|
1984
|
+
// D76 — `root` adds the repository's APPROVED discovered skills, loaded FIRST so the PM's own skill of the same id
|
|
1985
|
+
// overrides one and a built-in overrides both. Only an approved candidate whose file still hashes to what was approved
|
|
1986
|
+
// is ever offered; its harness is forced to an agent skill running claude.
|
|
1987
|
+
// The loaded skill records, each with the `file` it came from. loadTasks maps these to the task shape
|
|
1988
|
+
// and drops the file; the skill detail routes below need the file, so both read through here.
|
|
1989
|
+
function loadSkillRecords(root) {
|
|
1990
|
+
const discovered = root ? skillsReg.loadApproved(root, SKILL_APPROVALS, skillStore.parseSkill).skills : [];
|
|
1787
1991
|
return skillStore.loadSkills([
|
|
1992
|
+
...(discovered.length ? [{ skills: discovered }] : []),
|
|
1788
1993
|
{ dir: userSkillsDir(), source: 'user' },
|
|
1789
1994
|
{ dir: BUILTIN_SKILLS_DIR, source: 'builtin' },
|
|
1790
|
-
])
|
|
1995
|
+
]);
|
|
1791
1996
|
}
|
|
1997
|
+
function loadTasks(root) { return loadSkillRecords(root).map(toTask); }
|
|
1792
1998
|
// Map a loaded skill to the internal task shape fireSchedule/the picker use. `kind` is derived from the
|
|
1793
1999
|
// file's `run` and stays INTERNAL — it is never a field the PM selects.
|
|
1794
2000
|
function toTask(s) {
|
|
1795
2001
|
const t = { id: s.id, label: s.label, skill: s.id, kind: s.run, source: s.source,
|
|
1796
|
-
description: s.description, output: s.output };
|
|
2002
|
+
description: s.description, output: s.output, tier: s.tier || null };
|
|
1797
2003
|
if (s.run === 'deterministic') t.surface = s.surface;
|
|
1798
2004
|
else { t.agent = s.agent || 'claude'; t.prompt = s.body || null; }
|
|
1799
2005
|
return t;
|
|
1800
2006
|
}
|
|
1801
|
-
function taskById(id) { return loadTasks().find((t) => t.id === id || t.skill === id) || null; }
|
|
2007
|
+
function taskById(id, root) { return loadTasks(root).find((t) => t.id === id || t.skill === id) || null; }
|
|
2008
|
+
// D71 — the recommended schedules, one set per purpose. Times are host-local, like every schedule.
|
|
2009
|
+
const SEED_SETS = {
|
|
2010
|
+
requirements: [{ name: 'Daily requirement gathering', skill: 'gather-requirements', cadence: 'daily', time: '07:30' }],
|
|
2011
|
+
memory: [{ name: 'Weekly memory grooming', skill: 'memory-groom', cadence: 'weekly', day: 'Mon', time: '08:00' }],
|
|
2012
|
+
skills: [{ name: 'Weekly skill discovery', skill: 'skill-discover', cadence: 'weekly', day: 'Wed', time: '09:00' }],
|
|
2013
|
+
reports: [
|
|
2014
|
+
{ name: 'Daily report draft', skill: 'report-daily', cadence: 'daily', time: '17:30' },
|
|
2015
|
+
{ name: 'Weekly report draft', skill: 'report-weekly', cadence: 'weekly', day: 'Fri', time: '16:00' },
|
|
2016
|
+
{ name: 'Bi-weekly report draft', skill: 'report-biweekly', cadence: 'bi-weekly', day: 'Fri', time: '16:00' },
|
|
2017
|
+
{ name: 'Monthly report draft', skill: 'report-monthly', cadence: 'monthly', dayOfMonth: 1, time: '08:00' },
|
|
2018
|
+
],
|
|
2019
|
+
};
|
|
1802
2020
|
function readSchedules() {
|
|
1803
2021
|
try { const a = JSON.parse(fs.readFileSync(schedFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; }
|
|
1804
2022
|
}
|
|
@@ -1822,7 +2040,7 @@ function fireSchedule(sched) {
|
|
|
1822
2040
|
const w = workspaces.get(sched.ws) || homeWorkspace();
|
|
1823
2041
|
const root = w ? w.root : SELF_ROOT;
|
|
1824
2042
|
// One corpus load probing both keys — taskById would re-read every .skill file per probe.
|
|
1825
|
-
const tasks = loadTasks();
|
|
2043
|
+
const tasks = loadTasks(root);
|
|
1826
2044
|
const byId = (id) => tasks.find((t) => t.id === id || t.skill === id) || null;
|
|
1827
2045
|
const task = byId(sched.task) || byId(sched.skill);
|
|
1828
2046
|
// AGENT task — fire as a real PTY session running its skill (async; the skill files its own
|
|
@@ -1836,16 +2054,21 @@ function fireSchedule(sched) {
|
|
|
1836
2054
|
// Fixing only the SCAFFOLD fixes only skills that do not exist yet, which is no help to the
|
|
1837
2055
|
// eleven that do. `needs.json` is resolved per workspace, so this cannot be a one-off rewrite
|
|
1838
2056
|
// of the files either — the right answer for a given fire depends on which repo it fires in.
|
|
1839
|
-
|
|
2057
|
+
let prompt = task.prompt ? skillStore.retargetFiling(task.prompt, filingContract(w)) : null;
|
|
2058
|
+
// R11 — a schedule may carry the PM's own request ("a special report for the board meeting on …"). It is
|
|
2059
|
+
// appended AFTER the skill body so the skill's "if they already said, do not ask again" rule applies.
|
|
2060
|
+
if (prompt && sched.args) prompt += `\n\nThe PM's request for this run, given when it was scheduled (do not ask again): ${String(sched.args).replace(/\s+/g, ' ').trim()}`;
|
|
1840
2061
|
// The schedule's recorded profile travels with the process it creates (profiles R8.3). Only an
|
|
1841
2062
|
// AGENT-kind task has a PTY, which is why `profile_applies` is false for the skill-kind rows
|
|
1842
2063
|
// below rather than the control simply being hidden.
|
|
1843
2064
|
const s = spawnSession({ name: sched.name, agent: task.agent || 'claude', ws: sched.ws,
|
|
1844
2065
|
origin: 'schedule:' + sched.id, initialInput: prompt ? prompt + '\r' : null,
|
|
1845
|
-
profile: sched.profile == null ? null : sched.profile
|
|
2066
|
+
profile: sched.profile == null ? null : sched.profile,
|
|
2067
|
+
// D75 — the schedule's tier, then the skill's, then the policy's route for the skill: the harness picks the model for the work.
|
|
2068
|
+
modelAsk: { scheduleTier: sched.tier || null, skillTier: task.tier || null, skill: task.skill } });
|
|
1846
2069
|
const list = readSchedules(); const cur = list.find((x) => x.id === sched.id);
|
|
1847
2070
|
const started = s.state !== 'exited';
|
|
1848
|
-
if (cur) { cur.lastRun = nowISO(); cur.lastStatus = started ? 'started' : 'fail'; cur.nextRun = scheduleLogic.nextRun(cur); writeSchedules(list); }
|
|
2071
|
+
if (cur) { cur.lastRun = nowISO(); cur.lastStatus = started ? 'started' : 'fail'; cur.nextRun = scheduleLogic.nextRun(cur); if (cur.cadence === 'once') cur.enabled = false; writeSchedules(list); }
|
|
1849
2072
|
// A run that started WITHOUT the profile it recorded is reported as such rather than as a
|
|
1850
2073
|
// clean start: the session is real, the environment is not the one the schedule declared.
|
|
1851
2074
|
return { ok: started, kind: 'agent', sessionId: s.id,
|
|
@@ -1884,6 +2107,12 @@ function fireSchedule(sched) {
|
|
|
1884
2107
|
function checkDueSchedules() {
|
|
1885
2108
|
let fired = 0;
|
|
1886
2109
|
for (const s of readSchedules()) { try { if (scheduleLogic.isDue(s)) { fireSchedule(s); fired++; } } catch {} }
|
|
2110
|
+
// R11 — a one-off whose moment passed while nothing was running is marked missed and off, never caught up.
|
|
2111
|
+
try {
|
|
2112
|
+
const list = readSchedules(); let changed = false;
|
|
2113
|
+
for (const s of list) if (scheduleLogic.isMissed(s)) { s.enabled = false; s.lastStatus = 'missed'; s.nextRun = null; changed = true; }
|
|
2114
|
+
if (changed) writeSchedules(list);
|
|
2115
|
+
} catch {}
|
|
1887
2116
|
return fired;
|
|
1888
2117
|
}
|
|
1889
2118
|
// the tick — the persistent server checks on an interval; unref'd so it never blocks exit
|
|
@@ -1906,6 +2135,9 @@ function persist(s) {
|
|
|
1906
2135
|
// fact: a record persisted before profiles existed restores as "not recorded", and inventing a
|
|
1907
2136
|
// key for the past would be a lie about it.
|
|
1908
2137
|
if ('profile' in s) meta.profile = s.profile == null ? null : s.profile;
|
|
2138
|
+
// D75 — what model was asked for and what was APPLIED. Same `in`-guard as the profile: a record from before
|
|
2139
|
+
// routing existed restores as 'not recorded', and writing a key for the past would be a lie about it.
|
|
2140
|
+
if ('model' in s) { meta.model = s.model; meta.modelAsk = s.modelAsk || null; }
|
|
1909
2141
|
// WHAT THIS SESSION IS FOR, and where that came from. Same `in`-guard as `profile` above and for
|
|
1910
2142
|
// the same reason: every session running before this field existed has no attribution, and
|
|
1911
2143
|
// writing a key for it would turn "we were never told" into "it belongs to nothing". Both keys
|
|
@@ -2164,6 +2396,7 @@ function sessionList() {
|
|
|
2164
2396
|
const hx = transcriptHarness(s);
|
|
2165
2397
|
return {
|
|
2166
2398
|
id: s.id, ws: s.ws, name: s.name, agent: s.agent, state: s.state, created: s.created,
|
|
2399
|
+
model: 'model' in s ? s.model : null, model_line: modelLib.line('model' in s ? s.model : null),
|
|
2167
2400
|
cwd: path.relative(s.root, s.cwd) || '.', note: s.note || '', origin: s.origin || null,
|
|
2168
2401
|
// Derived presentation state, JOINED from both sources: the cadence clock above and the
|
|
2169
2402
|
// transcript's answer to whose turn it is (lib/session-state). working | quiet | exited |
|
|
@@ -2610,11 +2843,13 @@ function resolveProfileForSpawn(id) {
|
|
|
2610
2843
|
: `profile "${id}" is not among the descriptors in ${read.dir} — PMOS will not substitute another` };
|
|
2611
2844
|
}
|
|
2612
2845
|
|
|
2613
|
-
function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, initiative }) {
|
|
2846
|
+
function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, initiative, modelAsk }) {
|
|
2614
2847
|
const id = nextSessionId();
|
|
2615
2848
|
const w = getWs(ws);
|
|
2616
2849
|
const root = w ? w.root : SELF_ROOT;
|
|
2617
2850
|
const isShell = agent === 'shell';
|
|
2851
|
+
// D72 — the session reads the memory views once, at its start; rebuild them from the ledger now.
|
|
2852
|
+
try { memoryLib.renderFor(root); } catch { /* memory is never a reason a session fails to start */ }
|
|
2618
2853
|
// a scheduled run tags the session with its origin, and feeds the skill invocation as first input
|
|
2619
2854
|
const tag = origin || null;
|
|
2620
2855
|
const feed = (s) => { if (initialInput && s && s.pty) { try { setTimeout(() => { try { s.pty.write(initialInput); } catch {} }, 700); } catch {} } };
|
|
@@ -2632,6 +2867,25 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
2632
2867
|
const base = { id, ws: w ? w.id : wsId(SELF_ROOT), root, name: (name && String(name).trim()) || agent, agent, origin: tag,
|
|
2633
2868
|
profile: pres.id, profileRefusal: pres.ok ? null : pres.error };
|
|
2634
2869
|
|
|
2870
|
+
/* WHICH MODEL (D75). Resolved once, here, with the profile, so the record can say only what was applied.
|
|
2871
|
+
* Three states are kept apart — not asked, applied, and "this binary has no model control" — and a choice that
|
|
2872
|
+
* cannot be honoured (a pinned review route being lowered, a value that is not a plain token, an unreadable
|
|
2873
|
+
* policy) refuses BEFORE anything starts, as an unreadable profile does. */
|
|
2874
|
+
const mres = modelLib.resolveForSpawn(root, agent, modelAsk);
|
|
2875
|
+
if (!mres.ok) {
|
|
2876
|
+
base.model = { state: 'refused', tier: null, model: null, effort: null, decidedBy: null, why: mres.error };
|
|
2877
|
+
return exitedSession(base, workdir, `model choice refused — ${mres.error}`);
|
|
2878
|
+
}
|
|
2879
|
+
base.model = { state: mres.state, tier: mres.tier || null, model: mres.model || null, effort: mres.effort || null,
|
|
2880
|
+
decidedBy: mres.decidedBy || null, why: mres.why || null };
|
|
2881
|
+
base.modelAsk = modelAsk || null;
|
|
2882
|
+
const launchArgs = mres.state === 'applied' ? mres.args : [];
|
|
2883
|
+
if (mres.tier) {
|
|
2884
|
+
const mc = modelLib.core();
|
|
2885
|
+
if (mc.ok) mc.core.logDecision(root, { kind: 'spawn', session: id, agent, tier: mres.tier, model: mres.model, effort: mres.effort,
|
|
2886
|
+
decidedBy: mres.decidedBy, state: mres.state, skill: (modelAsk && modelAsk.skill) || null, by: 'workspace' });
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2635
2889
|
/* WHAT THIS SESSION IS FOR (workspace-session-attribution R1).
|
|
2636
2890
|
*
|
|
2637
2891
|
* A PMOS session has never recorded which initiative it is working on — not here, not on the wire,
|
|
@@ -2665,7 +2919,8 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
2665
2919
|
sessions.set(id, s);
|
|
2666
2920
|
// the OVERLAY, not the merged env — the merged env is for the direct-PTY path below, which
|
|
2667
2921
|
// really does spawn the process itself. See createTmuxSession for why the two differ.
|
|
2668
|
-
|
|
2922
|
+
// The launch args are plain tokens by construction (pmos-model's strict pattern), so joining them is safe.
|
|
2923
|
+
if (!createTmuxSession(tname, launchArgs.length ? [cmd, ...launchArgs].join(' ') : cmd, workdir, 80, 24, profileOverlay(pres.profile))) {
|
|
2669
2924
|
const why = `could not start a tmux session for '${cmd}'. Run \`npm run doctor\` in workspace/.`;
|
|
2670
2925
|
s.state = 'exited'; s.tmux = null; s.note = why;
|
|
2671
2926
|
s.buf = `\x1b[38;5;203m✗ ${why}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`;
|
|
@@ -2673,6 +2928,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
2673
2928
|
}
|
|
2674
2929
|
announceStateHealth(s); // the unmigrated notice comes FIRST — it explains an empty skills list
|
|
2675
2930
|
announceTree(s); // before attach, so the advisory is the first line of the pane's record
|
|
2931
|
+
announceModel(s);
|
|
2676
2932
|
attachPty(s);
|
|
2677
2933
|
feed(s);
|
|
2678
2934
|
persist(s); pushSessions(); pushWorkspaces(); return s;
|
|
@@ -2683,7 +2939,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
2683
2939
|
let p, usedCmd = cmd;
|
|
2684
2940
|
try {
|
|
2685
2941
|
if (isShell) { const r = spawnPty(cmd, workdir, penv); p = r.pty; usedCmd = r.cmd; }
|
|
2686
|
-
else p = pty.spawn(cmd,
|
|
2942
|
+
else p = pty.spawn(cmd, launchArgs, { name: 'xterm-256color', cols: 80, rows: 24, cwd: workdir, env: penv });
|
|
2687
2943
|
} catch (e) {
|
|
2688
2944
|
return exitedSession(base, workdir, spawnFailureAdvice(e, cmd, isShell));
|
|
2689
2945
|
}
|
|
@@ -2693,6 +2949,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
2693
2949
|
sessions.set(id, s);
|
|
2694
2950
|
announceStateHealth(s);
|
|
2695
2951
|
announceTree(s); // registered first: a session's co-tenancy question includes itself in the map
|
|
2952
|
+
announceModel(s);
|
|
2696
2953
|
p.onData((d) => {
|
|
2697
2954
|
appendBuf(s, d);
|
|
2698
2955
|
for (const c of s.clients) if (c.readyState === 1) c.send(JSON.stringify({ t: 'data', id, data: d }));
|
|
@@ -2727,7 +2984,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
2727
2984
|
// A FAILED git operation in a real repo must never spawn a session on the wrong branch (rubric
|
|
2728
2985
|
// hard floor): we return {ok:false} and spawn nothing. Kept as its own function so the
|
|
2729
2986
|
// git-then-maybe-spawn contract is unit-testable without a WS client.
|
|
2730
|
-
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative }) {
|
|
2987
|
+
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative, modelAsk }) {
|
|
2731
2988
|
const w = getWs(ws);
|
|
2732
2989
|
if (!w) return { ok: false, error: 'no workspace' };
|
|
2733
2990
|
// The profile is checked BEFORE any git operation. A composer that picked a profile PMOS cannot
|
|
@@ -2735,6 +2992,9 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
|
|
|
2735
2992
|
// and a session that claims a profile it never got is the quietest failure this feature has.
|
|
2736
2993
|
const pres = resolveProfileForSpawn(profile);
|
|
2737
2994
|
if (!pres.ok) return { ok: false, error: pres.error };
|
|
2995
|
+
// The model choice is checked on the same terms (D75): a refused choice starts nothing, and leaves no branch moved.
|
|
2996
|
+
const mchk = modelLib.resolveForSpawn(w.root, agent || 'shell', modelAsk);
|
|
2997
|
+
if (!mchk.ok) return { ok: false, error: `model choice refused — ${mchk.error}` };
|
|
2738
2998
|
let targetWs = w.id;
|
|
2739
2999
|
let notice = null;
|
|
2740
3000
|
// Isolation exists to protect OTHER sessions. Alone in the working TREE there are none to
|
|
@@ -2771,7 +3031,7 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
|
|
|
2771
3031
|
broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
|
|
2772
3032
|
}
|
|
2773
3033
|
}
|
|
2774
|
-
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative });
|
|
3034
|
+
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative, modelAsk });
|
|
2775
3035
|
// The co-tenancy fact, on the ordinary path too. Note where this sits: OUTSIDE the `if (branch)`
|
|
2776
3036
|
// block above. The isolate default already computes that other sessions would be disturbed — and
|
|
2777
3037
|
// then the whole git block is skipped when no branch was picked, which is the common path and the
|
|
@@ -3435,6 +3695,143 @@ function budgetRegistryFor(root) {
|
|
|
3435
3695
|
// are one product with one board, and counting them separately is what put the same initiative in
|
|
3436
3696
|
// the queue three times. boardCache.board() ONLY (§6.8): consoleState.workflow() is ~30s and
|
|
3437
3697
|
// synchronous, and on a request path it freezes every PTY socket for the whole half minute.
|
|
3698
|
+
/* ── overhaul-r4 — the portfolio, across every registered repository ───────────────────────────
|
|
3699
|
+
* One entry per REPOSITORY (repo_key), never per worktree. Boards come from boardCache only — the
|
|
3700
|
+
* synchronous derive freezes every PTY socket for ~30 s (board-cache.js) — so every repository
|
|
3701
|
+
* carries its own as_of / stale / pending, and one that cannot be read is listed WITH its error,
|
|
3702
|
+
* never dropped and never shown as empty. Grouping comes from the spine (domain › objective › KR, or
|
|
3703
|
+
* a health budget), which is a cheap read of the records. */
|
|
3704
|
+
function repoLeads() {
|
|
3705
|
+
const groups = new Map();
|
|
3706
|
+
for (const t of wsList()) {
|
|
3707
|
+
const key = t.repo_key || t.root;
|
|
3708
|
+
if (!groups.has(key)) groups.set(key, { key, name: t.label || t.repo_name || t.name, trees: [] });
|
|
3709
|
+
groups.get(key).trees.push(t);
|
|
3710
|
+
}
|
|
3711
|
+
return [...groups.values()].map((g) => {
|
|
3712
|
+
const lead = g.trees.find((t) => path.resolve(t.root) === path.resolve(g.key)) || g.trees[0];
|
|
3713
|
+
const label = (g.trees.find((t) => t.label) || {}).label;
|
|
3714
|
+
return { key: g.key, name: label || g.name, ws: lead.id, root: (workspaces.get(lead.id) || lead).root, ids: new Set(g.trees.map((t) => t.id)) };
|
|
3715
|
+
});
|
|
3716
|
+
}
|
|
3717
|
+
/** The hub (D-3): the registered repository whose root holds portfolio.yaml. Null when there is none. */
|
|
3718
|
+
function portfolioHub(leads) {
|
|
3719
|
+
const hub = (leads || repoLeads()).find((r) => isDir(r.root) && fs.existsSync(path.join(r.root, 'portfolio.yaml')));
|
|
3720
|
+
if (!hub) return null;
|
|
3721
|
+
// `repos:` then indented `<repository name>: <domain>` lines — the only shape read, stated in the PRD
|
|
3722
|
+
const map = {};
|
|
3723
|
+
let inRepos = false;
|
|
3724
|
+
for (const line of fs.readFileSync(path.join(hub.root, 'portfolio.yaml'), 'utf8').split('\n')) {
|
|
3725
|
+
if (/^repos\s*:\s*$/.test(line)) { inRepos = true; continue; }
|
|
3726
|
+
if (/^\S/.test(line)) inRepos = false;
|
|
3727
|
+
const m = inRepos && /^\s+["']?([^:"']+?)["']?\s*:\s*["']?([A-Za-z0-9][\w-]*)["']?\s*$/.exec(line);
|
|
3728
|
+
if (m) map[m[1].trim()] = m[2];
|
|
3729
|
+
}
|
|
3730
|
+
return { name: hub.name, ws: hub.ws, root: hub.root, domains: map };
|
|
3731
|
+
}
|
|
3732
|
+
function portfolio() {
|
|
3733
|
+
const leads = repoLeads();
|
|
3734
|
+
const hub = portfolioHub(leads);
|
|
3735
|
+
const sessions = sessionList();
|
|
3736
|
+
const repos = [];
|
|
3737
|
+
const rows = [];
|
|
3738
|
+
for (const r of leads) {
|
|
3739
|
+
const board = boardCache.board(r.root);
|
|
3740
|
+
let spine;
|
|
3741
|
+
try { spine = surfaceSpine(r.root); }
|
|
3742
|
+
catch (e) { spine = { unreadable: 'the records could not be read — ' + String((e && e.message) || 'error').slice(0, 120) }; }
|
|
3743
|
+
const repoDomain = hub ? hub.domains[r.name] || null : null;
|
|
3744
|
+
const entry = { name: r.name, ws: r.ws, domain: repoDomain, as_of: board.as_of || null, stale: !!board.stale,
|
|
3745
|
+
pending: !!board.pending, error: board.ok === false ? board.error : (spine.unreadable || null), count: 0 };
|
|
3746
|
+
repos.push(entry);
|
|
3747
|
+
if (spine.unreadable) continue;
|
|
3748
|
+
const group = new Map(); // initiative id -> { group, domain }
|
|
3749
|
+
for (const o of spine.objectives || []) {
|
|
3750
|
+
for (const k of o.krs || []) for (const i of k.initiatives || []) group.set(i.id, { group: `${o.name || o.key} › ${k.name || k.key}`, kind: 'kr', rec: i });
|
|
3751
|
+
for (const i of o.direct || []) group.set(i.id, { group: o.name || o.key, kind: 'objective', rec: i });
|
|
3752
|
+
}
|
|
3753
|
+
for (const b of spine.budgets || []) for (const i of b.initiatives || []) group.set(i.id, { group: b.id, kind: 'health', rec: i });
|
|
3754
|
+
for (const d of spine.domains || []) for (const i of d.initiatives || []) group.set(i.id, { group: `${d.id} (no OKR)`, kind: 'domain', domain: d.name, rec: i });
|
|
3755
|
+
for (const i of spine.unanchored || []) group.set(i.id, { group: 'no resolvable anchor', kind: 'unanchored', rec: i });
|
|
3756
|
+
const byId = new Map(((board && board.rows) || []).filter((x) => x && x.id).map((x) => [String(x.id), x]));
|
|
3757
|
+
for (const [id, g] of group) {
|
|
3758
|
+
const b = byId.get(id);
|
|
3759
|
+
rows.push({
|
|
3760
|
+
repo: r.name, ws: r.ws, id, title: (b && b.title) || g.rec.title || id, lane: (b && b.lane) || g.rec.lane || null,
|
|
3761
|
+
stage: b ? b.stage : null, accepted: b ? !!b.accepted : null, next: b && b.next ? b.next.action || b.next.why || null : null,
|
|
3762
|
+
blocked: b ? (b.blockers || []).length > 0 : null,
|
|
3763
|
+
group: g.group, group_kind: g.kind, domain: g.domain || repoDomain,
|
|
3764
|
+
sessions: sessions.filter((x) => r.ids.has(x.ws) && x.initiative === id && x.state !== 'exited').length,
|
|
3765
|
+
});
|
|
3766
|
+
entry.count++;
|
|
3767
|
+
}
|
|
3768
|
+
}
|
|
3769
|
+
return { ok: true, as_of: nowISO(), hub: hub ? { name: hub.name, ws: hub.ws } : null, repos, rows };
|
|
3770
|
+
}
|
|
3771
|
+
|
|
3772
|
+
/* overhaul-r4 C — a report from the portfolio aggregate, written into the HUB on a PM press only. It
|
|
3773
|
+
* creates a new file (never overwrites; a numeric suffix is added) and is NOT logged as a PM edit of
|
|
3774
|
+
* an existing document — that is /api/doc/save's meaning, and a generated report is not one. */
|
|
3775
|
+
function portfolioReport(kind, q) {
|
|
3776
|
+
const pf = portfolio();
|
|
3777
|
+
const hubInfo = pf.hub && workspaces.get(pf.hub.ws);
|
|
3778
|
+
if (!hubInfo) return { ok: false, reason: 'no-hub', error: 'no hub repository — add a portfolio.yaml to your PM repository and register it (D-3); a report is never written to a guessed place' };
|
|
3779
|
+
if (!['weekly', 'okr', 'query'].includes(kind)) return { ok: false, error: 'kind must be weekly, okr or query' };
|
|
3780
|
+
const needle = String(q || '').trim().toLowerCase();
|
|
3781
|
+
if (kind === 'query' && !needle) return { ok: false, error: 'a query report needs a q' };
|
|
3782
|
+
let rows = pf.rows;
|
|
3783
|
+
if (kind === 'query') rows = rows.filter((x) => [x.id, x.title, x.group, x.repo, x.domain].some((v) => v && String(v).toLowerCase().includes(needle)));
|
|
3784
|
+
const day = nowISO().slice(0, 10);
|
|
3785
|
+
const titles = { weekly: 'Weekly portfolio report', okr: 'OKR and domain progress', query: `Portfolio query — "${String(q).trim()}"` };
|
|
3786
|
+
const lines = [];
|
|
3787
|
+
// OKF v0.2 (D73): one key per line, nothing indented — the frontmatter readers stop at the first indented line.
|
|
3788
|
+
lines.push('---', 'type: Reference', `title: "${titles[kind].replace(/"/g, "'")} — ${day}"`,
|
|
3789
|
+
`description: "Portfolio aggregate across the registered repositories, written on the PM's request."`,
|
|
3790
|
+
`generated: {by: process:pmos-workspace, at: "${nowISO()}"}`, 'status: stable', '---', '');
|
|
3791
|
+
lines.push(`# ${titles[kind]} — ${day}`, '');
|
|
3792
|
+
lines.push(`Generated by the PMOS workspace from the portfolio aggregate at ${pf.as_of}, on the PM's request. It reads every registered repository's derived board; it states what it could not read rather than leaving it out.`, '');
|
|
3793
|
+
const unread = pf.repos.filter((r) => r.error || r.pending);
|
|
3794
|
+
const staleR = pf.repos.filter((r) => r.stale && !r.error);
|
|
3795
|
+
lines.push('## Coverage', '');
|
|
3796
|
+
lines.push('| repository | domain | initiatives | board as of | state |', '|---|---|---:|---|---|');
|
|
3797
|
+
for (const r of pf.repos) lines.push(`| ${r.name} | ${r.domain || '—'} | ${r.count} | ${r.as_of || '—'} | ${r.error ? 'NOT READ — ' + String(r.error).replace(/\|/g, '/').slice(0, 80) : r.pending ? 'board pending — stages not yet derived' : r.stale ? 'stale' : 'fresh'} |`);
|
|
3798
|
+
lines.push('');
|
|
3799
|
+
if (unread.length) lines.push(`**Not fully read:** ${unread.map((r) => r.name).join(', ')}. Their initiatives are missing or shown without a stage.`, '');
|
|
3800
|
+
if (staleR.length) lines.push(`**Stale boards:** ${staleR.map((r) => r.name).join(', ')} (refreshing in the background).`, '');
|
|
3801
|
+
const cell = (v) => String(v == null ? '—' : v).replace(/\|/g, '/').replace(/\n/g, ' ').slice(0, 140);
|
|
3802
|
+
const table = (list) => {
|
|
3803
|
+
const out = ['| initiative | repository | lane | stage | next |', '|---|---|---|---|---|'];
|
|
3804
|
+
for (const x of list) out.push(`| \`${x.id}\` — ${cell(x.title)} | ${x.repo} | ${x.lane || '—'} | ${x.accepted ? 'accepted' : (x.stage || 'not derived')}${x.blocked ? ' ✗' : ''} | ${cell(x.next)} |`);
|
|
3805
|
+
return out;
|
|
3806
|
+
};
|
|
3807
|
+
const groupBy = (list, keyFn) => { const m = new Map(); for (const x of list) { const k = keyFn(x); if (!m.has(k)) m.set(k, []); m.get(k).push(x); } return [...m.entries()].sort((a, b) => a[0].localeCompare(b[0])); };
|
|
3808
|
+
if (kind === 'okr') {
|
|
3809
|
+
for (const [dom, list] of groupBy(rows, (x) => x.domain || '(no domain declared)')) {
|
|
3810
|
+
lines.push(`## Domain: ${dom}`, '');
|
|
3811
|
+
for (const [grp, l2] of groupBy(list, (x) => x.group)) {
|
|
3812
|
+
const done = l2.filter((x) => x.accepted).length;
|
|
3813
|
+
lines.push(`### ${grp} — ${done} of ${l2.length} accepted`, '', ...table(l2), '');
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
} else {
|
|
3817
|
+
const waiting = rows.filter((x) => x.blocked && !x.accepted);
|
|
3818
|
+
lines.push('## Needs you', '');
|
|
3819
|
+
if (waiting.length) lines.push(...table(waiting), ''); else lines.push('Nothing derived as blocked across the repositories that were read.', '');
|
|
3820
|
+
for (const [dom, list] of groupBy(rows, (x) => x.domain || '(no domain declared)')) {
|
|
3821
|
+
lines.push(`## Domain: ${dom}`, '');
|
|
3822
|
+
for (const [repo, l2] of groupBy(list, (x) => x.repo)) lines.push(`### ${repo}`, '', ...table(l2), '');
|
|
3823
|
+
}
|
|
3824
|
+
if (!rows.length) lines.push('No initiative matched.', '');
|
|
3825
|
+
}
|
|
3826
|
+
const slug = kind === 'query' ? '-' + needle.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) : '';
|
|
3827
|
+
const dir = path.join(hubInfo.root, 'reports');
|
|
3828
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3829
|
+
let rel = `reports/${day}-${kind}${slug}.md`;
|
|
3830
|
+
for (let n = 2; fs.existsSync(path.join(hubInfo.root, rel)); n++) rel = `reports/${day}-${kind}${slug}-${n}.md`;
|
|
3831
|
+
fs.writeFileSync(path.join(hubInfo.root, rel), lines.join('\n') + '\n', { flag: 'wx' });
|
|
3832
|
+
return { ok: true, ws: hubInfo.id, path: rel, rows: rows.length, unread: unread.map((r) => r.name) };
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3438
3835
|
function focusProducts() {
|
|
3439
3836
|
const trees = wsList();
|
|
3440
3837
|
const groups = new Map(); // repo_key -> { id, name, trees[] }
|
|
@@ -3544,7 +3941,8 @@ function buildConsole(name, w, root, params) {
|
|
|
3544
3941
|
if (s.unreadable) return { ok: false, error: s.unreadable };
|
|
3545
3942
|
const placed = s.objectives.reduce((n, o) =>
|
|
3546
3943
|
n + o.krs.reduce((m, k) => m + k.initiatives.length, 0) + (o.direct ? o.direct.length : 0), 0)
|
|
3547
|
-
+ s.budgets.reduce((n, b) => n + b.initiatives.length, 0)
|
|
3944
|
+
+ s.budgets.reduce((n, b) => n + b.initiatives.length, 0)
|
|
3945
|
+
+ (s.domains || []).reduce((n, d) => n + d.initiatives.length, 0); // D67 domain groups
|
|
3548
3946
|
// `empty` is a SUCCESSFUL read of genuinely nothing — a repo with no initiative records at all.
|
|
3549
3947
|
// It is a supported thing to point the workspace at, so it renders calm, not red.
|
|
3550
3948
|
return { ok: true, empty: placed === 0 && s.unanchored.length === 0, ...s };
|
|
@@ -3866,10 +4264,31 @@ async function handleRequest(req, res) {
|
|
|
3866
4264
|
return json(res, { ok: true, workspace: { ...w, home: HOME_ENABLED && w.root === SELF_ROOT, shape: wsShape(w.root) } });
|
|
3867
4265
|
}
|
|
3868
4266
|
if (p === '/api/workspaces/forget' && req.method === 'POST') {
|
|
4267
|
+
// `repository: true` removes the repository's whole family of rows (overhaul-r1 R3.2); without it,
|
|
4268
|
+
// one row, as before. A refusal names its cause — unknown / home / sessions (R3.3).
|
|
4269
|
+
let body = {};
|
|
4270
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); } catch {}
|
|
4271
|
+
if (body.repository) {
|
|
4272
|
+
const out = forgetRepository(body.id);
|
|
4273
|
+
return json(res, out, out.ok ? 200 : 409);
|
|
4274
|
+
}
|
|
4275
|
+
const blocker = forgetBlocker(body.id);
|
|
4276
|
+
if (blocker) return json(res, { ok: false, ...blocker }, 409);
|
|
4277
|
+
forgetWorkspace(body.id);
|
|
4278
|
+
return json(res, { ok: true, removed: [body.id] });
|
|
4279
|
+
}
|
|
4280
|
+
if (p === '/api/workspaces/rename' && req.method === 'POST') {
|
|
4281
|
+
// A display name only (overhaul-r1 R3.1): the id is a hash of the root and does not move.
|
|
3869
4282
|
let body = {};
|
|
3870
4283
|
try { body = JSON.parse((await readBody(req)) || '{}'); } catch {}
|
|
3871
|
-
const
|
|
3872
|
-
|
|
4284
|
+
const w = workspaces.get(body.id);
|
|
4285
|
+
const name = String(body.name || '').trim();
|
|
4286
|
+
if (!w) return json(res, { ok: false, reason: 'unknown', error: 'unknown repository' }, 404);
|
|
4287
|
+
if (!name) return json(res, { ok: false, error: 'a name is required' }, 400);
|
|
4288
|
+
// `label` is the PM's name for the REPOSITORY. The client names a product after its folder
|
|
4289
|
+
// (`repo_name`), so changing `name` alone would never show; `label` wins over both.
|
|
4290
|
+
w.label = name; saveWorkspaces(); pushWorkspaces();
|
|
4291
|
+
return json(res, { ok: true, workspace: { id: w.id, name, label: name } });
|
|
3873
4292
|
}
|
|
3874
4293
|
if (p === '/api/git/worktrees/remove' && req.method === 'POST') {
|
|
3875
4294
|
// Reclaim a worktree's folder. `dir` comes from a client, so it is checked against git's own
|
|
@@ -4120,11 +4539,103 @@ async function handleRequest(req, res) {
|
|
|
4120
4539
|
// ── scheduled tasks ──
|
|
4121
4540
|
// The picker's source: file-driven skills. `kind` is carried for internal/back-compat consumers,
|
|
4122
4541
|
// but the New Skill flow + the scheduled lens never render it — one concept, no user-facing kind.
|
|
4542
|
+
if (p === '/api/skills/candidates' && req.method === 'GET') {
|
|
4543
|
+
// D76 — the directory of discovered and generated skills for one repository, with what the PM has done about each.
|
|
4544
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
4545
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
4546
|
+
const c = skillsReg.core();
|
|
4547
|
+
if (!c.ok) return json(res, { ok: false, error: c.error });
|
|
4548
|
+
const { rows, torn } = c.core.candidates(w.root);
|
|
4549
|
+
const reads = skillsReg.readsFor(SKILL_APPROVALS, w.root);
|
|
4550
|
+
const approved = new Set(skillsReg.approvedFor(SKILL_APPROVALS, w.root).map((a) => `${a.id}@${a.sha}`));
|
|
4551
|
+
const res2 = c.core.resolve(w.root, skillsReg.approvedFor(SKILL_APPROVALS, w.root));
|
|
4552
|
+
const live = new Set(res2.skills.map((x) => `${x.id}@${x.sha}`));
|
|
4553
|
+
const others = new Set(skillStore.loadSkills([{ dir: userSkillsDir(), source: 'user' }, { dir: BUILTIN_SKILLS_DIR, source: 'builtin' }]).map((x) => x.id));
|
|
4554
|
+
return json(res, { ok: true, ws: w.id, torn, blocked: res2.blocked,
|
|
4555
|
+
candidates: rows.map((r) => ({ ...r, read: reads.has(`${r.id}@${r.sha}`), approved: approved.has(`${r.id}@${r.sha}`), running: live.has(`${r.id}@${r.sha}`),
|
|
4556
|
+
needs_license_ack: c.core.licenseNeedsAck(r.license), collides: others.has(r.id) })) });
|
|
4557
|
+
}
|
|
4558
|
+
if (p === '/api/skills/decide' && req.method === 'POST') {
|
|
4559
|
+
// D76 — the PM's press. `read` returns the full text and records that it was opened; `approve` is refused without
|
|
4560
|
+
// it, and for an unknown or non-permissive licence without an acknowledgement; `reject` and `retire` go to the
|
|
4561
|
+
// repository's registry. Approval is recorded HERE, locally, never in the repository.
|
|
4562
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
4563
|
+
const w = getWs(body.ws);
|
|
4564
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
4565
|
+
const c = skillsReg.core();
|
|
4566
|
+
if (!c.ok) return json(res, { ok: false, error: c.error });
|
|
4567
|
+
const row = c.core.candidates(w.root).rows.find((r) => r.id === String(body.id || '') && r.sha.startsWith(String(body.sha || 'x')));
|
|
4568
|
+
if (!row) return json(res, { ok: false, error: 'no such candidate' }, 404);
|
|
4569
|
+
const key = skillsReg.rootKey(w.root); const at = nowISO();
|
|
4570
|
+
const by = 'human:wawan'; const eid = 'a-' + Date.now().toString(36) + '-' + crypto.randomBytes(3).toString('hex');
|
|
4571
|
+
try {
|
|
4572
|
+
if (body.op === 'read') {
|
|
4573
|
+
const t = c.core.textOf(w.root, row);
|
|
4574
|
+
if (!t.ok) return json(res, { ok: false, error: t.error, changed: !!t.changed }, 409);
|
|
4575
|
+
skillsReg.appendApproval(SKILL_APPROVALS, { eid, at, by, op: 'read', root: key, id: row.id, sha: row.sha });
|
|
4576
|
+
return json(res, { ok: true, text: t.text, id: row.id, sha12: row.sha12, scan: row.scan, ignored_keys: row.ignored_keys });
|
|
4577
|
+
}
|
|
4578
|
+
if (body.op === 'approve') {
|
|
4579
|
+
if (row.state !== 'quarantined') return json(res, { ok: false, error: `that candidate is ${row.state} — it cannot be approved` }, 409);
|
|
4580
|
+
if (!skillsReg.readsFor(SKILL_APPROVALS, w.root).has(`${row.id}@${row.sha}`)) return json(res, { ok: false, error: 'open the full text first (read it) — approval needs the exact text to have been opened' }, 409);
|
|
4581
|
+
if (c.core.licenseNeedsAck(row.license) && body.ack_license !== true) return json(res, { ok: false, error: `its licence is "${row.license}": say you have read it and accept it (acknowledge the licence) to approve`, needs_license_ack: true }, 409);
|
|
4582
|
+
const t = c.core.textOf(w.root, row);
|
|
4583
|
+
if (!t.ok) return json(res, { ok: false, error: t.error }, 409);
|
|
4584
|
+
if (loadTasks(null).some((x) => x.id === row.id)) return json(res, { ok: false, error: `a skill named "${row.id}" already exists here and always wins over a discovered one — rename the candidate or retire it`, collides: true }, 409);
|
|
4585
|
+
skillsReg.appendApproval(SKILL_APPROVALS, { eid, at, by, op: 'approve', root: key, id: row.id, sha: row.sha, ack_license: body.ack_license === true, ignored_keys: row.ignored_keys || [] });
|
|
4586
|
+
return json(res, { ok: true, approved: row.id, sha12: row.sha12, note: `runs as an agent skill on claude; ${(row.ignored_keys || []).length ? 'its own ' + row.ignored_keys.join(', ') + ' were ignored' : 'it set no harness keys'}; approval is local to this machine` });
|
|
4587
|
+
}
|
|
4588
|
+
if (body.op === 'reject' || body.op === 'retire') {
|
|
4589
|
+
const r = c.core.decide(w.root, body.op, { id: row.id, sha: row.sha12, reason: body.reason, by });
|
|
4590
|
+
return json(res, r, r.ok ? 200 : 400);
|
|
4591
|
+
}
|
|
4592
|
+
} catch (e) { return json(res, { ok: false, error: 'could not record that — ' + String((e && e.message) || e).slice(0, 120) }, 500); }
|
|
4593
|
+
return json(res, { ok: false, error: 'op must be read, approve, reject or retire' }, 400);
|
|
4594
|
+
}
|
|
4123
4595
|
if (p === '/api/tasks') return json(res, { tasks: loadTasks().map((t) => ({ id: t.id, label: t.label, skill: t.skill, kind: t.kind })) });
|
|
4124
4596
|
// ── skills (workspace-skill-authoring): the ONE-concept surface + the New Skill flow ──
|
|
4597
|
+
// One skill's full text, for the detail panel. Only the PM's own skills (source `user`) are
|
|
4598
|
+
// editable: a builtin ships with the app and a discovered one is pinned to the text the PM approved,
|
|
4599
|
+
// so editing either in place would change what was reviewed. Both can be copied to an editable one.
|
|
4600
|
+
if (p === '/api/skills/read' && req.method === 'GET') {
|
|
4601
|
+
const sw = getWs(url.searchParams.get('ws'));
|
|
4602
|
+
const rec = loadSkillRecords(sw ? sw.root : null).find((x) => x.id === url.searchParams.get('id'));
|
|
4603
|
+
if (!rec) return json(res, { ok: false, error: 'no skill with that name' }, 404);
|
|
4604
|
+
let text; try { text = fs.readFileSync(rec.file, 'utf8'); } catch { return json(res, { ok: false, error: 'the skill file could not be read' }, 500); }
|
|
4605
|
+
return json(res, { ok: true, id: rec.id, label: rec.label || rec.id, source: rec.source, editable: rec.source === 'user',
|
|
4606
|
+
file: path.basename(rec.file), description: rec.description || '', output: rec.output || [], text });
|
|
4607
|
+
}
|
|
4608
|
+
if (p === '/api/skills/save' && req.method === 'POST') {
|
|
4609
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
4610
|
+
const rec = loadSkillRecords(null).find((x) => x.id === body.id && x.source === 'user');
|
|
4611
|
+
if (!rec) return json(res, { ok: false, error: 'only your own skills can be edited here' }, 403);
|
|
4612
|
+
const text = typeof body.text === 'string' ? body.text : '';
|
|
4613
|
+
if (!text.trim() || text.length > 200000) return json(res, { ok: false, error: 'the skill text is empty or too long' }, 400);
|
|
4614
|
+
const parsed = skillStore.parseSkill(text);
|
|
4615
|
+
if (parsed.name !== rec.id) return json(res, { ok: false, error: `the name: line must stay "${rec.id}" — rename by copying instead` }, 400);
|
|
4616
|
+
if (!String(parsed.description || '').trim()) return json(res, { ok: false, error: 'the skill needs a description: line' }, 400);
|
|
4617
|
+
try { const tmp = rec.file + '.tmp'; fs.writeFileSync(tmp, text.endsWith('\n') ? text : text + '\n'); fs.renameSync(tmp, rec.file); }
|
|
4618
|
+
catch { return json(res, { ok: false, error: 'the skill file could not be written' }, 500); }
|
|
4619
|
+
return json(res, { ok: true, id: rec.id });
|
|
4620
|
+
}
|
|
4621
|
+
if (p === '/api/skills/copy' && req.method === 'POST') {
|
|
4622
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
4623
|
+
const sw = getWs(body.ws);
|
|
4624
|
+
const all = loadSkillRecords(sw ? sw.root : null);
|
|
4625
|
+
const rec = all.find((x) => x.id === body.id);
|
|
4626
|
+
if (!rec) return json(res, { ok: false, error: 'no skill with that name' }, 404);
|
|
4627
|
+
let id = rec.id + '-copy';
|
|
4628
|
+
for (let n = 2; all.some((x) => x.id === id); n++) id = rec.id + '-copy-' + n;
|
|
4629
|
+
let text; try { text = fs.readFileSync(rec.file, 'utf8'); } catch { return json(res, { ok: false, error: 'the skill file could not be read' }, 500); }
|
|
4630
|
+
text = text.replace(/^name:.*$/m, 'name: ' + id).replace(/^label:\s*"?(.*?)"?\s*$/m, (_, l) => 'label: "' + l.replace(/"/g, "'") + ' (my copy)"');
|
|
4631
|
+
try { const d = userSkillsDir(); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, id + '.skill'), text, { flag: 'wx' }); }
|
|
4632
|
+
catch { return json(res, { ok: false, error: 'the copy could not be written' }, 500); }
|
|
4633
|
+
return json(res, { ok: true, id });
|
|
4634
|
+
}
|
|
4125
4635
|
// GET lists every skill with NO kind field — from the user's view there is just a skill.
|
|
4126
4636
|
if (p === '/api/skills' && req.method === 'GET') {
|
|
4127
|
-
const
|
|
4637
|
+
const sw = getWs(url.searchParams.get('ws'));
|
|
4638
|
+
const skills = loadTasks(sw ? sw.root : null).map((t) => ({ id: t.id, label: t.label, skill: t.skill,
|
|
4128
4639
|
description: t.description || '', output: t.output || [], source: t.source || 'builtin' }));
|
|
4129
4640
|
// NEVER a bare list. Under an unmigrated conflict this list is either short by every skill the
|
|
4130
4641
|
// PM authored or is being read from the old directory, and in both cases the payload has to say
|
|
@@ -4225,7 +4736,9 @@ async function handleRequest(req, res) {
|
|
|
4225
4736
|
return json(res, { ok: true, prefs });
|
|
4226
4737
|
}
|
|
4227
4738
|
if (p === '/api/schedules' && req.method === 'GET') {
|
|
4228
|
-
|
|
4739
|
+
// `all=1` (overhaul-r4 D1): every repository's schedules — the drawer lists `scheduled` under "all
|
|
4740
|
+
// repositories", so filtering to the home workspace there would hide the PM's other schedules
|
|
4741
|
+
const wsFilter = url.searchParams.get('all') ? null : getWs(url.searchParams.get('ws'));
|
|
4229
4742
|
const id = wsFilter ? wsFilter.id : null;
|
|
4230
4743
|
// One skills-corpus read per request, not one per schedule row — taskById re-reads every
|
|
4231
4744
|
// .skill file, and the PM's forty-five schedules made that forty-five reads per GET.
|
|
@@ -4246,7 +4759,9 @@ async function handleRequest(req, res) {
|
|
|
4246
4759
|
}
|
|
4247
4760
|
if (p === '/api/schedules' && req.method === 'POST') {
|
|
4248
4761
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
4249
|
-
|
|
4762
|
+
// D76 — an APPROVED discovered skill of this repository is a skill too, so it can be scheduled; a quarantined one is not.
|
|
4763
|
+
const swsch = getWs(body.ws);
|
|
4764
|
+
if (!taskById(body.task || body.skill, swsch ? swsch.root : null)) return json(res, { ok: false, error: 'that task has no backing skill' }, 400);
|
|
4250
4765
|
const norm = scheduleLogic.normalizeSchedule(body);
|
|
4251
4766
|
if (!norm.ok) return json(res, { ok: false, error: norm.error }, 400);
|
|
4252
4767
|
const w = getWs(body.ws);
|
|
@@ -4259,12 +4774,35 @@ async function handleRequest(req, res) {
|
|
|
4259
4774
|
const list = readSchedules(); list.unshift(sched); writeSchedules(list);
|
|
4260
4775
|
return json(res, { ok: true, schedule: sched });
|
|
4261
4776
|
}
|
|
4777
|
+
if (p === '/api/schedules/seed' && req.method === 'POST') {
|
|
4778
|
+
// D71 — one press creates the recommended schedules for a repository. Idempotent: a schedule for
|
|
4779
|
+
// the same skill in the same repository is never created twice. Never reached by anything but the
|
|
4780
|
+
// PM's press; the tick then fires them like any other schedule.
|
|
4781
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
4782
|
+
const w = getWs(body.ws);
|
|
4783
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
4784
|
+
const set = SEED_SETS[body.set];
|
|
4785
|
+
if (!set) return json(res, { ok: false, error: `unknown set '${body.set}' — one of ${Object.keys(SEED_SETS).join(', ')}` }, 400);
|
|
4786
|
+
const list = readSchedules(); const made = []; const kept = [];
|
|
4787
|
+
for (const def of set) {
|
|
4788
|
+
if (!taskById(def.skill, w.root)) { kept.push({ skill: def.skill, why: 'the skill is not installed' }); continue; }
|
|
4789
|
+
if (list.some((x) => x.ws === w.id && x.skill === def.skill)) { kept.push({ skill: def.skill, why: 'already scheduled' }); continue; }
|
|
4790
|
+
const norm = scheduleLogic.normalizeSchedule({ ...def, task: def.skill });
|
|
4791
|
+
if (!norm.ok) { kept.push({ skill: def.skill, why: norm.error }); continue; }
|
|
4792
|
+
const sched = { id: 'sc-' + Date.now().toString(36) + made.length, ws: w.id, ...norm.schedule, profile: null, lastRun: null, lastStatus: null };
|
|
4793
|
+
sched.nextRun = scheduleLogic.nextRun(sched);
|
|
4794
|
+
list.unshift(sched); made.push(sched);
|
|
4795
|
+
}
|
|
4796
|
+
if (made.length) writeSchedules(list);
|
|
4797
|
+
return json(res, { ok: true, created: made.map((x) => ({ id: x.id, name: x.name, skill: x.skill, cadence: x.cadence, time: x.time, nextRun: x.nextRun })), kept });
|
|
4798
|
+
}
|
|
4262
4799
|
if (p === '/api/schedules/update' && req.method === 'POST') {
|
|
4263
4800
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
4264
4801
|
const list = readSchedules(); const s = list.find((x) => x.id === body.id);
|
|
4265
4802
|
if (!s) return json(res, { ok: false, error: 'no such schedule' }, 404);
|
|
4266
4803
|
if (typeof body.enabled === 'boolean') s.enabled = body.enabled;
|
|
4267
|
-
for (const k of ['name', 'cadence', 'day', 'dayOfMonth', 'time', 'task', 'skill']) if (body[k] != null) s[k] = body[k];
|
|
4804
|
+
for (const k of ['name', 'cadence', 'day', 'dayOfMonth', 'time', 'task', 'skill', 'date', 'args']) if (body[k] != null) s[k] = body[k];
|
|
4805
|
+
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); }
|
|
4268
4806
|
// `profile` is settable to null (clear it) as well as to an id, so it is checked for PRESENCE
|
|
4269
4807
|
// of the key rather than for a non-null value — the other fields above cannot be cleared.
|
|
4270
4808
|
if ('profile' in body) {
|
|
@@ -4293,7 +4831,30 @@ async function handleRequest(req, res) {
|
|
|
4293
4831
|
let md; try { md = fs.readFileSync(path.join(DELIV_DIR, id + '.md'), 'utf8'); } catch { res.writeHead(404); return res.end('not found'); }
|
|
4294
4832
|
res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' }); return res.end(md);
|
|
4295
4833
|
}
|
|
4296
|
-
if (p === '/api/
|
|
4834
|
+
if (p === '/api/session/model' && req.method === 'POST') {
|
|
4835
|
+
// D75 — switch the model of a RUNNING Claude session, on the PM's press only. It types the harness's own
|
|
4836
|
+
// `/model <model>` command into the pane; effort is not changed by it. Never automatic, never for a binary
|
|
4837
|
+
// PMOS has not checked, and a pinned review route cannot be lowered here either.
|
|
4838
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
4839
|
+
const s = sessions.get(String(body.id || ''));
|
|
4840
|
+
if (!s) return json(res, { ok: false, error: 'no such session' }, 404);
|
|
4841
|
+
if (!s.pty || s.state === 'exited') return json(res, { ok: false, error: 'that session is not running — there is nothing to switch' }, 400);
|
|
4842
|
+
if (s.agent !== 'claude') return json(res, { ok: false, error: `'${s.agent}' has no model command PMOS has checked — nothing was typed` }, 400);
|
|
4843
|
+
const r = modelLib.resolveForSpawn(s.root, s.agent, { explicit: { tier: body.tier, model: body.model }, skill: s.modelAsk && s.modelAsk.skill });
|
|
4844
|
+
if (!r.ok) return json(res, { ok: false, error: r.error }, 400);
|
|
4845
|
+
if (!r.model) return json(res, { ok: false, error: 'say a tier (fast, balanced, deep) or a model' }, 400);
|
|
4846
|
+
try { s.pty.write(`/model ${r.model}\r`); }
|
|
4847
|
+
catch (e) { return json(res, { ok: false, error: 'could not write to the session — ' + String((e && e.message) || e).slice(0, 100) }, 500); }
|
|
4848
|
+
const at = nowISO();
|
|
4849
|
+
s.model = { ...(s.model || {}), state: 'applied', switched: { model: r.model, tier: r.tier, at } };
|
|
4850
|
+
persist(s); pushSessions();
|
|
4851
|
+
const mc = modelLib.core();
|
|
4852
|
+
if (mc.ok) mc.core.logDecision(s.root, { kind: 'switch', session: s.id, agent: s.agent, tier: r.tier, model: r.model, by: 'human:wawan' });
|
|
4853
|
+
return json(res, { ok: true, model: r.model, tier: r.tier, note: 'typed /model into the pane; effort is unchanged' });
|
|
4854
|
+
}
|
|
4855
|
+
// `version` lets the launcher notice a server still running OLD code after an upgrade: files on disk
|
|
4856
|
+
// advance under a live process, so the UI would otherwise call routes that process never learned.
|
|
4857
|
+
if (p === '/api/sessions') return json(res, { sessions: sessionList(), version: APP_VERSION });
|
|
4297
4858
|
if (p === '/api/git/branches') {
|
|
4298
4859
|
// Read-only branch awareness for a workspace. A non-repo returns {ok:false} (never a fake
|
|
4299
4860
|
// branch). Fixed git args, no caller input in the read path (R1).
|
|
@@ -4403,14 +4964,20 @@ async function handleRequest(req, res) {
|
|
|
4403
4964
|
if (!w) { res.writeHead(404); return res.end('no workspace'); }
|
|
4404
4965
|
const full = path.resolve(w.root, url.searchParams.get('path') || '');
|
|
4405
4966
|
if (!insideRoot(w.root, full)) { res.writeHead(404); return res.end('outside workspace'); }
|
|
4406
|
-
const mime = preview.
|
|
4967
|
+
const mime = preview.assetMime(path.basename(full));
|
|
4407
4968
|
if (!mime) { res.writeHead(404); return res.end('not a servable asset'); }
|
|
4408
4969
|
return fs.readFile(full, (err, buf) => {
|
|
4409
4970
|
if (err) { res.writeHead(404); return res.end('not found'); }
|
|
4410
4971
|
res.writeHead(200, {
|
|
4411
4972
|
'Content-Type': mime,
|
|
4412
4973
|
'X-Content-Type-Options': 'nosniff',
|
|
4413
|
-
|
|
4974
|
+
// An image keeps the script-blocking sandbox. A PDF cannot: the CSP `sandbox` directive
|
|
4975
|
+
// disables the browser's built-in PDF viewer, which is the whole point of serving it
|
|
4976
|
+
// (overhaul-r2 D1). It still gets `default-src 'none'` — nothing it references is fetched —
|
|
4977
|
+
// and it is only ever a file inside this workspace's root, on 127.0.0.1.
|
|
4978
|
+
'Content-Security-Policy': mime === 'application/pdf'
|
|
4979
|
+
? "default-src 'none'; style-src 'unsafe-inline'"
|
|
4980
|
+
: "default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
|
4414
4981
|
});
|
|
4415
4982
|
res.end(buf);
|
|
4416
4983
|
});
|
|
@@ -4630,6 +5197,15 @@ async function handleRequest(req, res) {
|
|
|
4630
5197
|
// share no field (`{session, line, after}` vs `{path, kind, match, openable}`), the scrollback
|
|
4631
5198
|
// search scrubs secrets out of text an agent typed, and the file search reports a walk bound.
|
|
4632
5199
|
// Collapsing them into one payload would mean one of the two lying about what it looked at.
|
|
5200
|
+
if (p === '/api/portfolio' && req.method === 'GET') return json(res, portfolio());
|
|
5201
|
+
if (p === '/api/reports' && req.method === 'POST') {
|
|
5202
|
+
let body = {};
|
|
5203
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5204
|
+
let out;
|
|
5205
|
+
try { out = portfolioReport(String(body.kind || ''), body.q); }
|
|
5206
|
+
catch (e) { out = { ok: false, error: 'the report could not be written — ' + String((e && e.message) || 'error').slice(0, 140) }; }
|
|
5207
|
+
return json(res, out, out.ok ? 200 : (out.reason === 'no-hub' ? 409 : 400));
|
|
5208
|
+
}
|
|
4633
5209
|
if (p === '/api/search/files') {
|
|
4634
5210
|
// `search everything…`, finally over everything: every file under THIS workspace's root, by
|
|
4635
5211
|
// path and by content. There is no path parameter — the query is a needle, not a location — so
|
|
@@ -4641,7 +5217,19 @@ async function handleRequest(req, res) {
|
|
|
4641
5217
|
return json(res, { ok: false, error: 'no workspace to search', q: url.searchParams.get('q') || '',
|
|
4642
5218
|
hits: [], truncated: false, scanned: 0, content_scanned: 0, limit: SEARCH_LIMIT_DEFAULT });
|
|
4643
5219
|
}
|
|
4644
|
-
|
|
5220
|
+
// Every line of work of the repository, not only the tree the id names (overhaul-r1 R1): a file
|
|
5221
|
+
// that exists only on a linked worktree's branch was unfindable from its product. `tree` narrows.
|
|
5222
|
+
// overhaul-r4 B1 — `scope=all`: every registered repository's family of trees, each hit tagged
|
|
5223
|
+
// with its repository, truncation reported PER repository (a capped repo never reads as complete)
|
|
5224
|
+
if (url.searchParams.get('scope') === 'all') {
|
|
5225
|
+
const per = repoLeads().map((r) => ({ r, out: searchFamily(workspaces.get(r.ws) || w, url.searchParams.get('q'), url.searchParams.get('limit')) }));
|
|
5226
|
+
const hits = per.flatMap(({ r, out }) => (out.hits || []).map((h) => ({ ...h, repo: r.name })));
|
|
5227
|
+
return json(res, { ok: true, scope: 'all', q: url.searchParams.get('q') || '', hits,
|
|
5228
|
+
truncated: per.some(({ out }) => out.truncated), content_truncated: per.some(({ out }) => out.content_truncated),
|
|
5229
|
+
scanned: per.reduce((n, { out }) => n + (out.scanned || 0), 0), content_scanned: per.reduce((n, { out }) => n + (out.content_scanned || 0), 0),
|
|
5230
|
+
repos: per.map(({ r, out }) => ({ repo: r.name, ws: r.ws, truncated: !!out.truncated, scanned: out.scanned || 0, hits: (out.hits || []).length })) });
|
|
5231
|
+
}
|
|
5232
|
+
return json(res, { ws: w.id, ...searchFamily(w, url.searchParams.get('q'), url.searchParams.get('limit'), url.searchParams.get('tree')) });
|
|
4645
5233
|
}
|
|
4646
5234
|
if (p === '/api/search') {
|
|
4647
5235
|
// Episodic recall over this workspace's own past sessions. Localhost only, like everything
|
|
@@ -4738,6 +5326,104 @@ async function handleRequest(req, res) {
|
|
|
4738
5326
|
pm_label_recorded: rec.pm_label === 'pass' || rec.pm_label === 'fail',
|
|
4739
5327
|
});
|
|
4740
5328
|
}
|
|
5329
|
+
if (p === '/api/memory' && req.method === 'GET') {
|
|
5330
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
5331
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
5332
|
+
const c = memoryLib.core();
|
|
5333
|
+
if (!c.ok) return json(res, { ok: false, error: c.error });
|
|
5334
|
+
const cur = c.core.load(w.root);
|
|
5335
|
+
return json(res, { ok: true, ws: w.id, name: w.name, entries: cur.state.entries, pending: cur.state.pending, blocked: cur.state.blocked,
|
|
5336
|
+
usage: cur.usage, torn: cur.torn, config: { write_approval: !!cur.cfg.write_approval }, wired: memoryLib.wired(w.root),
|
|
5337
|
+
has_state_dir: !!c.core.stateDir(w.root, false) });
|
|
5338
|
+
}
|
|
5339
|
+
if (p === '/api/memory' && req.method === 'POST') {
|
|
5340
|
+
// D72 — a PM write. The actor is the PM, so it is never staged, and only the PM can confirm, approve or reject.
|
|
5341
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5342
|
+
const w = getWs(body.ws);
|
|
5343
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
5344
|
+
const c = memoryLib.core();
|
|
5345
|
+
if (!c.ok) return json(res, { ok: false, error: c.error });
|
|
5346
|
+
const out = c.core.write(w.root, { op: body.op, target: body.target, text: body.text, old: body.old, ref: body.ref,
|
|
5347
|
+
confidence: body.confidence, source: body.source, stale_after: body.stale_after, by: 'human:wawan' });
|
|
5348
|
+
return json(res, out, out.ok ? 200 : 400);
|
|
5349
|
+
}
|
|
5350
|
+
if (p === '/api/memory/config' && req.method === 'POST') {
|
|
5351
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5352
|
+
const w = getWs(body.ws);
|
|
5353
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
5354
|
+
const c = memoryLib.core();
|
|
5355
|
+
if (!c.ok) return json(res, { ok: false, error: c.error });
|
|
5356
|
+
const dir = c.core.memDir(w.root, true);
|
|
5357
|
+
const cfg = c.core.readConfig(dir);
|
|
5358
|
+
if (typeof body.write_approval === 'boolean') cfg.write_approval = body.write_approval;
|
|
5359
|
+
try { fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ memory_chars: cfg.memory_chars, user_chars: cfg.user_chars, write_approval: cfg.write_approval }, null, 2) + '\n'); }
|
|
5360
|
+
catch (e) { return json(res, { ok: false, error: 'could not save the setting — ' + String((e && e.message) || e).slice(0, 120) }, 500); }
|
|
5361
|
+
return json(res, { ok: true, config: { write_approval: !!cfg.write_approval } });
|
|
5362
|
+
}
|
|
5363
|
+
if (p === '/api/memory/wire' && req.method === 'POST') {
|
|
5364
|
+
// D72 — one press adds a marked block to the repository's CLAUDE.md so sessions import the snapshot.
|
|
5365
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5366
|
+
const w = getWs(body.ws);
|
|
5367
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
5368
|
+
return json(res, memoryLib.wire(w.root));
|
|
5369
|
+
}
|
|
5370
|
+
if (p === '/api/sync/status') {
|
|
5371
|
+
// Local only — no network. Safe to poll; it is what tells the PM whether a press is needed.
|
|
5372
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
5373
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
5374
|
+
return json(res, { ws: w.id, name: w.name, ...(await syncLib.status(w.root)) });
|
|
5375
|
+
}
|
|
5376
|
+
if (p === '/api/sync' && req.method === 'POST') {
|
|
5377
|
+
// D70 — the ONE place the workspace runs fetch / integrate / commit / push. Only the PM's press
|
|
5378
|
+
// reaches it; nothing schedules or chains it, and it is off every other action's request path.
|
|
5379
|
+
let body = {};
|
|
5380
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
5381
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5382
|
+
const w = getWs(body.ws);
|
|
5383
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
5384
|
+
if (syncing.has(w.root)) return json(res, { ok: false, error: 'a sync is already running for this repository' }, 409);
|
|
5385
|
+
syncing.add(w.root);
|
|
5386
|
+
try {
|
|
5387
|
+
const out = await syncLib.sync(w.root, { label: os.hostname() });
|
|
5388
|
+
boardCache.invalidate(w.root);
|
|
5389
|
+
return json(res, { ws: w.id, name: w.name, ...out });
|
|
5390
|
+
} finally { syncing.delete(w.root); }
|
|
5391
|
+
}
|
|
5392
|
+
if (p === '/api/lifecycle' && req.method === 'POST') {
|
|
5393
|
+
// D70 — close, hold, cancel or reopen an initiative. Only reached by an explicit PM press. It
|
|
5394
|
+
// appends ONE line to lifecycle.jsonl and never writes an acceptance.
|
|
5395
|
+
let body = {};
|
|
5396
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
5397
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5398
|
+
const id = String(body.initiative_id || '');
|
|
5399
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(id)) return json(res, { ok: false, error: 'initiative_id is required' }, 400);
|
|
5400
|
+
if (body.state !== 'active' && !STOPPED_STATES.includes(body.state)) {
|
|
5401
|
+
return json(res, { ok: false, error: `state must be one of ${STOPPED_STATES.concat('active').join(', ')}` }, 400);
|
|
5402
|
+
}
|
|
5403
|
+
const w = getWs(body.ws);
|
|
5404
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
5405
|
+
if (!initiativeIndex(w.root).initiatives.some((g) => g.id === id)) {
|
|
5406
|
+
return json(res, { ok: false, error: `no initiative '${id}' in ${w.name}` }, 404);
|
|
5407
|
+
}
|
|
5408
|
+
const rec = { initiative_id: id, state: body.state, reason: String(body.reason || '').slice(0, 500), pm: 'wawan', at: nowISO() };
|
|
5409
|
+
// The ledger the ENGINE reads for this repository's layout (scripts/workflow-state.js#layout): a repository
|
|
5410
|
+
// with planning/initiatives/ keeps its ledgers in planning/runs/, a kit in pmos/state/. Writing anywhere else
|
|
5411
|
+
// (R6 wrote the workspace's runtime state for PMOS-self) shows on the board but never clears the gate.
|
|
5412
|
+
let d = null;
|
|
5413
|
+
if (isDir(path.join(w.root, 'planning', 'initiatives'))) d = path.join(w.root, 'planning', 'runs');
|
|
5414
|
+
else if (isDir(path.join(w.root, 'pmos', 'state', 'initiatives'))) d = path.join(w.root, 'pmos', 'state');
|
|
5415
|
+
else d = stateDirFor(w, { create: true });
|
|
5416
|
+
if (!d) return json(res, { ok: false, error: 'this repository has no state directory to record in — nothing was written' }, 500);
|
|
5417
|
+
try { fs.mkdirSync(d, { recursive: true }); } catch { /* the guard reports it */ }
|
|
5418
|
+
const sink = path.join(d, 'lifecycle.jsonl');
|
|
5419
|
+
try { createSinkGuard({ sink, what: 'lifecycle' }).appendLine(sink, rec); }
|
|
5420
|
+
catch (e) {
|
|
5421
|
+
warnWriteFail('lifecycle.jsonl', e);
|
|
5422
|
+
return json(res, { ok: false, error: 'could not record the change — nothing was saved', sink }, 500);
|
|
5423
|
+
}
|
|
5424
|
+
boardCache.invalidate(w.root);
|
|
5425
|
+
return json(res, { ok: true, recorded: rec, sink });
|
|
5426
|
+
}
|
|
4741
5427
|
if (p === '/api/doc/save' && req.method === 'POST') {
|
|
4742
5428
|
// The editor's save (workspace-doc-editor R3/R4). The ONLY write path on this surface, and it is
|
|
4743
5429
|
// reached solely by the PM pressing save on a document they were editing. Nothing here consults
|
|
@@ -4768,7 +5454,12 @@ async function handleRequest(req, res) {
|
|
|
4768
5454
|
return json(res, { ok: false, error: saved.reason, detail: saved.detail || null }, code);
|
|
4769
5455
|
}
|
|
4770
5456
|
|
|
4771
|
-
|
|
5457
|
+
// R11 (D74) — a document that declares `generated: {by, at}` on one line says who last produced it. A save
|
|
5458
|
+
// from here is the PM's own hand, so the line is rewritten to say so. Only that one line, only in the leading
|
|
5459
|
+
// block, and only when it is already there: nothing is added to a document that never had one. The stamped text
|
|
5460
|
+
// goes back to the editor, which takes it as its new base, so the next save is not read as stale.
|
|
5461
|
+
const stamped = stampEdit(saved.text, nowISO());
|
|
5462
|
+
try { fs.writeFileSync(full, stamped); }
|
|
4772
5463
|
catch (e) {
|
|
4773
5464
|
warnWriteFail('document save (' + body.path + ')', e);
|
|
4774
5465
|
return json(res, { ok: false, error: 'could not write the document — nothing was changed' }, 500);
|
|
@@ -4792,7 +5483,7 @@ async function handleRequest(req, res) {
|
|
|
4792
5483
|
}
|
|
4793
5484
|
// The document IS saved at this point. A failed log is reported honestly rather than as success,
|
|
4794
5485
|
// but it must not read as "your edit was lost".
|
|
4795
|
-
return json(res, { ok: true, saved: body.path, logged, change: { added: rec.added, removed: rec.removed, at_line: rec.at_line },
|
|
5486
|
+
return json(res, { ok: true, saved: body.path, logged, ...(stamped !== saved.text ? { text: stamped, stamped: true } : {}), change: { added: rec.added, removed: rec.removed, at_line: rec.at_line },
|
|
4796
5487
|
warning: logged ? undefined : 'the document was saved but the change could not be logged' });
|
|
4797
5488
|
}
|
|
4798
5489
|
if (p === '/api/align/candidates') {
|
|
@@ -5018,7 +5709,8 @@ function writeChangeContext(dir) {
|
|
|
5018
5709
|
const out = [
|
|
5019
5710
|
'---', 'type: Reference',
|
|
5020
5711
|
'title: "Change context — what the PM has been editing here, and why"',
|
|
5021
|
-
'
|
|
5712
|
+
'description: "A digest of the PM\'s recent edits and their reasons, regenerated on every save. For agents writing documents here."',
|
|
5713
|
+
`generated: {by: process:pmos-workspace, at: "${nowISO()}"}`, 'status: stable', '---', '',
|
|
5022
5714
|
'# Change context',
|
|
5023
5715
|
'',
|
|
5024
5716
|
'**Read this before writing documents in this repository.** It is generated from the PM\'s own',
|
|
@@ -5153,10 +5845,12 @@ wss.on('connection', (ws) => {
|
|
|
5153
5845
|
// `initiative` is what the client has always held and never sent: `+ session` is clicked on
|
|
5154
5846
|
// an initiative row, the tab carries its `slug`, and the spawn message named three other
|
|
5155
5847
|
// fields. An absent or unusable value leaves the session unattributed — never a refused spawn.
|
|
5156
|
-
const r = spawnWithBranch({ name: m.name, agent: m.agent, cwd: m.cwd, ws: m.ws, branch: m.branch, createBranch: m.createBranch, isolate: m.isolate, profile: m.profile, initiative: m.initiative
|
|
5848
|
+
const r = spawnWithBranch({ name: m.name, agent: m.agent, cwd: m.cwd, ws: m.ws, branch: m.branch, createBranch: m.createBranch, isolate: m.isolate, profile: m.profile, initiative: m.initiative,
|
|
5849
|
+
// D75 — what the composer chose (a tier, or a model), carried like the profile. Absent = not asked.
|
|
5850
|
+
modelAsk: (m.tier || m.model) ? { explicit: { tier: m.tier, model: m.model, effort: m.effort } } : null });
|
|
5157
5851
|
// A refused profile is its own sentence, not "branch: …" — the composer's error line names
|
|
5158
5852
|
// what PMOS could not read, and nothing was started.
|
|
5159
|
-
if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^profile /.test(r.error || '') ? '' : 'branch: ') + r.error })); return; }
|
|
5853
|
+
if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^(profile|model choice) /.test(r.error || '') ? '' : 'branch: ') + r.error })); return; }
|
|
5160
5854
|
// a non-fatal truth about what just happened (a worktree's path, a branch ignored on a
|
|
5161
5855
|
// non-repo) — said out loud, never swallowed
|
|
5162
5856
|
if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
|
|
@@ -5176,7 +5870,7 @@ wss.on('connection', (ws) => {
|
|
|
5176
5870
|
// server already holds, not re-sent by the client — a client that forgot it would otherwise
|
|
5177
5871
|
// silently restart the pane on a different environment, and the pane would look identical.
|
|
5178
5872
|
const carried = 'profile' in old ? old.profile : null;
|
|
5179
|
-
const s = spawnSession({ name: old.name, agent: old.agent, cwd: path.relative(old.root, old.cwd), ws: old.ws, profile: carried });
|
|
5873
|
+
const s = spawnSession({ name: old.name, agent: old.agent, cwd: path.relative(old.root, old.cwd), ws: old.ws, profile: carried, modelAsk: old.modelAsk || null });
|
|
5180
5874
|
// A descriptor deleted since the original spawn does NOT get substituted. The respawn goes
|
|
5181
5875
|
// ahead with no profile — the pane line already says so — and the reason is said out loud
|
|
5182
5876
|
// rather than left for the PM to notice.
|
|
@@ -5302,4 +5996,4 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
|
|
|
5302
5996
|
// things that matter independently of a rendered page: that no declared field reaches a status,
|
|
5303
5997
|
// that an unreadable history reports rather than degrades, and that a planning-only commit does
|
|
5304
5998
|
// not count as delivery.
|
|
5305
|
-
surfaceInitiatives, deliveryIndex, deriveState, isDeliveryPath };
|
|
5999
|
+
surfaceInitiatives, readLifecycle, deliveryIndex, deriveState, isDeliveryPath };
|