create-pmos 0.37.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-pmos.js +22 -3
- 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/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 +371 -0
- package/workspace/public/pm/modes.js +7 -4
- 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 +690 -40
- 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
|
|
@@ -355,7 +359,7 @@ function loadWorkspaces() {
|
|
|
355
359
|
if (!w || !w.root) continue;
|
|
356
360
|
const root = path.resolve(w.root);
|
|
357
361
|
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() });
|
|
362
|
+
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
363
|
}
|
|
360
364
|
}
|
|
361
365
|
}
|
|
@@ -378,13 +382,49 @@ function registerWorkspace(root, name) {
|
|
|
378
382
|
}
|
|
379
383
|
// Forgetting a workspace never touches the repo — it only stops hosting it here. Blocked
|
|
380
384
|
// while it still owns sessions, so scrollback is never orphaned by a click.
|
|
381
|
-
|
|
385
|
+
/**
|
|
386
|
+
* Why a workspace cannot be forgotten, or null when it can. The three causes used to share one
|
|
387
|
+
* message ("unknown workspace, the home workspace, or it still owns sessions"), which told the PM
|
|
388
|
+
* nothing they could act on (overhaul-r1 R3.3). Sessions are NAMED, live or exited, because an
|
|
389
|
+
* exited session still belongs to the row and would re-register it on restart (~recovery).
|
|
390
|
+
*/
|
|
391
|
+
function forgetBlocker(id) {
|
|
382
392
|
const w = workspaces.get(id);
|
|
383
|
-
if (!w
|
|
384
|
-
if (
|
|
393
|
+
if (!w) return { reason: 'unknown', error: 'unknown repository — it may already have been removed' };
|
|
394
|
+
if (HOME_ENABLED && w.root === SELF_ROOT) return { reason: 'home', error: `“${w.name}” is the home workspace and cannot be removed` };
|
|
395
|
+
const owned = [...sessions.values()].filter((s) => s.ws === id);
|
|
396
|
+
if (owned.length) {
|
|
397
|
+
const names = owned.map((s) => s.name || s.id);
|
|
398
|
+
return { reason: 'sessions', sessions: names,
|
|
399
|
+
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` };
|
|
400
|
+
}
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
function forgetWorkspace(id) {
|
|
404
|
+
if (forgetBlocker(id)) return false;
|
|
385
405
|
workspaces.delete(id); saveWorkspaces(); pushWorkspaces();
|
|
386
406
|
return true;
|
|
387
407
|
}
|
|
408
|
+
/**
|
|
409
|
+
* Remove a REPOSITORY: its row and every linked-worktree row of the same repo (overhaul-r1 R3.2).
|
|
410
|
+
* All-or-nothing — if any row is blocked, nothing is forgotten and every blocker is reported. Never
|
|
411
|
+
* deletes a folder; reclaiming a worktree's folder stays with /api/git/worktrees/remove.
|
|
412
|
+
*/
|
|
413
|
+
function forgetRepository(id) {
|
|
414
|
+
const w = workspaces.get(id);
|
|
415
|
+
if (!w) return { ok: false, ...forgetBlocker(id) };
|
|
416
|
+
const fam = treeFamily(w);
|
|
417
|
+
const blocked = fam.map((x) => ({ x, b: forgetBlocker(x.id) })).filter((y) => y.b);
|
|
418
|
+
if (blocked.length) {
|
|
419
|
+
const sessionsNamed = blocked.flatMap((y) => y.b.sessions || []);
|
|
420
|
+
const first = blocked[0].b;
|
|
421
|
+
return { ok: false, reason: first.reason, sessions: sessionsNamed.length ? sessionsNamed : undefined,
|
|
422
|
+
error: blocked.map((y) => y.b.error).join('; ') };
|
|
423
|
+
}
|
|
424
|
+
for (const x of fam) workspaces.delete(x.id);
|
|
425
|
+
saveWorkspaces(); pushWorkspaces();
|
|
426
|
+
return { ok: true, removed: fam.map((x) => x.id) };
|
|
427
|
+
}
|
|
388
428
|
// The home is SELF_ROOT when enabled; otherwise (installed copy, no launcher env) there is no
|
|
389
429
|
// privileged home, so an unresolved ws id falls back to the first registered repo rather than
|
|
390
430
|
// resolving to the package dir.
|
|
@@ -501,6 +541,11 @@ function treeWriter(tree) {
|
|
|
501
541
|
* definition looking at and which persists in the session record. Every new session, until the two
|
|
502
542
|
* directories are resolved, because a notice that shows once is a notice that is missed once.
|
|
503
543
|
*/
|
|
544
|
+
// D75 — say which model was applied, in the pane's own record, when one was asked for. Silent for 'not asked'.
|
|
545
|
+
function announceModel(s) {
|
|
546
|
+
if (!s.model || s.model.state === 'not-asked') return;
|
|
547
|
+
appendBuf(s, `\x1b[38;5;244mpmos: ${modelLib.line(s.model)}\x1b[0m\r\n`);
|
|
548
|
+
}
|
|
504
549
|
function announceStateHealth(s) {
|
|
505
550
|
const h = stateHealthNow();
|
|
506
551
|
if (h.ok) return;
|
|
@@ -542,7 +587,7 @@ function wsList() {
|
|
|
542
587
|
return [...workspaces.values()].map((w) => {
|
|
543
588
|
const rid = repoIdentity(w.root);
|
|
544
589
|
return {
|
|
545
|
-
id: w.id, name: w.name, root: w.root, home: HOME_ENABLED && w.root === SELF_ROOT,
|
|
590
|
+
id: w.id, name: w.name, label: w.label || null, root: w.root, home: HOME_ENABLED && w.root === SELF_ROOT,
|
|
546
591
|
shape: wsShape(w.root), addedAt: w.addedAt,
|
|
547
592
|
sessions: [...sessions.values()].filter((s) => s.ws === w.id).length,
|
|
548
593
|
// the repository this working tree belongs to, and the branch it is on. `branch_code`
|
|
@@ -832,7 +877,7 @@ const SEARCH_LIMIT_DEFAULT = 40;
|
|
|
832
877
|
const SEARCH_LIMIT_MAX = 200;
|
|
833
878
|
const SEARCH_INDEX_TTL_MS = 4000; // one word of typing costs one walk, not one per keystroke
|
|
834
879
|
const SEARCH_SNIPPET = 160;
|
|
835
|
-
const SEARCH_OPENABLE = new Set(['markdown', 'text', 'html']); // what /api/doc will serve
|
|
880
|
+
const SEARCH_OPENABLE = new Set(['markdown', 'text', 'html', 'pdf', 'sheet']); // what /api/doc (or /api/asset, for pdf) will serve
|
|
836
881
|
const searchIndexCache = new Map(); // root -> { at, files, truncated, scanned }
|
|
837
882
|
|
|
838
883
|
/** Every file under `root`, flat and repo-relative, cached for SEARCH_INDEX_TTL_MS. */
|
|
@@ -949,6 +994,7 @@ function searchWorkspace(root, query, limit) {
|
|
|
949
994
|
* cannot click into an unknown. */
|
|
950
995
|
const st = fs.statSync(path.join(root, rel));
|
|
951
996
|
if (!st.isFile()) return false;
|
|
997
|
+
if (['pdf', 'sheet'].includes(preview.previewKind(rel))) return st.size > 0; // served by /api/asset, never read as text
|
|
952
998
|
/* Past the byte budget we do not READ the file — but "we did not look" is not "the reader
|
|
953
999
|
* refuses it". Returning false there swapped one false label for its opposite: a legitimate
|
|
954
1000
|
* 316 KB markdown doc reported openable:false while /api/doc served it 200 with 324,017 bytes.
|
|
@@ -977,7 +1023,7 @@ function searchWorkspace(root, query, limit) {
|
|
|
977
1023
|
if (overflow) contentCut = true;
|
|
978
1024
|
if (!overflow) {
|
|
979
1025
|
for (const f of idx.files) {
|
|
980
|
-
if (seen.has(f.path) || !SEARCH_OPENABLE.has(f.kind)) continue;
|
|
1026
|
+
if (seen.has(f.path) || !SEARCH_OPENABLE.has(f.kind) || f.kind === 'pdf' || f.kind === 'sheet') continue; // a PDF body is not text
|
|
981
1027
|
if (read >= SEARCH_CONTENT_FILES) { contentCut = true; break; }
|
|
982
1028
|
const full = path.join(root, f.path);
|
|
983
1029
|
let st;
|
|
@@ -1015,6 +1061,86 @@ function searchWorkspace(root, query, limit) {
|
|
|
1015
1061
|
};
|
|
1016
1062
|
}
|
|
1017
1063
|
|
|
1064
|
+
/**
|
|
1065
|
+
* Every registered tree of the repository `w` belongs to — its LINES OF WORK (overhaul-r1 R1).
|
|
1066
|
+
* Derived per call from `repo_key`, the same identity `wsList()` publishes and the client folds
|
|
1067
|
+
* by, so the registry gains no parent pointer and there is still one source of truth. The main
|
|
1068
|
+
* tree (the one whose root IS the repo key) comes first; the rest keep registry order. A folder
|
|
1069
|
+
* with no git is a family of one, which is the truth.
|
|
1070
|
+
*/
|
|
1071
|
+
function treeFamily(w) {
|
|
1072
|
+
if (!w) return [];
|
|
1073
|
+
const key = repoIdentity(w.root).key;
|
|
1074
|
+
const fam = [...workspaces.values()].filter((x) => x.id === w.id || repoIdentity(x.root).key === key);
|
|
1075
|
+
fam.sort((a, b) => (b.root === key) - (a.root === key));
|
|
1076
|
+
return fam;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/** Content identity for the merge — full-body sha1, only within the content budget. A file past
|
|
1080
|
+
* the budget is NOT compared, so it is never merged on a guess: it stays one row per tree. */
|
|
1081
|
+
function contentDigest(root, rel) {
|
|
1082
|
+
try {
|
|
1083
|
+
const full = path.join(root, rel);
|
|
1084
|
+
const st = fs.statSync(full);
|
|
1085
|
+
if (!st.isFile() || st.size > SEARCH_CONTENT_BYTES) return null;
|
|
1086
|
+
return crypto.createHash('sha1').update(fs.readFileSync(full)).digest('hex');
|
|
1087
|
+
} catch { return null; }
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Search every line of work of a repository (overhaul-r1 R1.1–R1.5). Each hit carries the `ws` and
|
|
1092
|
+
* `branch` of the tree it came from, plus `branches` — every tree that holds the same path with
|
|
1093
|
+
* byte-identical content, which is then listed ONCE (PM choice, 2026-10-01). A path that differs
|
|
1094
|
+
* between trees, or exists on only some, is one hit per distinct tree. Walk bounds stay per tree
|
|
1095
|
+
* and are reported per tree in `trees[]`, so a capped walk on one branch cannot read as complete;
|
|
1096
|
+
* the top-level flags are the OR across trees. `only` narrows the search to one tree.
|
|
1097
|
+
*/
|
|
1098
|
+
function searchFamily(w, query, limit, only) {
|
|
1099
|
+
let fam = treeFamily(w);
|
|
1100
|
+
if (only) fam = fam.filter((x) => x.id === only);
|
|
1101
|
+
if (!fam.length) fam = [w];
|
|
1102
|
+
const branchOf = (x) => repoIdentity(x.root).branch || null;
|
|
1103
|
+
const per = fam.map((x) => ({ x, branch: branchOf(x), r: searchWorkspace(x.root, query, limit) }));
|
|
1104
|
+
const first = per[0].r;
|
|
1105
|
+
const cap = first.limit;
|
|
1106
|
+
const groups = new Map(); // path -> [{hit, x, branch, digest}]
|
|
1107
|
+
for (const t of per) {
|
|
1108
|
+
for (const h of t.r.hits || []) {
|
|
1109
|
+
if (!groups.has(h.path)) groups.set(h.path, []);
|
|
1110
|
+
groups.get(h.path).push({ h, x: t.x, branch: t.branch });
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
const hits = [];
|
|
1114
|
+
for (const list of groups.values()) {
|
|
1115
|
+
if (list.length === 1) {
|
|
1116
|
+
const e = list[0];
|
|
1117
|
+
hits.push({ ...e.h, ws: e.x.id, branch: e.branch, branches: [e.branch] });
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1120
|
+
const buckets = new Map(); // digest (or a per-tree token when uncomparable) -> entries
|
|
1121
|
+
for (const e of list) {
|
|
1122
|
+
const d = contentDigest(e.x.root, e.h.path) || ('uncompared:' + e.x.id);
|
|
1123
|
+
if (!buckets.has(d)) buckets.set(d, []);
|
|
1124
|
+
buckets.get(d).push(e);
|
|
1125
|
+
}
|
|
1126
|
+
for (const b of buckets.values()) {
|
|
1127
|
+
const e = b[0];
|
|
1128
|
+
hits.push({ ...e.h, ws: e.x.id, branch: e.branch, branches: b.map((y) => y.branch) });
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
const overflow = hits.length > cap;
|
|
1132
|
+
return {
|
|
1133
|
+
ok: per.every((t) => t.r.ok !== false), q: first.q, limit: cap,
|
|
1134
|
+
hits: hits.slice(0, cap),
|
|
1135
|
+
truncated: overflow || per.some((t) => t.r.truncated),
|
|
1136
|
+
content_truncated: per.some((t) => t.r.content_truncated),
|
|
1137
|
+
scanned: per.reduce((n, t) => n + (t.r.scanned || 0), 0),
|
|
1138
|
+
content_scanned: per.reduce((n, t) => n + (t.r.content_scanned || 0), 0),
|
|
1139
|
+
trees: per.map((t) => ({ ws: t.x.id, branch: t.branch, main: t.x.root === repoIdentity(w.root).key,
|
|
1140
|
+
truncated: !!t.r.truncated, content_truncated: !!t.r.content_truncated, scanned: t.r.scanned || 0 })),
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1018
1144
|
// Serve a TEXTUAL document — markdown (incl. .skill), plain-text/config source, or HTML source —
|
|
1019
1145
|
// per-workspace-guarded. Images are NOT served here (they stream through /api/asset); anything
|
|
1020
1146
|
// non-textual returns null so the reader shows it inert. A NUL byte ⇒ binary masquerading as text
|
|
@@ -1131,8 +1257,23 @@ function parseHead(full) {
|
|
|
1131
1257
|
const clean = (m) => m ? m[1].replace(/\*\*/g, '').split(/\s+[—–-]\s|\(/)[0].trim().slice(0, 48) : null;
|
|
1132
1258
|
const kr = head.match(/^\s*[-*]?\s*parent[_-]?kr\s*:\s*(.+?)\s*$/mi);
|
|
1133
1259
|
const lane = head.match(/^\s*[-*]?\s*lane\s*:\s*(.+?)\s*$/mi);
|
|
1260
|
+
// overhaul-r2 C1 — an artifact may serve SEVERAL initiatives: `initiatives: [a, b]`,
|
|
1261
|
+
// `initiative: a, b`, or a YAML list under `initiatives:`. Each named id lists the artifact; the
|
|
1262
|
+
// first is also its primary owner when no `initiative_id:` is declared.
|
|
1263
|
+
const also = [];
|
|
1264
|
+
const multi = head.match(/^\s*initiatives?\s*:[ \t]*(.*)$/m);
|
|
1265
|
+
if (multi) {
|
|
1266
|
+
const inline = multi[1].replace(/^\[|\]$/g, '').trim();
|
|
1267
|
+
if (inline) inline.split(',').forEach((x) => also.push(x.trim().replace(/^["']|["']$/g, '')));
|
|
1268
|
+
else {
|
|
1269
|
+
const after = head.slice(multi.index + multi[0].length).split('\n').slice(1);
|
|
1270
|
+
for (const ln of after) { const li = /^\s+-\s*["']?([A-Za-z0-9][\w.-]*)["']?\s*$/.exec(ln); if (!li) break; also.push(li[1]); }
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
const alsoIds = [...new Set(also.filter((x) => /^[A-Za-z0-9][\w.-]*$/.test(x)))];
|
|
1134
1274
|
return {
|
|
1135
|
-
initiative: id ? id[1] : null,
|
|
1275
|
+
initiative: id ? id[1] : (alsoIds[0] || null),
|
|
1276
|
+
also: alsoIds,
|
|
1136
1277
|
recordId: rid ? rid[1] : null,
|
|
1137
1278
|
title: h1 ? h1[1].replace(/^(PRD|Eval Rubric|Design Brief)\s*:\s*/i, '').trim() : null,
|
|
1138
1279
|
kr: clean(kr), lane: clean(lane),
|
|
@@ -1195,6 +1336,7 @@ function docMeta(root, full) {
|
|
|
1195
1336
|
mtimeMs: st.mtimeMs, path: rel, name: base, role,
|
|
1196
1337
|
// frontmatter wins; the filename convention is the fallback, never the override
|
|
1197
1338
|
initiative: head.initiative || (role === 'record' ? head.recordId : null) || null,
|
|
1339
|
+
also: role === 'record' ? [] : (head.also || []),
|
|
1198
1340
|
stem, title: head.title || null,
|
|
1199
1341
|
kr: head.kr || null, lane: head.lane || null,
|
|
1200
1342
|
dest: head.dest || null,
|
|
@@ -1288,6 +1430,13 @@ function initiativeIndex(root) {
|
|
|
1288
1430
|
if (!groups.has(k)) groups.set(k, { id: k, title: null, kr: null, lane: null, artifacts: [] });
|
|
1289
1431
|
const g = groups.get(k);
|
|
1290
1432
|
g.artifacts.push(art);
|
|
1433
|
+
// overhaul-r2 C1 — listed under every OTHER initiative it names too (it does not lend them a
|
|
1434
|
+
// title, an anchor or a lane: those stay the record's and its primary owner's)
|
|
1435
|
+
for (const other of m.also || []) {
|
|
1436
|
+
if (other === k) continue;
|
|
1437
|
+
if (!groups.has(other)) groups.set(other, { id: other, title: null, kr: null, lane: null, artifacts: [] });
|
|
1438
|
+
groups.get(other).artifacts.push({ ...art, shared: true });
|
|
1439
|
+
}
|
|
1291
1440
|
if (m.role === 'PRD' && m.title) g.title = m.title; // the PRD names the initiative
|
|
1292
1441
|
else if (!g.title && m.title) g.title = m.title;
|
|
1293
1442
|
// The anchor and lane are the RECORD's to declare (D56/D59 — it is what the gates read); the
|
|
@@ -1314,6 +1463,17 @@ function initiativeIndex(root) {
|
|
|
1314
1463
|
// groups planning/ into initiatives and carries the PMOS knowledge (okf/skills/build-skills,
|
|
1315
1464
|
// wherever they live) + governance. Its guarantee is that it never DROPS a planning document —
|
|
1316
1465
|
// not that it mirrors every file in the repo (the folder lens is for that).
|
|
1466
|
+
// R11 — rewrite a single-line `generated: {by: …, at: …}` in the LEADING frontmatter block to the PM as author.
|
|
1467
|
+
// Returns the text unchanged when there is no such line (never adds one, never touches a nested block).
|
|
1468
|
+
function stampEdit(text, at) {
|
|
1469
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---(?=\r?\n|$)/.exec(text);
|
|
1470
|
+
if (!m) return text;
|
|
1471
|
+
const fm = m[1];
|
|
1472
|
+
const re = /^generated:[ \t]*\{[^\n}]*\}[ \t]*$/m;
|
|
1473
|
+
if (!re.test(fm)) return text;
|
|
1474
|
+
const next = fm.replace(re, `generated: {by: human:wawan, at: "${at}"}`);
|
|
1475
|
+
return next === fm ? text : text.replace(fm, () => next);
|
|
1476
|
+
}
|
|
1317
1477
|
function library(root) {
|
|
1318
1478
|
const shape = wsShape(root);
|
|
1319
1479
|
const roots = libraryTree(root);
|
|
@@ -1323,7 +1483,15 @@ function library(root) {
|
|
|
1323
1483
|
.map((d) => tree(root, d, {}, 0, { n: TREE_MAX_NODES }))
|
|
1324
1484
|
.filter((t) => t.dirs.length || t.docs.length)
|
|
1325
1485
|
: [];
|
|
1326
|
-
|
|
1486
|
+
// D74/R11 — the notes PMOS and its agents write into a repository: requirement notes, report drafts and
|
|
1487
|
+
// definitions, memory views (and the portfolio report at the root). Listed for ANY repository shape, because a
|
|
1488
|
+
// PM repository is not a 'pmos'-shaped bundle and its notes were invisible to this lens.
|
|
1489
|
+
const NOTE_DIRS = ['pmos/state/requirements', 'pmos/state/reports', 'pmos/state/memory',
|
|
1490
|
+
'planning/requirements', 'planning/reports', 'planning/memory', 'reports'];
|
|
1491
|
+
const notes = NOTE_DIRS.map((d) => path.join(root, ...d.split('/'))).filter((d) => isDir(d))
|
|
1492
|
+
.map((d) => tree(root, d, {}, 0, { n: TREE_MAX_NODES }))
|
|
1493
|
+
.filter((t) => t.dirs.length || t.docs.length);
|
|
1494
|
+
return { shape, roots, initiatives, ungrouped, knowledge, notes, governance: looseDocs(root) };
|
|
1327
1495
|
}
|
|
1328
1496
|
|
|
1329
1497
|
// ── On-demand surfaces (D60 / the workspace-ondemand-surfaces PRD). ────────────────────────────
|
|
@@ -1334,7 +1502,7 @@ function library(root) {
|
|
|
1334
1502
|
// client renders it; a surface that can't be produced returns ok:false and the client shows an
|
|
1335
1503
|
// honest can't-produce state. RICE/effort/milestones/progress are NET-NEW state → emitted as null
|
|
1336
1504
|
// ("— not tracked"), never invented (D14/D40 honesty).
|
|
1337
|
-
const PHASE_OF = { planning: 'plan', building: 'deliver', blocked: 'deliver', delivered: 'report', accepted: 'report', unknown: 'plan' };
|
|
1505
|
+
const PHASE_OF = { stopped: 'plan', planning: 'plan', building: 'deliver', blocked: 'deliver', delivered: 'report', accepted: 'report', unknown: 'plan' };
|
|
1338
1506
|
|
|
1339
1507
|
// ── Delivery evidence (board-delivery-truth R-2/R-3; shared reader, workflow-status-sweep R-1). ──
|
|
1340
1508
|
// "Has this shipped?" answered from the trunk's own history rather than from anything anyone typed.
|
|
@@ -1399,6 +1567,7 @@ function deliveryIndex(root, ids) {
|
|
|
1399
1567
|
* one ordering departure, made because the alternative buries a returned initiative.)
|
|
1400
1568
|
*/
|
|
1401
1569
|
function deriveState(ev) {
|
|
1570
|
+
if (ev.stopped && !ev.accepted) return { state: 'stopped', why: `${ev.stopped.state}${ev.stopped.reason ? ` — ${ev.stopped.reason}` : ''} (lifecycle.jsonl, D70)` };
|
|
1402
1571
|
if (ev.accepted) return { state: 'accepted', why: `accepted by the PM${ev.acceptedAt ? ` on ${String(ev.acceptedAt).slice(0, 10)}` : ''}` };
|
|
1403
1572
|
if (ev.returned) return { state: 'blocked', why: 'the PM returned this at the Acceptance Gate' };
|
|
1404
1573
|
if (ev.needs) return { state: 'blocked', why: `${ev.needs} open item${ev.needs > 1 ? 's' : ''} in needs.json` };
|
|
@@ -1439,6 +1608,19 @@ function readLedger(w, basename) {
|
|
|
1439
1608
|
}
|
|
1440
1609
|
return out;
|
|
1441
1610
|
}
|
|
1611
|
+
// D70 — the lifecycle ledger: the latest line for an initiative wins; `active` reopens. The same rule
|
|
1612
|
+
// as scripts/workflow-state.js#readLifecycle (a parity case in workspace/test.js keeps them in step).
|
|
1613
|
+
const syncing = new Set(); // repositories with a sync in flight — one at a time
|
|
1614
|
+
const STOPPED_STATES = ['closed', 'on-hold', 'cancelled'];
|
|
1615
|
+
function readLifecycle(w) {
|
|
1616
|
+
const latest = new Map();
|
|
1617
|
+
for (const r of readLedger(w, 'lifecycle.jsonl')) {
|
|
1618
|
+
if (!r || typeof r.initiative_id !== 'string') continue;
|
|
1619
|
+
if (r.state !== 'active' && !STOPPED_STATES.includes(r.state)) continue;
|
|
1620
|
+
latest.set(r.initiative_id, { state: r.state, reason: r.reason || '', pm: r.pm || '', at: r.at || '' });
|
|
1621
|
+
}
|
|
1622
|
+
return latest;
|
|
1623
|
+
}
|
|
1442
1624
|
function readAcceptances(w) {
|
|
1443
1625
|
const acc = new Map(); // initiative_id -> { action, at }
|
|
1444
1626
|
for (const r of readLedger(w, 'acceptances.jsonl')) {
|
|
@@ -1490,6 +1672,7 @@ function readRunCounts(w) {
|
|
|
1490
1672
|
function surfaceInitiatives(root, w) {
|
|
1491
1673
|
const { initiatives } = initiativeIndex(root);
|
|
1492
1674
|
const acc = readAcceptances(w);
|
|
1675
|
+
const life = readLifecycle(w);
|
|
1493
1676
|
const runs = readRunCounts(w);
|
|
1494
1677
|
const needsRaw = readNeeds(w) || [];
|
|
1495
1678
|
const needsBy = new Map();
|
|
@@ -1502,6 +1685,7 @@ function surfaceInitiatives(root, w) {
|
|
|
1502
1685
|
const { state, why } = deriveState({
|
|
1503
1686
|
// readAcceptances already folds every accepting label (approve/merge/publish) into 'accept'.
|
|
1504
1687
|
accepted: a.action === 'accept', acceptedAt: a.at,
|
|
1688
|
+
stopped: (life.get(g.id) && life.get(g.id).state !== 'active') ? life.get(g.id) : null,
|
|
1505
1689
|
returned: a.action === 'return',
|
|
1506
1690
|
needs: needsBy.get(g.id) || 0,
|
|
1507
1691
|
delivery: del.ok ? 'known' : 'unknown',
|
|
@@ -1515,6 +1699,7 @@ function surfaceInitiatives(root, w) {
|
|
|
1515
1699
|
// the client renders `statusText`, which always names the artifact the state was read from.
|
|
1516
1700
|
stage: null, kr: g.kr || null, lane: g.lane || null,
|
|
1517
1701
|
accepted: state === 'accepted',
|
|
1702
|
+
lifecycle: (life.get(g.id) && life.get(g.id).state !== 'active') ? life.get(g.id) : null,
|
|
1518
1703
|
delivered: state === 'accepted' || state === 'delivered',
|
|
1519
1704
|
delivery_evidence: deliveredBy,
|
|
1520
1705
|
artifacts: g.artifacts.map((a2) => ({ role: a2.role, name: a2.name, path: a2.path, dest: a2.dest || null })),
|
|
@@ -1530,7 +1715,7 @@ function surfaceInitiatives(root, w) {
|
|
|
1530
1715
|
initiatives: items,
|
|
1531
1716
|
pulse: {
|
|
1532
1717
|
total: items.length, planning: by('planning'), building: by('building'),
|
|
1533
|
-
blocked: by('blocked'), delivered: by('delivered'), accepted: by('accepted'),
|
|
1718
|
+
blocked: by('blocked'), delivered: by('delivered'), accepted: by('accepted'), stopped: by('stopped'),
|
|
1534
1719
|
unknown: by('unknown'),
|
|
1535
1720
|
},
|
|
1536
1721
|
// R-3: a failed read is reported, never absorbed. The client renders this RED above the list
|
|
@@ -1664,6 +1849,7 @@ function surfaceSpine(root) {
|
|
|
1664
1849
|
|
|
1665
1850
|
const budgets = new Map(); // health:<id> groups — peers of objectives (D59), not a footnote
|
|
1666
1851
|
const unanchored = []; // R6 — never dropped to make the tree tidy
|
|
1852
|
+
const domains = new Map(); // domain:<id> groups (D67) — work that serves a domain, no OKR
|
|
1667
1853
|
|
|
1668
1854
|
for (const dir of initiativeDirs(root)) {
|
|
1669
1855
|
let files = [];
|
|
@@ -1691,6 +1877,13 @@ function surfaceSpine(root) {
|
|
|
1691
1877
|
budgets.get(key).initiatives.push(rec);
|
|
1692
1878
|
continue;
|
|
1693
1879
|
}
|
|
1880
|
+
// D67 — a domain is a peer group like a health budget: the standing goal it serves, no OKR
|
|
1881
|
+
if (r.kind === 'domain' && r.status === 'resolved') {
|
|
1882
|
+
const key = `domain:${r.domain}`;
|
|
1883
|
+
if (!domains.has(key)) domains.set(key, { id: key, name: r.domain, goal: r.domain_goal || null, initiatives: [] });
|
|
1884
|
+
domains.get(key).initiatives.push(rec);
|
|
1885
|
+
continue;
|
|
1886
|
+
}
|
|
1694
1887
|
if (r.kind === 'okr' && r.status === 'resolved') {
|
|
1695
1888
|
const kr = r.kr ? krByKey.get(`${A.objectiveId(r.objective)}::${A.krId(r.kr)}`) : null;
|
|
1696
1889
|
if (kr) { kr.initiatives.push(rec); continue; }
|
|
@@ -1714,6 +1907,7 @@ function surfaceSpine(root) {
|
|
|
1714
1907
|
return {
|
|
1715
1908
|
objectives,
|
|
1716
1909
|
budgets: [...budgets.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
|
1910
|
+
domains: [...domains.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
|
1717
1911
|
unanchored,
|
|
1718
1912
|
unreadable: null,
|
|
1719
1913
|
budgets_readable: !!(budgetsDoc && budgetsDoc.ok),
|
|
@@ -1759,6 +1953,9 @@ function buildSurface(name, root, w) {
|
|
|
1759
1953
|
// refused the move, takes effect on the next read instead of on the next restart.
|
|
1760
1954
|
function schedFile() { return schedStore().path; }
|
|
1761
1955
|
const DELIV_DIR = path.join(STATE, 'deliverables');
|
|
1956
|
+
// D76 — the PM's LOCAL approvals of discovered skills. Runtime state on this machine, never the repository: a commit or a
|
|
1957
|
+
// hand-edited registry must not be able to approve a skill for the PM.
|
|
1958
|
+
const SKILL_APPROVALS = path.join(STATE, 'skill-approvals.jsonl');
|
|
1762
1959
|
// Skills are file-driven (workspace-skill-authoring): built-ins ship committed under workspace/skills/,
|
|
1763
1960
|
// user-authored ones are scaffolded into state/skills/ by the New Skill flow. There is ONE user-facing
|
|
1764
1961
|
// concept — a skill — so the picker never exposes the internal `run`/`kind`; how a skill runs (server
|
|
@@ -1783,8 +1980,13 @@ function filingContract(w) {
|
|
|
1783
1980
|
}
|
|
1784
1981
|
// Read the skill files fresh each call so a just-created user skill appears with no restart. User dirs
|
|
1785
1982
|
// load FIRST and built-ins LAST, so a built-in's load-bearing deterministic fast-path can't be shadowed.
|
|
1786
|
-
|
|
1983
|
+
// D76 — `root` adds the repository's APPROVED discovered skills, loaded FIRST so the PM's own skill of the same id
|
|
1984
|
+
// overrides one and a built-in overrides both. Only an approved candidate whose file still hashes to what was approved
|
|
1985
|
+
// is ever offered; its harness is forced to an agent skill running claude.
|
|
1986
|
+
function loadTasks(root) {
|
|
1987
|
+
const discovered = root ? skillsReg.loadApproved(root, SKILL_APPROVALS, skillStore.parseSkill).skills : [];
|
|
1787
1988
|
return skillStore.loadSkills([
|
|
1989
|
+
...(discovered.length ? [{ skills: discovered }] : []),
|
|
1788
1990
|
{ dir: userSkillsDir(), source: 'user' },
|
|
1789
1991
|
{ dir: BUILTIN_SKILLS_DIR, source: 'builtin' },
|
|
1790
1992
|
]).map(toTask);
|
|
@@ -1793,12 +1995,24 @@ function loadTasks() {
|
|
|
1793
1995
|
// file's `run` and stays INTERNAL — it is never a field the PM selects.
|
|
1794
1996
|
function toTask(s) {
|
|
1795
1997
|
const t = { id: s.id, label: s.label, skill: s.id, kind: s.run, source: s.source,
|
|
1796
|
-
description: s.description, output: s.output };
|
|
1998
|
+
description: s.description, output: s.output, tier: s.tier || null };
|
|
1797
1999
|
if (s.run === 'deterministic') t.surface = s.surface;
|
|
1798
2000
|
else { t.agent = s.agent || 'claude'; t.prompt = s.body || null; }
|
|
1799
2001
|
return t;
|
|
1800
2002
|
}
|
|
1801
|
-
function taskById(id) { return loadTasks().find((t) => t.id === id || t.skill === id) || null; }
|
|
2003
|
+
function taskById(id, root) { return loadTasks(root).find((t) => t.id === id || t.skill === id) || null; }
|
|
2004
|
+
// D71 — the recommended schedules, one set per purpose. Times are host-local, like every schedule.
|
|
2005
|
+
const SEED_SETS = {
|
|
2006
|
+
requirements: [{ name: 'Daily requirement gathering', skill: 'gather-requirements', cadence: 'daily', time: '07:30' }],
|
|
2007
|
+
memory: [{ name: 'Weekly memory grooming', skill: 'memory-groom', cadence: 'weekly', day: 'Mon', time: '08:00' }],
|
|
2008
|
+
skills: [{ name: 'Weekly skill discovery', skill: 'skill-discover', cadence: 'weekly', day: 'Wed', time: '09:00' }],
|
|
2009
|
+
reports: [
|
|
2010
|
+
{ name: 'Daily report draft', skill: 'report-daily', cadence: 'daily', time: '17:30' },
|
|
2011
|
+
{ name: 'Weekly report draft', skill: 'report-weekly', cadence: 'weekly', day: 'Fri', time: '16:00' },
|
|
2012
|
+
{ name: 'Bi-weekly report draft', skill: 'report-biweekly', cadence: 'bi-weekly', day: 'Fri', time: '16:00' },
|
|
2013
|
+
{ name: 'Monthly report draft', skill: 'report-monthly', cadence: 'monthly', dayOfMonth: 1, time: '08:00' },
|
|
2014
|
+
],
|
|
2015
|
+
};
|
|
1802
2016
|
function readSchedules() {
|
|
1803
2017
|
try { const a = JSON.parse(fs.readFileSync(schedFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; }
|
|
1804
2018
|
}
|
|
@@ -1822,7 +2036,7 @@ function fireSchedule(sched) {
|
|
|
1822
2036
|
const w = workspaces.get(sched.ws) || homeWorkspace();
|
|
1823
2037
|
const root = w ? w.root : SELF_ROOT;
|
|
1824
2038
|
// One corpus load probing both keys — taskById would re-read every .skill file per probe.
|
|
1825
|
-
const tasks = loadTasks();
|
|
2039
|
+
const tasks = loadTasks(root);
|
|
1826
2040
|
const byId = (id) => tasks.find((t) => t.id === id || t.skill === id) || null;
|
|
1827
2041
|
const task = byId(sched.task) || byId(sched.skill);
|
|
1828
2042
|
// AGENT task — fire as a real PTY session running its skill (async; the skill files its own
|
|
@@ -1836,16 +2050,21 @@ function fireSchedule(sched) {
|
|
|
1836
2050
|
// Fixing only the SCAFFOLD fixes only skills that do not exist yet, which is no help to the
|
|
1837
2051
|
// eleven that do. `needs.json` is resolved per workspace, so this cannot be a one-off rewrite
|
|
1838
2052
|
// of the files either — the right answer for a given fire depends on which repo it fires in.
|
|
1839
|
-
|
|
2053
|
+
let prompt = task.prompt ? skillStore.retargetFiling(task.prompt, filingContract(w)) : null;
|
|
2054
|
+
// R11 — a schedule may carry the PM's own request ("a special report for the board meeting on …"). It is
|
|
2055
|
+
// appended AFTER the skill body so the skill's "if they already said, do not ask again" rule applies.
|
|
2056
|
+
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
2057
|
// The schedule's recorded profile travels with the process it creates (profiles R8.3). Only an
|
|
1841
2058
|
// AGENT-kind task has a PTY, which is why `profile_applies` is false for the skill-kind rows
|
|
1842
2059
|
// below rather than the control simply being hidden.
|
|
1843
2060
|
const s = spawnSession({ name: sched.name, agent: task.agent || 'claude', ws: sched.ws,
|
|
1844
2061
|
origin: 'schedule:' + sched.id, initialInput: prompt ? prompt + '\r' : null,
|
|
1845
|
-
profile: sched.profile == null ? null : sched.profile
|
|
2062
|
+
profile: sched.profile == null ? null : sched.profile,
|
|
2063
|
+
// 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.
|
|
2064
|
+
modelAsk: { scheduleTier: sched.tier || null, skillTier: task.tier || null, skill: task.skill } });
|
|
1846
2065
|
const list = readSchedules(); const cur = list.find((x) => x.id === sched.id);
|
|
1847
2066
|
const started = s.state !== 'exited';
|
|
1848
|
-
if (cur) { cur.lastRun = nowISO(); cur.lastStatus = started ? 'started' : 'fail'; cur.nextRun = scheduleLogic.nextRun(cur); writeSchedules(list); }
|
|
2067
|
+
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
2068
|
// A run that started WITHOUT the profile it recorded is reported as such rather than as a
|
|
1850
2069
|
// clean start: the session is real, the environment is not the one the schedule declared.
|
|
1851
2070
|
return { ok: started, kind: 'agent', sessionId: s.id,
|
|
@@ -1884,6 +2103,12 @@ function fireSchedule(sched) {
|
|
|
1884
2103
|
function checkDueSchedules() {
|
|
1885
2104
|
let fired = 0;
|
|
1886
2105
|
for (const s of readSchedules()) { try { if (scheduleLogic.isDue(s)) { fireSchedule(s); fired++; } } catch {} }
|
|
2106
|
+
// R11 — a one-off whose moment passed while nothing was running is marked missed and off, never caught up.
|
|
2107
|
+
try {
|
|
2108
|
+
const list = readSchedules(); let changed = false;
|
|
2109
|
+
for (const s of list) if (scheduleLogic.isMissed(s)) { s.enabled = false; s.lastStatus = 'missed'; s.nextRun = null; changed = true; }
|
|
2110
|
+
if (changed) writeSchedules(list);
|
|
2111
|
+
} catch {}
|
|
1887
2112
|
return fired;
|
|
1888
2113
|
}
|
|
1889
2114
|
// the tick — the persistent server checks on an interval; unref'd so it never blocks exit
|
|
@@ -1906,6 +2131,9 @@ function persist(s) {
|
|
|
1906
2131
|
// fact: a record persisted before profiles existed restores as "not recorded", and inventing a
|
|
1907
2132
|
// key for the past would be a lie about it.
|
|
1908
2133
|
if ('profile' in s) meta.profile = s.profile == null ? null : s.profile;
|
|
2134
|
+
// D75 — what model was asked for and what was APPLIED. Same `in`-guard as the profile: a record from before
|
|
2135
|
+
// routing existed restores as 'not recorded', and writing a key for the past would be a lie about it.
|
|
2136
|
+
if ('model' in s) { meta.model = s.model; meta.modelAsk = s.modelAsk || null; }
|
|
1909
2137
|
// WHAT THIS SESSION IS FOR, and where that came from. Same `in`-guard as `profile` above and for
|
|
1910
2138
|
// the same reason: every session running before this field existed has no attribution, and
|
|
1911
2139
|
// writing a key for it would turn "we were never told" into "it belongs to nothing". Both keys
|
|
@@ -2164,6 +2392,7 @@ function sessionList() {
|
|
|
2164
2392
|
const hx = transcriptHarness(s);
|
|
2165
2393
|
return {
|
|
2166
2394
|
id: s.id, ws: s.ws, name: s.name, agent: s.agent, state: s.state, created: s.created,
|
|
2395
|
+
model: 'model' in s ? s.model : null, model_line: modelLib.line('model' in s ? s.model : null),
|
|
2167
2396
|
cwd: path.relative(s.root, s.cwd) || '.', note: s.note || '', origin: s.origin || null,
|
|
2168
2397
|
// Derived presentation state, JOINED from both sources: the cadence clock above and the
|
|
2169
2398
|
// transcript's answer to whose turn it is (lib/session-state). working | quiet | exited |
|
|
@@ -2610,11 +2839,13 @@ function resolveProfileForSpawn(id) {
|
|
|
2610
2839
|
: `profile "${id}" is not among the descriptors in ${read.dir} — PMOS will not substitute another` };
|
|
2611
2840
|
}
|
|
2612
2841
|
|
|
2613
|
-
function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, initiative }) {
|
|
2842
|
+
function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, initiative, modelAsk }) {
|
|
2614
2843
|
const id = nextSessionId();
|
|
2615
2844
|
const w = getWs(ws);
|
|
2616
2845
|
const root = w ? w.root : SELF_ROOT;
|
|
2617
2846
|
const isShell = agent === 'shell';
|
|
2847
|
+
// D72 — the session reads the memory views once, at its start; rebuild them from the ledger now.
|
|
2848
|
+
try { memoryLib.renderFor(root); } catch { /* memory is never a reason a session fails to start */ }
|
|
2618
2849
|
// a scheduled run tags the session with its origin, and feeds the skill invocation as first input
|
|
2619
2850
|
const tag = origin || null;
|
|
2620
2851
|
const feed = (s) => { if (initialInput && s && s.pty) { try { setTimeout(() => { try { s.pty.write(initialInput); } catch {} }, 700); } catch {} } };
|
|
@@ -2632,6 +2863,25 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
2632
2863
|
const base = { id, ws: w ? w.id : wsId(SELF_ROOT), root, name: (name && String(name).trim()) || agent, agent, origin: tag,
|
|
2633
2864
|
profile: pres.id, profileRefusal: pres.ok ? null : pres.error };
|
|
2634
2865
|
|
|
2866
|
+
/* WHICH MODEL (D75). Resolved once, here, with the profile, so the record can say only what was applied.
|
|
2867
|
+
* Three states are kept apart — not asked, applied, and "this binary has no model control" — and a choice that
|
|
2868
|
+
* cannot be honoured (a pinned review route being lowered, a value that is not a plain token, an unreadable
|
|
2869
|
+
* policy) refuses BEFORE anything starts, as an unreadable profile does. */
|
|
2870
|
+
const mres = modelLib.resolveForSpawn(root, agent, modelAsk);
|
|
2871
|
+
if (!mres.ok) {
|
|
2872
|
+
base.model = { state: 'refused', tier: null, model: null, effort: null, decidedBy: null, why: mres.error };
|
|
2873
|
+
return exitedSession(base, workdir, `model choice refused — ${mres.error}`);
|
|
2874
|
+
}
|
|
2875
|
+
base.model = { state: mres.state, tier: mres.tier || null, model: mres.model || null, effort: mres.effort || null,
|
|
2876
|
+
decidedBy: mres.decidedBy || null, why: mres.why || null };
|
|
2877
|
+
base.modelAsk = modelAsk || null;
|
|
2878
|
+
const launchArgs = mres.state === 'applied' ? mres.args : [];
|
|
2879
|
+
if (mres.tier) {
|
|
2880
|
+
const mc = modelLib.core();
|
|
2881
|
+
if (mc.ok) mc.core.logDecision(root, { kind: 'spawn', session: id, agent, tier: mres.tier, model: mres.model, effort: mres.effort,
|
|
2882
|
+
decidedBy: mres.decidedBy, state: mres.state, skill: (modelAsk && modelAsk.skill) || null, by: 'workspace' });
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2635
2885
|
/* WHAT THIS SESSION IS FOR (workspace-session-attribution R1).
|
|
2636
2886
|
*
|
|
2637
2887
|
* A PMOS session has never recorded which initiative it is working on — not here, not on the wire,
|
|
@@ -2665,7 +2915,8 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
2665
2915
|
sessions.set(id, s);
|
|
2666
2916
|
// the OVERLAY, not the merged env — the merged env is for the direct-PTY path below, which
|
|
2667
2917
|
// really does spawn the process itself. See createTmuxSession for why the two differ.
|
|
2668
|
-
|
|
2918
|
+
// The launch args are plain tokens by construction (pmos-model's strict pattern), so joining them is safe.
|
|
2919
|
+
if (!createTmuxSession(tname, launchArgs.length ? [cmd, ...launchArgs].join(' ') : cmd, workdir, 80, 24, profileOverlay(pres.profile))) {
|
|
2669
2920
|
const why = `could not start a tmux session for '${cmd}'. Run \`npm run doctor\` in workspace/.`;
|
|
2670
2921
|
s.state = 'exited'; s.tmux = null; s.note = why;
|
|
2671
2922
|
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 +2924,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
2673
2924
|
}
|
|
2674
2925
|
announceStateHealth(s); // the unmigrated notice comes FIRST — it explains an empty skills list
|
|
2675
2926
|
announceTree(s); // before attach, so the advisory is the first line of the pane's record
|
|
2927
|
+
announceModel(s);
|
|
2676
2928
|
attachPty(s);
|
|
2677
2929
|
feed(s);
|
|
2678
2930
|
persist(s); pushSessions(); pushWorkspaces(); return s;
|
|
@@ -2683,7 +2935,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
2683
2935
|
let p, usedCmd = cmd;
|
|
2684
2936
|
try {
|
|
2685
2937
|
if (isShell) { const r = spawnPty(cmd, workdir, penv); p = r.pty; usedCmd = r.cmd; }
|
|
2686
|
-
else p = pty.spawn(cmd,
|
|
2938
|
+
else p = pty.spawn(cmd, launchArgs, { name: 'xterm-256color', cols: 80, rows: 24, cwd: workdir, env: penv });
|
|
2687
2939
|
} catch (e) {
|
|
2688
2940
|
return exitedSession(base, workdir, spawnFailureAdvice(e, cmd, isShell));
|
|
2689
2941
|
}
|
|
@@ -2693,6 +2945,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
2693
2945
|
sessions.set(id, s);
|
|
2694
2946
|
announceStateHealth(s);
|
|
2695
2947
|
announceTree(s); // registered first: a session's co-tenancy question includes itself in the map
|
|
2948
|
+
announceModel(s);
|
|
2696
2949
|
p.onData((d) => {
|
|
2697
2950
|
appendBuf(s, d);
|
|
2698
2951
|
for (const c of s.clients) if (c.readyState === 1) c.send(JSON.stringify({ t: 'data', id, data: d }));
|
|
@@ -2727,7 +2980,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
2727
2980
|
// A FAILED git operation in a real repo must never spawn a session on the wrong branch (rubric
|
|
2728
2981
|
// hard floor): we return {ok:false} and spawn nothing. Kept as its own function so the
|
|
2729
2982
|
// git-then-maybe-spawn contract is unit-testable without a WS client.
|
|
2730
|
-
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative }) {
|
|
2983
|
+
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative, modelAsk }) {
|
|
2731
2984
|
const w = getWs(ws);
|
|
2732
2985
|
if (!w) return { ok: false, error: 'no workspace' };
|
|
2733
2986
|
// The profile is checked BEFORE any git operation. A composer that picked a profile PMOS cannot
|
|
@@ -2735,6 +2988,9 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
|
|
|
2735
2988
|
// and a session that claims a profile it never got is the quietest failure this feature has.
|
|
2736
2989
|
const pres = resolveProfileForSpawn(profile);
|
|
2737
2990
|
if (!pres.ok) return { ok: false, error: pres.error };
|
|
2991
|
+
// The model choice is checked on the same terms (D75): a refused choice starts nothing, and leaves no branch moved.
|
|
2992
|
+
const mchk = modelLib.resolveForSpawn(w.root, agent || 'shell', modelAsk);
|
|
2993
|
+
if (!mchk.ok) return { ok: false, error: `model choice refused — ${mchk.error}` };
|
|
2738
2994
|
let targetWs = w.id;
|
|
2739
2995
|
let notice = null;
|
|
2740
2996
|
// Isolation exists to protect OTHER sessions. Alone in the working TREE there are none to
|
|
@@ -2771,7 +3027,7 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
|
|
|
2771
3027
|
broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
|
|
2772
3028
|
}
|
|
2773
3029
|
}
|
|
2774
|
-
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative });
|
|
3030
|
+
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative, modelAsk });
|
|
2775
3031
|
// The co-tenancy fact, on the ordinary path too. Note where this sits: OUTSIDE the `if (branch)`
|
|
2776
3032
|
// block above. The isolate default already computes that other sessions would be disturbed — and
|
|
2777
3033
|
// then the whole git block is skipped when no branch was picked, which is the common path and the
|
|
@@ -3435,6 +3691,143 @@ function budgetRegistryFor(root) {
|
|
|
3435
3691
|
// are one product with one board, and counting them separately is what put the same initiative in
|
|
3436
3692
|
// the queue three times. boardCache.board() ONLY (§6.8): consoleState.workflow() is ~30s and
|
|
3437
3693
|
// synchronous, and on a request path it freezes every PTY socket for the whole half minute.
|
|
3694
|
+
/* ── overhaul-r4 — the portfolio, across every registered repository ───────────────────────────
|
|
3695
|
+
* One entry per REPOSITORY (repo_key), never per worktree. Boards come from boardCache only — the
|
|
3696
|
+
* synchronous derive freezes every PTY socket for ~30 s (board-cache.js) — so every repository
|
|
3697
|
+
* carries its own as_of / stale / pending, and one that cannot be read is listed WITH its error,
|
|
3698
|
+
* never dropped and never shown as empty. Grouping comes from the spine (domain › objective › KR, or
|
|
3699
|
+
* a health budget), which is a cheap read of the records. */
|
|
3700
|
+
function repoLeads() {
|
|
3701
|
+
const groups = new Map();
|
|
3702
|
+
for (const t of wsList()) {
|
|
3703
|
+
const key = t.repo_key || t.root;
|
|
3704
|
+
if (!groups.has(key)) groups.set(key, { key, name: t.label || t.repo_name || t.name, trees: [] });
|
|
3705
|
+
groups.get(key).trees.push(t);
|
|
3706
|
+
}
|
|
3707
|
+
return [...groups.values()].map((g) => {
|
|
3708
|
+
const lead = g.trees.find((t) => path.resolve(t.root) === path.resolve(g.key)) || g.trees[0];
|
|
3709
|
+
const label = (g.trees.find((t) => t.label) || {}).label;
|
|
3710
|
+
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)) };
|
|
3711
|
+
});
|
|
3712
|
+
}
|
|
3713
|
+
/** The hub (D-3): the registered repository whose root holds portfolio.yaml. Null when there is none. */
|
|
3714
|
+
function portfolioHub(leads) {
|
|
3715
|
+
const hub = (leads || repoLeads()).find((r) => isDir(r.root) && fs.existsSync(path.join(r.root, 'portfolio.yaml')));
|
|
3716
|
+
if (!hub) return null;
|
|
3717
|
+
// `repos:` then indented `<repository name>: <domain>` lines — the only shape read, stated in the PRD
|
|
3718
|
+
const map = {};
|
|
3719
|
+
let inRepos = false;
|
|
3720
|
+
for (const line of fs.readFileSync(path.join(hub.root, 'portfolio.yaml'), 'utf8').split('\n')) {
|
|
3721
|
+
if (/^repos\s*:\s*$/.test(line)) { inRepos = true; continue; }
|
|
3722
|
+
if (/^\S/.test(line)) inRepos = false;
|
|
3723
|
+
const m = inRepos && /^\s+["']?([^:"']+?)["']?\s*:\s*["']?([A-Za-z0-9][\w-]*)["']?\s*$/.exec(line);
|
|
3724
|
+
if (m) map[m[1].trim()] = m[2];
|
|
3725
|
+
}
|
|
3726
|
+
return { name: hub.name, ws: hub.ws, root: hub.root, domains: map };
|
|
3727
|
+
}
|
|
3728
|
+
function portfolio() {
|
|
3729
|
+
const leads = repoLeads();
|
|
3730
|
+
const hub = portfolioHub(leads);
|
|
3731
|
+
const sessions = sessionList();
|
|
3732
|
+
const repos = [];
|
|
3733
|
+
const rows = [];
|
|
3734
|
+
for (const r of leads) {
|
|
3735
|
+
const board = boardCache.board(r.root);
|
|
3736
|
+
let spine;
|
|
3737
|
+
try { spine = surfaceSpine(r.root); }
|
|
3738
|
+
catch (e) { spine = { unreadable: 'the records could not be read — ' + String((e && e.message) || 'error').slice(0, 120) }; }
|
|
3739
|
+
const repoDomain = hub ? hub.domains[r.name] || null : null;
|
|
3740
|
+
const entry = { name: r.name, ws: r.ws, domain: repoDomain, as_of: board.as_of || null, stale: !!board.stale,
|
|
3741
|
+
pending: !!board.pending, error: board.ok === false ? board.error : (spine.unreadable || null), count: 0 };
|
|
3742
|
+
repos.push(entry);
|
|
3743
|
+
if (spine.unreadable) continue;
|
|
3744
|
+
const group = new Map(); // initiative id -> { group, domain }
|
|
3745
|
+
for (const o of spine.objectives || []) {
|
|
3746
|
+
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 });
|
|
3747
|
+
for (const i of o.direct || []) group.set(i.id, { group: o.name || o.key, kind: 'objective', rec: i });
|
|
3748
|
+
}
|
|
3749
|
+
for (const b of spine.budgets || []) for (const i of b.initiatives || []) group.set(i.id, { group: b.id, kind: 'health', rec: i });
|
|
3750
|
+
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 });
|
|
3751
|
+
for (const i of spine.unanchored || []) group.set(i.id, { group: 'no resolvable anchor', kind: 'unanchored', rec: i });
|
|
3752
|
+
const byId = new Map(((board && board.rows) || []).filter((x) => x && x.id).map((x) => [String(x.id), x]));
|
|
3753
|
+
for (const [id, g] of group) {
|
|
3754
|
+
const b = byId.get(id);
|
|
3755
|
+
rows.push({
|
|
3756
|
+
repo: r.name, ws: r.ws, id, title: (b && b.title) || g.rec.title || id, lane: (b && b.lane) || g.rec.lane || null,
|
|
3757
|
+
stage: b ? b.stage : null, accepted: b ? !!b.accepted : null, next: b && b.next ? b.next.action || b.next.why || null : null,
|
|
3758
|
+
blocked: b ? (b.blockers || []).length > 0 : null,
|
|
3759
|
+
group: g.group, group_kind: g.kind, domain: g.domain || repoDomain,
|
|
3760
|
+
sessions: sessions.filter((x) => r.ids.has(x.ws) && x.initiative === id && x.state !== 'exited').length,
|
|
3761
|
+
});
|
|
3762
|
+
entry.count++;
|
|
3763
|
+
}
|
|
3764
|
+
}
|
|
3765
|
+
return { ok: true, as_of: nowISO(), hub: hub ? { name: hub.name, ws: hub.ws } : null, repos, rows };
|
|
3766
|
+
}
|
|
3767
|
+
|
|
3768
|
+
/* overhaul-r4 C — a report from the portfolio aggregate, written into the HUB on a PM press only. It
|
|
3769
|
+
* creates a new file (never overwrites; a numeric suffix is added) and is NOT logged as a PM edit of
|
|
3770
|
+
* an existing document — that is /api/doc/save's meaning, and a generated report is not one. */
|
|
3771
|
+
function portfolioReport(kind, q) {
|
|
3772
|
+
const pf = portfolio();
|
|
3773
|
+
const hubInfo = pf.hub && workspaces.get(pf.hub.ws);
|
|
3774
|
+
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' };
|
|
3775
|
+
if (!['weekly', 'okr', 'query'].includes(kind)) return { ok: false, error: 'kind must be weekly, okr or query' };
|
|
3776
|
+
const needle = String(q || '').trim().toLowerCase();
|
|
3777
|
+
if (kind === 'query' && !needle) return { ok: false, error: 'a query report needs a q' };
|
|
3778
|
+
let rows = pf.rows;
|
|
3779
|
+
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)));
|
|
3780
|
+
const day = nowISO().slice(0, 10);
|
|
3781
|
+
const titles = { weekly: 'Weekly portfolio report', okr: 'OKR and domain progress', query: `Portfolio query — "${String(q).trim()}"` };
|
|
3782
|
+
const lines = [];
|
|
3783
|
+
// OKF v0.2 (D73): one key per line, nothing indented — the frontmatter readers stop at the first indented line.
|
|
3784
|
+
lines.push('---', 'type: Reference', `title: "${titles[kind].replace(/"/g, "'")} — ${day}"`,
|
|
3785
|
+
`description: "Portfolio aggregate across the registered repositories, written on the PM's request."`,
|
|
3786
|
+
`generated: {by: process:pmos-workspace, at: "${nowISO()}"}`, 'status: stable', '---', '');
|
|
3787
|
+
lines.push(`# ${titles[kind]} — ${day}`, '');
|
|
3788
|
+
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.`, '');
|
|
3789
|
+
const unread = pf.repos.filter((r) => r.error || r.pending);
|
|
3790
|
+
const staleR = pf.repos.filter((r) => r.stale && !r.error);
|
|
3791
|
+
lines.push('## Coverage', '');
|
|
3792
|
+
lines.push('| repository | domain | initiatives | board as of | state |', '|---|---|---:|---|---|');
|
|
3793
|
+
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'} |`);
|
|
3794
|
+
lines.push('');
|
|
3795
|
+
if (unread.length) lines.push(`**Not fully read:** ${unread.map((r) => r.name).join(', ')}. Their initiatives are missing or shown without a stage.`, '');
|
|
3796
|
+
if (staleR.length) lines.push(`**Stale boards:** ${staleR.map((r) => r.name).join(', ')} (refreshing in the background).`, '');
|
|
3797
|
+
const cell = (v) => String(v == null ? '—' : v).replace(/\|/g, '/').replace(/\n/g, ' ').slice(0, 140);
|
|
3798
|
+
const table = (list) => {
|
|
3799
|
+
const out = ['| initiative | repository | lane | stage | next |', '|---|---|---|---|---|'];
|
|
3800
|
+
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)} |`);
|
|
3801
|
+
return out;
|
|
3802
|
+
};
|
|
3803
|
+
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])); };
|
|
3804
|
+
if (kind === 'okr') {
|
|
3805
|
+
for (const [dom, list] of groupBy(rows, (x) => x.domain || '(no domain declared)')) {
|
|
3806
|
+
lines.push(`## Domain: ${dom}`, '');
|
|
3807
|
+
for (const [grp, l2] of groupBy(list, (x) => x.group)) {
|
|
3808
|
+
const done = l2.filter((x) => x.accepted).length;
|
|
3809
|
+
lines.push(`### ${grp} — ${done} of ${l2.length} accepted`, '', ...table(l2), '');
|
|
3810
|
+
}
|
|
3811
|
+
}
|
|
3812
|
+
} else {
|
|
3813
|
+
const waiting = rows.filter((x) => x.blocked && !x.accepted);
|
|
3814
|
+
lines.push('## Needs you', '');
|
|
3815
|
+
if (waiting.length) lines.push(...table(waiting), ''); else lines.push('Nothing derived as blocked across the repositories that were read.', '');
|
|
3816
|
+
for (const [dom, list] of groupBy(rows, (x) => x.domain || '(no domain declared)')) {
|
|
3817
|
+
lines.push(`## Domain: ${dom}`, '');
|
|
3818
|
+
for (const [repo, l2] of groupBy(list, (x) => x.repo)) lines.push(`### ${repo}`, '', ...table(l2), '');
|
|
3819
|
+
}
|
|
3820
|
+
if (!rows.length) lines.push('No initiative matched.', '');
|
|
3821
|
+
}
|
|
3822
|
+
const slug = kind === 'query' ? '-' + needle.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) : '';
|
|
3823
|
+
const dir = path.join(hubInfo.root, 'reports');
|
|
3824
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3825
|
+
let rel = `reports/${day}-${kind}${slug}.md`;
|
|
3826
|
+
for (let n = 2; fs.existsSync(path.join(hubInfo.root, rel)); n++) rel = `reports/${day}-${kind}${slug}-${n}.md`;
|
|
3827
|
+
fs.writeFileSync(path.join(hubInfo.root, rel), lines.join('\n') + '\n', { flag: 'wx' });
|
|
3828
|
+
return { ok: true, ws: hubInfo.id, path: rel, rows: rows.length, unread: unread.map((r) => r.name) };
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3438
3831
|
function focusProducts() {
|
|
3439
3832
|
const trees = wsList();
|
|
3440
3833
|
const groups = new Map(); // repo_key -> { id, name, trees[] }
|
|
@@ -3544,7 +3937,8 @@ function buildConsole(name, w, root, params) {
|
|
|
3544
3937
|
if (s.unreadable) return { ok: false, error: s.unreadable };
|
|
3545
3938
|
const placed = s.objectives.reduce((n, o) =>
|
|
3546
3939
|
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)
|
|
3940
|
+
+ s.budgets.reduce((n, b) => n + b.initiatives.length, 0)
|
|
3941
|
+
+ (s.domains || []).reduce((n, d) => n + d.initiatives.length, 0); // D67 domain groups
|
|
3548
3942
|
// `empty` is a SUCCESSFUL read of genuinely nothing — a repo with no initiative records at all.
|
|
3549
3943
|
// It is a supported thing to point the workspace at, so it renders calm, not red.
|
|
3550
3944
|
return { ok: true, empty: placed === 0 && s.unanchored.length === 0, ...s };
|
|
@@ -3866,10 +4260,31 @@ async function handleRequest(req, res) {
|
|
|
3866
4260
|
return json(res, { ok: true, workspace: { ...w, home: HOME_ENABLED && w.root === SELF_ROOT, shape: wsShape(w.root) } });
|
|
3867
4261
|
}
|
|
3868
4262
|
if (p === '/api/workspaces/forget' && req.method === 'POST') {
|
|
4263
|
+
// `repository: true` removes the repository's whole family of rows (overhaul-r1 R3.2); without it,
|
|
4264
|
+
// one row, as before. A refusal names its cause — unknown / home / sessions (R3.3).
|
|
3869
4265
|
let body = {};
|
|
3870
4266
|
try { body = JSON.parse((await readBody(req)) || '{}'); } catch {}
|
|
3871
|
-
|
|
3872
|
-
|
|
4267
|
+
if (body.repository) {
|
|
4268
|
+
const out = forgetRepository(body.id);
|
|
4269
|
+
return json(res, out, out.ok ? 200 : 409);
|
|
4270
|
+
}
|
|
4271
|
+
const blocker = forgetBlocker(body.id);
|
|
4272
|
+
if (blocker) return json(res, { ok: false, ...blocker }, 409);
|
|
4273
|
+
forgetWorkspace(body.id);
|
|
4274
|
+
return json(res, { ok: true, removed: [body.id] });
|
|
4275
|
+
}
|
|
4276
|
+
if (p === '/api/workspaces/rename' && req.method === 'POST') {
|
|
4277
|
+
// A display name only (overhaul-r1 R3.1): the id is a hash of the root and does not move.
|
|
4278
|
+
let body = {};
|
|
4279
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); } catch {}
|
|
4280
|
+
const w = workspaces.get(body.id);
|
|
4281
|
+
const name = String(body.name || '').trim();
|
|
4282
|
+
if (!w) return json(res, { ok: false, reason: 'unknown', error: 'unknown repository' }, 404);
|
|
4283
|
+
if (!name) return json(res, { ok: false, error: 'a name is required' }, 400);
|
|
4284
|
+
// `label` is the PM's name for the REPOSITORY. The client names a product after its folder
|
|
4285
|
+
// (`repo_name`), so changing `name` alone would never show; `label` wins over both.
|
|
4286
|
+
w.label = name; saveWorkspaces(); pushWorkspaces();
|
|
4287
|
+
return json(res, { ok: true, workspace: { id: w.id, name, label: name } });
|
|
3873
4288
|
}
|
|
3874
4289
|
if (p === '/api/git/worktrees/remove' && req.method === 'POST') {
|
|
3875
4290
|
// Reclaim a worktree's folder. `dir` comes from a client, so it is checked against git's own
|
|
@@ -4120,11 +4535,65 @@ async function handleRequest(req, res) {
|
|
|
4120
4535
|
// ── scheduled tasks ──
|
|
4121
4536
|
// The picker's source: file-driven skills. `kind` is carried for internal/back-compat consumers,
|
|
4122
4537
|
// but the New Skill flow + the scheduled lens never render it — one concept, no user-facing kind.
|
|
4538
|
+
if (p === '/api/skills/candidates' && req.method === 'GET') {
|
|
4539
|
+
// D76 — the directory of discovered and generated skills for one repository, with what the PM has done about each.
|
|
4540
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
4541
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
4542
|
+
const c = skillsReg.core();
|
|
4543
|
+
if (!c.ok) return json(res, { ok: false, error: c.error });
|
|
4544
|
+
const { rows, torn } = c.core.candidates(w.root);
|
|
4545
|
+
const reads = skillsReg.readsFor(SKILL_APPROVALS, w.root);
|
|
4546
|
+
const approved = new Set(skillsReg.approvedFor(SKILL_APPROVALS, w.root).map((a) => `${a.id}@${a.sha}`));
|
|
4547
|
+
const res2 = c.core.resolve(w.root, skillsReg.approvedFor(SKILL_APPROVALS, w.root));
|
|
4548
|
+
const live = new Set(res2.skills.map((x) => `${x.id}@${x.sha}`));
|
|
4549
|
+
const others = new Set(skillStore.loadSkills([{ dir: userSkillsDir(), source: 'user' }, { dir: BUILTIN_SKILLS_DIR, source: 'builtin' }]).map((x) => x.id));
|
|
4550
|
+
return json(res, { ok: true, ws: w.id, torn, blocked: res2.blocked,
|
|
4551
|
+
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}`),
|
|
4552
|
+
needs_license_ack: c.core.licenseNeedsAck(r.license), collides: others.has(r.id) })) });
|
|
4553
|
+
}
|
|
4554
|
+
if (p === '/api/skills/decide' && req.method === 'POST') {
|
|
4555
|
+
// D76 — the PM's press. `read` returns the full text and records that it was opened; `approve` is refused without
|
|
4556
|
+
// it, and for an unknown or non-permissive licence without an acknowledgement; `reject` and `retire` go to the
|
|
4557
|
+
// repository's registry. Approval is recorded HERE, locally, never in the repository.
|
|
4558
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
4559
|
+
const w = getWs(body.ws);
|
|
4560
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
4561
|
+
const c = skillsReg.core();
|
|
4562
|
+
if (!c.ok) return json(res, { ok: false, error: c.error });
|
|
4563
|
+
const row = c.core.candidates(w.root).rows.find((r) => r.id === String(body.id || '') && r.sha.startsWith(String(body.sha || 'x')));
|
|
4564
|
+
if (!row) return json(res, { ok: false, error: 'no such candidate' }, 404);
|
|
4565
|
+
const key = skillsReg.rootKey(w.root); const at = nowISO();
|
|
4566
|
+
const by = 'human:wawan'; const eid = 'a-' + Date.now().toString(36) + '-' + crypto.randomBytes(3).toString('hex');
|
|
4567
|
+
try {
|
|
4568
|
+
if (body.op === 'read') {
|
|
4569
|
+
const t = c.core.textOf(w.root, row);
|
|
4570
|
+
if (!t.ok) return json(res, { ok: false, error: t.error, changed: !!t.changed }, 409);
|
|
4571
|
+
skillsReg.appendApproval(SKILL_APPROVALS, { eid, at, by, op: 'read', root: key, id: row.id, sha: row.sha });
|
|
4572
|
+
return json(res, { ok: true, text: t.text, id: row.id, sha12: row.sha12, scan: row.scan, ignored_keys: row.ignored_keys });
|
|
4573
|
+
}
|
|
4574
|
+
if (body.op === 'approve') {
|
|
4575
|
+
if (row.state !== 'quarantined') return json(res, { ok: false, error: `that candidate is ${row.state} — it cannot be approved` }, 409);
|
|
4576
|
+
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);
|
|
4577
|
+
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);
|
|
4578
|
+
const t = c.core.textOf(w.root, row);
|
|
4579
|
+
if (!t.ok) return json(res, { ok: false, error: t.error }, 409);
|
|
4580
|
+
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);
|
|
4581
|
+
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 || [] });
|
|
4582
|
+
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` });
|
|
4583
|
+
}
|
|
4584
|
+
if (body.op === 'reject' || body.op === 'retire') {
|
|
4585
|
+
const r = c.core.decide(w.root, body.op, { id: row.id, sha: row.sha12, reason: body.reason, by });
|
|
4586
|
+
return json(res, r, r.ok ? 200 : 400);
|
|
4587
|
+
}
|
|
4588
|
+
} catch (e) { return json(res, { ok: false, error: 'could not record that — ' + String((e && e.message) || e).slice(0, 120) }, 500); }
|
|
4589
|
+
return json(res, { ok: false, error: 'op must be read, approve, reject or retire' }, 400);
|
|
4590
|
+
}
|
|
4123
4591
|
if (p === '/api/tasks') return json(res, { tasks: loadTasks().map((t) => ({ id: t.id, label: t.label, skill: t.skill, kind: t.kind })) });
|
|
4124
4592
|
// ── skills (workspace-skill-authoring): the ONE-concept surface + the New Skill flow ──
|
|
4125
4593
|
// GET lists every skill with NO kind field — from the user's view there is just a skill.
|
|
4126
4594
|
if (p === '/api/skills' && req.method === 'GET') {
|
|
4127
|
-
const
|
|
4595
|
+
const sw = getWs(url.searchParams.get('ws'));
|
|
4596
|
+
const skills = loadTasks(sw ? sw.root : null).map((t) => ({ id: t.id, label: t.label, skill: t.skill,
|
|
4128
4597
|
description: t.description || '', output: t.output || [], source: t.source || 'builtin' }));
|
|
4129
4598
|
// NEVER a bare list. Under an unmigrated conflict this list is either short by every skill the
|
|
4130
4599
|
// PM authored or is being read from the old directory, and in both cases the payload has to say
|
|
@@ -4225,7 +4694,9 @@ async function handleRequest(req, res) {
|
|
|
4225
4694
|
return json(res, { ok: true, prefs });
|
|
4226
4695
|
}
|
|
4227
4696
|
if (p === '/api/schedules' && req.method === 'GET') {
|
|
4228
|
-
|
|
4697
|
+
// `all=1` (overhaul-r4 D1): every repository's schedules — the drawer lists `scheduled` under "all
|
|
4698
|
+
// repositories", so filtering to the home workspace there would hide the PM's other schedules
|
|
4699
|
+
const wsFilter = url.searchParams.get('all') ? null : getWs(url.searchParams.get('ws'));
|
|
4229
4700
|
const id = wsFilter ? wsFilter.id : null;
|
|
4230
4701
|
// One skills-corpus read per request, not one per schedule row — taskById re-reads every
|
|
4231
4702
|
// .skill file, and the PM's forty-five schedules made that forty-five reads per GET.
|
|
@@ -4246,7 +4717,9 @@ async function handleRequest(req, res) {
|
|
|
4246
4717
|
}
|
|
4247
4718
|
if (p === '/api/schedules' && req.method === 'POST') {
|
|
4248
4719
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
4249
|
-
|
|
4720
|
+
// D76 — an APPROVED discovered skill of this repository is a skill too, so it can be scheduled; a quarantined one is not.
|
|
4721
|
+
const swsch = getWs(body.ws);
|
|
4722
|
+
if (!taskById(body.task || body.skill, swsch ? swsch.root : null)) return json(res, { ok: false, error: 'that task has no backing skill' }, 400);
|
|
4250
4723
|
const norm = scheduleLogic.normalizeSchedule(body);
|
|
4251
4724
|
if (!norm.ok) return json(res, { ok: false, error: norm.error }, 400);
|
|
4252
4725
|
const w = getWs(body.ws);
|
|
@@ -4259,12 +4732,35 @@ async function handleRequest(req, res) {
|
|
|
4259
4732
|
const list = readSchedules(); list.unshift(sched); writeSchedules(list);
|
|
4260
4733
|
return json(res, { ok: true, schedule: sched });
|
|
4261
4734
|
}
|
|
4735
|
+
if (p === '/api/schedules/seed' && req.method === 'POST') {
|
|
4736
|
+
// D71 — one press creates the recommended schedules for a repository. Idempotent: a schedule for
|
|
4737
|
+
// the same skill in the same repository is never created twice. Never reached by anything but the
|
|
4738
|
+
// PM's press; the tick then fires them like any other schedule.
|
|
4739
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
4740
|
+
const w = getWs(body.ws);
|
|
4741
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
4742
|
+
const set = SEED_SETS[body.set];
|
|
4743
|
+
if (!set) return json(res, { ok: false, error: `unknown set '${body.set}' — one of ${Object.keys(SEED_SETS).join(', ')}` }, 400);
|
|
4744
|
+
const list = readSchedules(); const made = []; const kept = [];
|
|
4745
|
+
for (const def of set) {
|
|
4746
|
+
if (!taskById(def.skill, w.root)) { kept.push({ skill: def.skill, why: 'the skill is not installed' }); continue; }
|
|
4747
|
+
if (list.some((x) => x.ws === w.id && x.skill === def.skill)) { kept.push({ skill: def.skill, why: 'already scheduled' }); continue; }
|
|
4748
|
+
const norm = scheduleLogic.normalizeSchedule({ ...def, task: def.skill });
|
|
4749
|
+
if (!norm.ok) { kept.push({ skill: def.skill, why: norm.error }); continue; }
|
|
4750
|
+
const sched = { id: 'sc-' + Date.now().toString(36) + made.length, ws: w.id, ...norm.schedule, profile: null, lastRun: null, lastStatus: null };
|
|
4751
|
+
sched.nextRun = scheduleLogic.nextRun(sched);
|
|
4752
|
+
list.unshift(sched); made.push(sched);
|
|
4753
|
+
}
|
|
4754
|
+
if (made.length) writeSchedules(list);
|
|
4755
|
+
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 });
|
|
4756
|
+
}
|
|
4262
4757
|
if (p === '/api/schedules/update' && req.method === 'POST') {
|
|
4263
4758
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
4264
4759
|
const list = readSchedules(); const s = list.find((x) => x.id === body.id);
|
|
4265
4760
|
if (!s) return json(res, { ok: false, error: 'no such schedule' }, 404);
|
|
4266
4761
|
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];
|
|
4762
|
+
for (const k of ['name', 'cadence', 'day', 'dayOfMonth', 'time', 'task', 'skill', 'date', 'args']) if (body[k] != null) s[k] = body[k];
|
|
4763
|
+
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
4764
|
// `profile` is settable to null (clear it) as well as to an id, so it is checked for PRESENCE
|
|
4269
4765
|
// of the key rather than for a non-null value — the other fields above cannot be cleared.
|
|
4270
4766
|
if ('profile' in body) {
|
|
@@ -4293,6 +4789,27 @@ async function handleRequest(req, res) {
|
|
|
4293
4789
|
let md; try { md = fs.readFileSync(path.join(DELIV_DIR, id + '.md'), 'utf8'); } catch { res.writeHead(404); return res.end('not found'); }
|
|
4294
4790
|
res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' }); return res.end(md);
|
|
4295
4791
|
}
|
|
4792
|
+
if (p === '/api/session/model' && req.method === 'POST') {
|
|
4793
|
+
// D75 — switch the model of a RUNNING Claude session, on the PM's press only. It types the harness's own
|
|
4794
|
+
// `/model <model>` command into the pane; effort is not changed by it. Never automatic, never for a binary
|
|
4795
|
+
// PMOS has not checked, and a pinned review route cannot be lowered here either.
|
|
4796
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
4797
|
+
const s = sessions.get(String(body.id || ''));
|
|
4798
|
+
if (!s) return json(res, { ok: false, error: 'no such session' }, 404);
|
|
4799
|
+
if (!s.pty || s.state === 'exited') return json(res, { ok: false, error: 'that session is not running — there is nothing to switch' }, 400);
|
|
4800
|
+
if (s.agent !== 'claude') return json(res, { ok: false, error: `'${s.agent}' has no model command PMOS has checked — nothing was typed` }, 400);
|
|
4801
|
+
const r = modelLib.resolveForSpawn(s.root, s.agent, { explicit: { tier: body.tier, model: body.model }, skill: s.modelAsk && s.modelAsk.skill });
|
|
4802
|
+
if (!r.ok) return json(res, { ok: false, error: r.error }, 400);
|
|
4803
|
+
if (!r.model) return json(res, { ok: false, error: 'say a tier (fast, balanced, deep) or a model' }, 400);
|
|
4804
|
+
try { s.pty.write(`/model ${r.model}\r`); }
|
|
4805
|
+
catch (e) { return json(res, { ok: false, error: 'could not write to the session — ' + String((e && e.message) || e).slice(0, 100) }, 500); }
|
|
4806
|
+
const at = nowISO();
|
|
4807
|
+
s.model = { ...(s.model || {}), state: 'applied', switched: { model: r.model, tier: r.tier, at } };
|
|
4808
|
+
persist(s); pushSessions();
|
|
4809
|
+
const mc = modelLib.core();
|
|
4810
|
+
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' });
|
|
4811
|
+
return json(res, { ok: true, model: r.model, tier: r.tier, note: 'typed /model into the pane; effort is unchanged' });
|
|
4812
|
+
}
|
|
4296
4813
|
if (p === '/api/sessions') return json(res, { sessions: sessionList() });
|
|
4297
4814
|
if (p === '/api/git/branches') {
|
|
4298
4815
|
// Read-only branch awareness for a workspace. A non-repo returns {ok:false} (never a fake
|
|
@@ -4403,14 +4920,20 @@ async function handleRequest(req, res) {
|
|
|
4403
4920
|
if (!w) { res.writeHead(404); return res.end('no workspace'); }
|
|
4404
4921
|
const full = path.resolve(w.root, url.searchParams.get('path') || '');
|
|
4405
4922
|
if (!insideRoot(w.root, full)) { res.writeHead(404); return res.end('outside workspace'); }
|
|
4406
|
-
const mime = preview.
|
|
4923
|
+
const mime = preview.assetMime(path.basename(full));
|
|
4407
4924
|
if (!mime) { res.writeHead(404); return res.end('not a servable asset'); }
|
|
4408
4925
|
return fs.readFile(full, (err, buf) => {
|
|
4409
4926
|
if (err) { res.writeHead(404); return res.end('not found'); }
|
|
4410
4927
|
res.writeHead(200, {
|
|
4411
4928
|
'Content-Type': mime,
|
|
4412
4929
|
'X-Content-Type-Options': 'nosniff',
|
|
4413
|
-
|
|
4930
|
+
// An image keeps the script-blocking sandbox. A PDF cannot: the CSP `sandbox` directive
|
|
4931
|
+
// disables the browser's built-in PDF viewer, which is the whole point of serving it
|
|
4932
|
+
// (overhaul-r2 D1). It still gets `default-src 'none'` — nothing it references is fetched —
|
|
4933
|
+
// and it is only ever a file inside this workspace's root, on 127.0.0.1.
|
|
4934
|
+
'Content-Security-Policy': mime === 'application/pdf'
|
|
4935
|
+
? "default-src 'none'; style-src 'unsafe-inline'"
|
|
4936
|
+
: "default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
|
4414
4937
|
});
|
|
4415
4938
|
res.end(buf);
|
|
4416
4939
|
});
|
|
@@ -4630,6 +5153,15 @@ async function handleRequest(req, res) {
|
|
|
4630
5153
|
// share no field (`{session, line, after}` vs `{path, kind, match, openable}`), the scrollback
|
|
4631
5154
|
// search scrubs secrets out of text an agent typed, and the file search reports a walk bound.
|
|
4632
5155
|
// Collapsing them into one payload would mean one of the two lying about what it looked at.
|
|
5156
|
+
if (p === '/api/portfolio' && req.method === 'GET') return json(res, portfolio());
|
|
5157
|
+
if (p === '/api/reports' && req.method === 'POST') {
|
|
5158
|
+
let body = {};
|
|
5159
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5160
|
+
let out;
|
|
5161
|
+
try { out = portfolioReport(String(body.kind || ''), body.q); }
|
|
5162
|
+
catch (e) { out = { ok: false, error: 'the report could not be written — ' + String((e && e.message) || 'error').slice(0, 140) }; }
|
|
5163
|
+
return json(res, out, out.ok ? 200 : (out.reason === 'no-hub' ? 409 : 400));
|
|
5164
|
+
}
|
|
4633
5165
|
if (p === '/api/search/files') {
|
|
4634
5166
|
// `search everything…`, finally over everything: every file under THIS workspace's root, by
|
|
4635
5167
|
// path and by content. There is no path parameter — the query is a needle, not a location — so
|
|
@@ -4641,7 +5173,19 @@ async function handleRequest(req, res) {
|
|
|
4641
5173
|
return json(res, { ok: false, error: 'no workspace to search', q: url.searchParams.get('q') || '',
|
|
4642
5174
|
hits: [], truncated: false, scanned: 0, content_scanned: 0, limit: SEARCH_LIMIT_DEFAULT });
|
|
4643
5175
|
}
|
|
4644
|
-
|
|
5176
|
+
// Every line of work of the repository, not only the tree the id names (overhaul-r1 R1): a file
|
|
5177
|
+
// that exists only on a linked worktree's branch was unfindable from its product. `tree` narrows.
|
|
5178
|
+
// overhaul-r4 B1 — `scope=all`: every registered repository's family of trees, each hit tagged
|
|
5179
|
+
// with its repository, truncation reported PER repository (a capped repo never reads as complete)
|
|
5180
|
+
if (url.searchParams.get('scope') === 'all') {
|
|
5181
|
+
const per = repoLeads().map((r) => ({ r, out: searchFamily(workspaces.get(r.ws) || w, url.searchParams.get('q'), url.searchParams.get('limit')) }));
|
|
5182
|
+
const hits = per.flatMap(({ r, out }) => (out.hits || []).map((h) => ({ ...h, repo: r.name })));
|
|
5183
|
+
return json(res, { ok: true, scope: 'all', q: url.searchParams.get('q') || '', hits,
|
|
5184
|
+
truncated: per.some(({ out }) => out.truncated), content_truncated: per.some(({ out }) => out.content_truncated),
|
|
5185
|
+
scanned: per.reduce((n, { out }) => n + (out.scanned || 0), 0), content_scanned: per.reduce((n, { out }) => n + (out.content_scanned || 0), 0),
|
|
5186
|
+
repos: per.map(({ r, out }) => ({ repo: r.name, ws: r.ws, truncated: !!out.truncated, scanned: out.scanned || 0, hits: (out.hits || []).length })) });
|
|
5187
|
+
}
|
|
5188
|
+
return json(res, { ws: w.id, ...searchFamily(w, url.searchParams.get('q'), url.searchParams.get('limit'), url.searchParams.get('tree')) });
|
|
4645
5189
|
}
|
|
4646
5190
|
if (p === '/api/search') {
|
|
4647
5191
|
// Episodic recall over this workspace's own past sessions. Localhost only, like everything
|
|
@@ -4738,6 +5282,104 @@ async function handleRequest(req, res) {
|
|
|
4738
5282
|
pm_label_recorded: rec.pm_label === 'pass' || rec.pm_label === 'fail',
|
|
4739
5283
|
});
|
|
4740
5284
|
}
|
|
5285
|
+
if (p === '/api/memory' && req.method === 'GET') {
|
|
5286
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
5287
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
5288
|
+
const c = memoryLib.core();
|
|
5289
|
+
if (!c.ok) return json(res, { ok: false, error: c.error });
|
|
5290
|
+
const cur = c.core.load(w.root);
|
|
5291
|
+
return json(res, { ok: true, ws: w.id, name: w.name, entries: cur.state.entries, pending: cur.state.pending, blocked: cur.state.blocked,
|
|
5292
|
+
usage: cur.usage, torn: cur.torn, config: { write_approval: !!cur.cfg.write_approval }, wired: memoryLib.wired(w.root),
|
|
5293
|
+
has_state_dir: !!c.core.stateDir(w.root, false) });
|
|
5294
|
+
}
|
|
5295
|
+
if (p === '/api/memory' && req.method === 'POST') {
|
|
5296
|
+
// D72 — a PM write. The actor is the PM, so it is never staged, and only the PM can confirm, approve or reject.
|
|
5297
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5298
|
+
const w = getWs(body.ws);
|
|
5299
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
5300
|
+
const c = memoryLib.core();
|
|
5301
|
+
if (!c.ok) return json(res, { ok: false, error: c.error });
|
|
5302
|
+
const out = c.core.write(w.root, { op: body.op, target: body.target, text: body.text, old: body.old, ref: body.ref,
|
|
5303
|
+
confidence: body.confidence, source: body.source, stale_after: body.stale_after, by: 'human:wawan' });
|
|
5304
|
+
return json(res, out, out.ok ? 200 : 400);
|
|
5305
|
+
}
|
|
5306
|
+
if (p === '/api/memory/config' && req.method === 'POST') {
|
|
5307
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5308
|
+
const w = getWs(body.ws);
|
|
5309
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
5310
|
+
const c = memoryLib.core();
|
|
5311
|
+
if (!c.ok) return json(res, { ok: false, error: c.error });
|
|
5312
|
+
const dir = c.core.memDir(w.root, true);
|
|
5313
|
+
const cfg = c.core.readConfig(dir);
|
|
5314
|
+
if (typeof body.write_approval === 'boolean') cfg.write_approval = body.write_approval;
|
|
5315
|
+
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'); }
|
|
5316
|
+
catch (e) { return json(res, { ok: false, error: 'could not save the setting — ' + String((e && e.message) || e).slice(0, 120) }, 500); }
|
|
5317
|
+
return json(res, { ok: true, config: { write_approval: !!cfg.write_approval } });
|
|
5318
|
+
}
|
|
5319
|
+
if (p === '/api/memory/wire' && req.method === 'POST') {
|
|
5320
|
+
// D72 — one press adds a marked block to the repository's CLAUDE.md so sessions import the snapshot.
|
|
5321
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5322
|
+
const w = getWs(body.ws);
|
|
5323
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
5324
|
+
return json(res, memoryLib.wire(w.root));
|
|
5325
|
+
}
|
|
5326
|
+
if (p === '/api/sync/status') {
|
|
5327
|
+
// Local only — no network. Safe to poll; it is what tells the PM whether a press is needed.
|
|
5328
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
5329
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
5330
|
+
return json(res, { ws: w.id, name: w.name, ...(await syncLib.status(w.root)) });
|
|
5331
|
+
}
|
|
5332
|
+
if (p === '/api/sync' && req.method === 'POST') {
|
|
5333
|
+
// D70 — the ONE place the workspace runs fetch / integrate / commit / push. Only the PM's press
|
|
5334
|
+
// reaches it; nothing schedules or chains it, and it is off every other action's request path.
|
|
5335
|
+
let body = {};
|
|
5336
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
5337
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5338
|
+
const w = getWs(body.ws);
|
|
5339
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
5340
|
+
if (syncing.has(w.root)) return json(res, { ok: false, error: 'a sync is already running for this repository' }, 409);
|
|
5341
|
+
syncing.add(w.root);
|
|
5342
|
+
try {
|
|
5343
|
+
const out = await syncLib.sync(w.root, { label: os.hostname() });
|
|
5344
|
+
boardCache.invalidate(w.root);
|
|
5345
|
+
return json(res, { ws: w.id, name: w.name, ...out });
|
|
5346
|
+
} finally { syncing.delete(w.root); }
|
|
5347
|
+
}
|
|
5348
|
+
if (p === '/api/lifecycle' && req.method === 'POST') {
|
|
5349
|
+
// D70 — close, hold, cancel or reopen an initiative. Only reached by an explicit PM press. It
|
|
5350
|
+
// appends ONE line to lifecycle.jsonl and never writes an acceptance.
|
|
5351
|
+
let body = {};
|
|
5352
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
5353
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5354
|
+
const id = String(body.initiative_id || '');
|
|
5355
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(id)) return json(res, { ok: false, error: 'initiative_id is required' }, 400);
|
|
5356
|
+
if (body.state !== 'active' && !STOPPED_STATES.includes(body.state)) {
|
|
5357
|
+
return json(res, { ok: false, error: `state must be one of ${STOPPED_STATES.concat('active').join(', ')}` }, 400);
|
|
5358
|
+
}
|
|
5359
|
+
const w = getWs(body.ws);
|
|
5360
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
5361
|
+
if (!initiativeIndex(w.root).initiatives.some((g) => g.id === id)) {
|
|
5362
|
+
return json(res, { ok: false, error: `no initiative '${id}' in ${w.name}` }, 404);
|
|
5363
|
+
}
|
|
5364
|
+
const rec = { initiative_id: id, state: body.state, reason: String(body.reason || '').slice(0, 500), pm: 'wawan', at: nowISO() };
|
|
5365
|
+
// The ledger the ENGINE reads for this repository's layout (scripts/workflow-state.js#layout): a repository
|
|
5366
|
+
// with planning/initiatives/ keeps its ledgers in planning/runs/, a kit in pmos/state/. Writing anywhere else
|
|
5367
|
+
// (R6 wrote the workspace's runtime state for PMOS-self) shows on the board but never clears the gate.
|
|
5368
|
+
let d = null;
|
|
5369
|
+
if (isDir(path.join(w.root, 'planning', 'initiatives'))) d = path.join(w.root, 'planning', 'runs');
|
|
5370
|
+
else if (isDir(path.join(w.root, 'pmos', 'state', 'initiatives'))) d = path.join(w.root, 'pmos', 'state');
|
|
5371
|
+
else d = stateDirFor(w, { create: true });
|
|
5372
|
+
if (!d) return json(res, { ok: false, error: 'this repository has no state directory to record in — nothing was written' }, 500);
|
|
5373
|
+
try { fs.mkdirSync(d, { recursive: true }); } catch { /* the guard reports it */ }
|
|
5374
|
+
const sink = path.join(d, 'lifecycle.jsonl');
|
|
5375
|
+
try { createSinkGuard({ sink, what: 'lifecycle' }).appendLine(sink, rec); }
|
|
5376
|
+
catch (e) {
|
|
5377
|
+
warnWriteFail('lifecycle.jsonl', e);
|
|
5378
|
+
return json(res, { ok: false, error: 'could not record the change — nothing was saved', sink }, 500);
|
|
5379
|
+
}
|
|
5380
|
+
boardCache.invalidate(w.root);
|
|
5381
|
+
return json(res, { ok: true, recorded: rec, sink });
|
|
5382
|
+
}
|
|
4741
5383
|
if (p === '/api/doc/save' && req.method === 'POST') {
|
|
4742
5384
|
// The editor's save (workspace-doc-editor R3/R4). The ONLY write path on this surface, and it is
|
|
4743
5385
|
// reached solely by the PM pressing save on a document they were editing. Nothing here consults
|
|
@@ -4768,7 +5410,12 @@ async function handleRequest(req, res) {
|
|
|
4768
5410
|
return json(res, { ok: false, error: saved.reason, detail: saved.detail || null }, code);
|
|
4769
5411
|
}
|
|
4770
5412
|
|
|
4771
|
-
|
|
5413
|
+
// R11 (D74) — a document that declares `generated: {by, at}` on one line says who last produced it. A save
|
|
5414
|
+
// from here is the PM's own hand, so the line is rewritten to say so. Only that one line, only in the leading
|
|
5415
|
+
// block, and only when it is already there: nothing is added to a document that never had one. The stamped text
|
|
5416
|
+
// goes back to the editor, which takes it as its new base, so the next save is not read as stale.
|
|
5417
|
+
const stamped = stampEdit(saved.text, nowISO());
|
|
5418
|
+
try { fs.writeFileSync(full, stamped); }
|
|
4772
5419
|
catch (e) {
|
|
4773
5420
|
warnWriteFail('document save (' + body.path + ')', e);
|
|
4774
5421
|
return json(res, { ok: false, error: 'could not write the document — nothing was changed' }, 500);
|
|
@@ -4792,7 +5439,7 @@ async function handleRequest(req, res) {
|
|
|
4792
5439
|
}
|
|
4793
5440
|
// The document IS saved at this point. A failed log is reported honestly rather than as success,
|
|
4794
5441
|
// 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 },
|
|
5442
|
+
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
5443
|
warning: logged ? undefined : 'the document was saved but the change could not be logged' });
|
|
4797
5444
|
}
|
|
4798
5445
|
if (p === '/api/align/candidates') {
|
|
@@ -5018,7 +5665,8 @@ function writeChangeContext(dir) {
|
|
|
5018
5665
|
const out = [
|
|
5019
5666
|
'---', 'type: Reference',
|
|
5020
5667
|
'title: "Change context — what the PM has been editing here, and why"',
|
|
5021
|
-
'
|
|
5668
|
+
'description: "A digest of the PM\'s recent edits and their reasons, regenerated on every save. For agents writing documents here."',
|
|
5669
|
+
`generated: {by: process:pmos-workspace, at: "${nowISO()}"}`, 'status: stable', '---', '',
|
|
5022
5670
|
'# Change context',
|
|
5023
5671
|
'',
|
|
5024
5672
|
'**Read this before writing documents in this repository.** It is generated from the PM\'s own',
|
|
@@ -5153,10 +5801,12 @@ wss.on('connection', (ws) => {
|
|
|
5153
5801
|
// `initiative` is what the client has always held and never sent: `+ session` is clicked on
|
|
5154
5802
|
// an initiative row, the tab carries its `slug`, and the spawn message named three other
|
|
5155
5803
|
// 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
|
|
5804
|
+
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,
|
|
5805
|
+
// D75 — what the composer chose (a tier, or a model), carried like the profile. Absent = not asked.
|
|
5806
|
+
modelAsk: (m.tier || m.model) ? { explicit: { tier: m.tier, model: m.model, effort: m.effort } } : null });
|
|
5157
5807
|
// A refused profile is its own sentence, not "branch: …" — the composer's error line names
|
|
5158
5808
|
// 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; }
|
|
5809
|
+
if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^(profile|model choice) /.test(r.error || '') ? '' : 'branch: ') + r.error })); return; }
|
|
5160
5810
|
// a non-fatal truth about what just happened (a worktree's path, a branch ignored on a
|
|
5161
5811
|
// non-repo) — said out loud, never swallowed
|
|
5162
5812
|
if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
|
|
@@ -5176,7 +5826,7 @@ wss.on('connection', (ws) => {
|
|
|
5176
5826
|
// server already holds, not re-sent by the client — a client that forgot it would otherwise
|
|
5177
5827
|
// silently restart the pane on a different environment, and the pane would look identical.
|
|
5178
5828
|
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 });
|
|
5829
|
+
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
5830
|
// A descriptor deleted since the original spawn does NOT get substituted. The respawn goes
|
|
5181
5831
|
// ahead with no profile — the pane line already says so — and the reason is said out loud
|
|
5182
5832
|
// rather than left for the PM to notice.
|
|
@@ -5302,4 +5952,4 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
|
|
|
5302
5952
|
// things that matter independently of a rendered page: that no declared field reaches a status,
|
|
5303
5953
|
// that an unreadable history reports rather than degrades, and that a planning-only commit does
|
|
5304
5954
|
// not count as delivery.
|
|
5305
|
-
surfaceInitiatives, deliveryIndex, deriveState, isDeliveryPath };
|
|
5955
|
+
surfaceInitiatives, readLifecycle, deliveryIndex, deriveState, isDeliveryPath };
|