create-pmos 0.14.0 → 0.16.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/README.md +57 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/README.md +44 -12
- package/workspace/design-parity.js +358 -0
- package/workspace/lib/console-state.js +48 -1
- package/workspace/lib/engine/workflow-state.js +13 -3
- package/workspace/lib/find-browser.js +60 -0
- package/workspace/lib/focus.js +967 -0
- package/workspace/lib/four-panel.js +345 -0
- package/workspace/lib/git-info.js +28 -1
- package/workspace/lib/handoff.js +280 -0
- package/workspace/lib/layout.js +68 -46
- package/workspace/lib/mobile-nav.js +157 -0
- package/workspace/lib/profiles.js +285 -0
- package/workspace/lib/repo-scan.js +469 -0
- package/workspace/package-lock.json +29 -2
- package/workspace/package.json +14 -4
- package/workspace/public/index.html +79 -279
- package/workspace/public/pm/app.js +242 -0
- package/workspace/public/pm/data.js +877 -0
- package/workspace/public/pm/dom.js +116 -0
- package/workspace/public/pm/fixture.js +979 -0
- package/workspace/public/pm/mobile.css +200 -0
- package/workspace/public/pm/mobile.js +403 -0
- package/workspace/public/pm/modes.js +1076 -0
- package/workspace/public/pm/panel-detail.js +648 -0
- package/workspace/public/pm/panel-initiatives.js +426 -0
- package/workspace/public/pm/panel-repos.js +460 -0
- package/workspace/public/pm/panel-tabs.js +487 -0
- package/workspace/public/pm/state.js +164 -0
- package/workspace/public/pm/tab-doc.js +279 -0
- package/workspace/public/pm/tab-task.js +83 -0
- package/workspace/public/pm/tab-term.js +560 -0
- package/workspace/public/pm/tokens.css +73 -0
- package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
- package/workspace/server.js +1072 -31
- package/workspace/public/app.js +0 -3450
- package/workspace/public/styles.css +0 -1444
package/workspace/server.js
CHANGED
|
@@ -25,7 +25,7 @@ const fs = require('fs');
|
|
|
25
25
|
const os = require('os');
|
|
26
26
|
const path = require('path');
|
|
27
27
|
const crypto = require('crypto');
|
|
28
|
-
const { execFile, spawnSync } = require('child_process');
|
|
28
|
+
const { execFile, spawnSync, spawn } = require('child_process');
|
|
29
29
|
const { WebSocketServer } = require('ws');
|
|
30
30
|
|
|
31
31
|
// node-pty is a NATIVE module; a broken/mismatched build is the #1 field failure
|
|
@@ -75,6 +75,15 @@ const tmuxControl = require('./lib/tmux-control'); // tmux -CC protocol: the
|
|
|
75
75
|
const relatedDocs = require('./lib/related-docs'); // which documents a change puts out of step (R5/R6)
|
|
76
76
|
const applyChange = require('./lib/apply-change'); // anchored + whole-document saves, refuse-on-stale
|
|
77
77
|
const sessionState = require('./lib/session-state'); // the cadence×turn join — validated on BOTH sides
|
|
78
|
+
// Bundle 2 (planning/design/bundle-2-wiring-contract.md). Four already-built pure modules. The
|
|
79
|
+
// server's whole job for these four features is to READ the filesystem and hand the result over —
|
|
80
|
+
// none of their logic is re-derived here, because a second implementation of "what does this
|
|
81
|
+
// record declare" is how two surfaces come to disagree about the same file.
|
|
82
|
+
const repoScan = require('./lib/repo-scan'); // scanRepo(root) — pure, cannot write (ingest R3)
|
|
83
|
+
const profilesLib = require('./lib/profiles'); // readProfiles(homeDir) / profileLine(profile, read)
|
|
84
|
+
const focus = require('./lib/focus'); // buildFocus(input) → FocusView (cross-product)
|
|
85
|
+
const handoff = require('./lib/handoff'); // readLinks(parsedFrontmatterObject) — never a path
|
|
86
|
+
const fourPanel = require('./lib/four-panel'); // GET /api/initiative — the one-initiative join + the declaration stores
|
|
78
87
|
// The "home" repo this server manages. In PMOS-self the app ships INSIDE its repo
|
|
79
88
|
// (pmos/workspace) so the parent dir IS the repo. But a kit/tenant copy is installed standalone
|
|
80
89
|
// — `npm i -g` puts it under node_modules/pmos-workspace, or it runs from an npx bundle — and
|
|
@@ -98,12 +107,20 @@ const EMBEDDED_SELF = !INSTALLED_COPY && SELF_ROOT === PARENT_DIR &&
|
|
|
98
107
|
// (unsupported direct run) has no home — the client must open a repo to do anything.
|
|
99
108
|
const HOME_ENABLED = EMBEDDED_SELF || (!!process.env.PMOS_SELF_ROOT && dirExists(SELF_ROOT));
|
|
100
109
|
const PUB = path.join(__dirname, 'public');
|
|
110
|
+
const PM_DIR = path.join(PUB, 'pm'); // the four-panel front end — served as a directory, guarded below
|
|
101
111
|
const XROOT = path.join(__dirname, 'node_modules', '@xterm');
|
|
102
112
|
const STATE = path.join(__dirname, 'state');
|
|
103
113
|
const SESS_DIR = path.join(STATE, 'sessions');
|
|
104
114
|
const SCRATCH_DIR = path.join(STATE, 'scratchpad'); // per-workspace staging text (D62; local-only, gitignored)
|
|
105
115
|
const LAYOUT_DIR = path.join(STATE, 'layouts'); // per-workspace arrangements (R7; local-only, gitignored)
|
|
106
116
|
const WS_FILE = path.join(STATE, 'workspaces.json');
|
|
117
|
+
// Ingest proposals live BESIDE workspaces.json, never inside the repo being scanned (ingest R3):
|
|
118
|
+
// a proposal that is never accepted must leave the clone byte-identical.
|
|
119
|
+
const INGEST_DIR = path.join(STATE, 'ingest');
|
|
120
|
+
const CLONE_DIR = path.join(STATE, 'clones');
|
|
121
|
+
// PMOS's OWN memory of which profile to preselect per agent binary (profiles R8.2). It is a
|
|
122
|
+
// workspace preference, not an agent attribute — the file lives here for exactly that reason.
|
|
123
|
+
const PREFS_FILE = path.join(STATE, 'prefs.json');
|
|
107
124
|
const PORT = process.env.PMOS_WS_PORT ? Number(process.env.PMOS_WS_PORT) : 7433;
|
|
108
125
|
const preview = require('./lib/preview-kind'); // the single reader classifier (md|text|html|image|inert)
|
|
109
126
|
const layoutModel = require('./lib/layout'); // the arrangement model — validated on BOTH sides
|
|
@@ -139,6 +156,67 @@ function sessionEnv() {
|
|
|
139
156
|
for (const k of Object.keys(env)) if (/^CLAUDE_CODE_/.test(k)) delete env[k];
|
|
140
157
|
return env;
|
|
141
158
|
}
|
|
159
|
+
|
|
160
|
+
// ── Agent profiles (workspace-profiles). ──────────────────────────────────────────────────────
|
|
161
|
+
// A profile is a DESCRIPTOR the PM already keeps at ~/.claude/profiles/*.json. PMOS reads it and
|
|
162
|
+
// applies its `env` overlay when it creates a process — that is the whole mechanism. It runs no
|
|
163
|
+
// MCP client, checks no reachability and holds no credential, so nothing here may say "connected".
|
|
164
|
+
//
|
|
165
|
+
// The read is not cached. A descriptor added while the workspace is open must appear on the next
|
|
166
|
+
// look, and a stale copy of a file whose whole purpose is provenance is worse than a re-read.
|
|
167
|
+
function readProfilesNow() { return profilesLib.readProfiles(os.homedir()); }
|
|
168
|
+
/** One descriptor by id, or null. Returns the READ too, because profileLine(null, read) needs it
|
|
169
|
+
* to tell "no profiles on this machine" from "the read failed and we cannot say". */
|
|
170
|
+
function profileById(id) {
|
|
171
|
+
const read = readProfilesNow();
|
|
172
|
+
if (id == null || id === '') return { read, profile: null };
|
|
173
|
+
const profile = (read.profiles || []).find((p) => p.id === id) || null;
|
|
174
|
+
return { read, profile };
|
|
175
|
+
}
|
|
176
|
+
// `env` VALUES NEVER CROSS THE WIRE (contract §1.3). An environment overlay is exactly where a
|
|
177
|
+
// credential lives, and D08's never-log-headers discipline covers anything the browser receives.
|
|
178
|
+
// The names are shipped so the pane can say WHAT was applied; the values are not.
|
|
179
|
+
function profileWire(p, read) {
|
|
180
|
+
const env = p && p.env && typeof p.env === 'object' ? p.env : {};
|
|
181
|
+
const keys = Object.keys(env);
|
|
182
|
+
return {
|
|
183
|
+
id: p.id, label: p.label == null ? null : p.label, account: p.account == null ? null : p.account,
|
|
184
|
+
mcp: Array.isArray(p.mcp) ? p.mcp.slice() : [],
|
|
185
|
+
env_count: keys.length, env_keys: keys,
|
|
186
|
+
// Computed HERE, once, so no surface can re-word R6's five sentences.
|
|
187
|
+
line: profilesLib.profileLine(p, read),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
// PMOS's own memory: agent binary name → preferred profile id. It preselects the composer and
|
|
191
|
+
// starts nothing (R8.2). Read fresh; a corrupt file is treated as no memory rather than crashing
|
|
192
|
+
// a spawn — the preference is a convenience, and losing it costs one click.
|
|
193
|
+
function readPrefs() {
|
|
194
|
+
try { const o = JSON.parse(fs.readFileSync(PREFS_FILE, 'utf8')); return o && typeof o === 'object' && !Array.isArray(o) ? o : {}; }
|
|
195
|
+
catch { return {}; }
|
|
196
|
+
}
|
|
197
|
+
function writePrefs(prefs) {
|
|
198
|
+
try { fs.writeFileSync(PREFS_FILE, JSON.stringify(prefs)); return null; }
|
|
199
|
+
catch (e) { warnWriteFail('prefs.json (profile preselection)', e); return `the preference could not be saved — ${String((e && e.code) || 'write failed')}`; }
|
|
200
|
+
}
|
|
201
|
+
// The merged environment a session actually runs under. The overlay goes ON TOP of sessionEnv(),
|
|
202
|
+
// AFTER the marker-stripping (R3) — never instead of it, or a profile would silently re-enable the
|
|
203
|
+
// nested-session markers sessionEnv exists to remove.
|
|
204
|
+
function sessionEnvWithProfile(profile) {
|
|
205
|
+
const env = sessionEnv();
|
|
206
|
+
for (const [k, v] of Object.entries(profileOverlay(profile))) env[k] = v;
|
|
207
|
+
return env;
|
|
208
|
+
}
|
|
209
|
+
// Just the DELTA a profile introduces, stringified — never merged with the base. The tmux path
|
|
210
|
+
// needs these separately from sessionEnv() because a pane's environment does not come from the
|
|
211
|
+
// process that ran `new-session`: see createTmuxSession.
|
|
212
|
+
function profileOverlay(profile) {
|
|
213
|
+
const out = {};
|
|
214
|
+
if (profile && profile.env && typeof profile.env === 'object') {
|
|
215
|
+
for (const k of Object.keys(profile.env)) out[k] = String(profile.env[k]);
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
142
220
|
const SCROLLBACK = 200_000; // bytes of ring buffer per session
|
|
143
221
|
// How often the server re-broadcasts the session list while anything is alive (see heartbeat()).
|
|
144
222
|
// 2s is chosen against what it drives: the state dot and the quiet clock. QUIET_AFTER_MS is 30s, so
|
|
@@ -230,10 +308,15 @@ function repoIdentity(root) {
|
|
|
230
308
|
const now = Date.now();
|
|
231
309
|
if (hit && now - hit.at < REPO_KEY_TTL_MS) return hit;
|
|
232
310
|
let key = root, name = path.basename(root), branch = null, branchError = null, branchCode = null;
|
|
311
|
+
let remote = null;
|
|
233
312
|
try {
|
|
234
313
|
const trees = gitInfo.worktrees(root);
|
|
235
314
|
if (trees.length && trees[0].dir) { key = path.resolve(trees[0].dir); name = path.basename(key); }
|
|
236
315
|
} catch { /* not a repo, or git absent — the tree is its own group, which is the truth */ }
|
|
316
|
+
// Where this tree came FROM. Memoised with the rest of the identity (same 15s TTL) because it is
|
|
317
|
+
// another `git` shell-out on a call that runs for every workspace on every push. A failure is not
|
|
318
|
+
// a fault: a folder registered by path legitimately has no origin, and null is the honest answer.
|
|
319
|
+
try { remote = gitInfo.originUrl(root); } catch { remote = null; }
|
|
237
320
|
try {
|
|
238
321
|
const bi = gitInfo.branchInfo(root);
|
|
239
322
|
if (bi.ok) branch = bi.detached ? null : bi.current;
|
|
@@ -246,7 +329,7 @@ function repoIdentity(root) {
|
|
|
246
329
|
// one drifts.
|
|
247
330
|
else { branchError = bi.error || 'branch could not be read'; branchCode = bi.code || 'git-error'; }
|
|
248
331
|
} catch { branchError = 'branch could not be read'; branchCode = 'git-error'; }
|
|
249
|
-
const val = { at: now, key, name, branch, branchError, branchCode };
|
|
332
|
+
const val = { at: now, key, name, branch, branchError, branchCode, remote };
|
|
250
333
|
repoKeyCache.set(root, val);
|
|
251
334
|
return val;
|
|
252
335
|
}
|
|
@@ -262,6 +345,14 @@ function wsList() {
|
|
|
262
345
|
// the client renders the first quietly and the second as a fault (R1/R2).
|
|
263
346
|
repo_key: rid.key, repo_name: rid.name, branch: rid.branch,
|
|
264
347
|
branch_error: rid.branchError, branch_code: rid.branchCode,
|
|
348
|
+
// Where the tree came from, and the ONE label the repository list can honestly put on a row.
|
|
349
|
+
// `remote` is the raw origin URL (null when there is none) so a caller can derive a richer
|
|
350
|
+
// answer without guessing; `kind` is the surface's own two-value vocabulary — a repository
|
|
351
|
+
// cloned from GitHub, or a folder on this machine. A remote that is not GitHub's reads as
|
|
352
|
+
// `local`, because `github` would be a claim about a host nobody contacted. Deriving `github`
|
|
353
|
+
// from the mere presence of a `.git` directory is what this replaces.
|
|
354
|
+
remote: rid.remote,
|
|
355
|
+
kind: gitInfo.isGithubRemote(rid.remote) ? 'github' : 'local',
|
|
265
356
|
};
|
|
266
357
|
});
|
|
267
358
|
}
|
|
@@ -669,9 +760,34 @@ function surfaceRoadmap(root, w) {
|
|
|
669
760
|
// (KR\d+) — so `## O-INTEGRITY` and `KR-C` were invisible here while the ladder happily accepted
|
|
670
761
|
// anchors naming them. One field with two opinions is the defect this initiative removes; a second
|
|
671
762
|
// parser would have been the same bug wearing a different regex.
|
|
763
|
+
/* WHERE THE RESOLVER ACTUALLY LIVES — the same two-location lookup `lib/console-state.js` already
|
|
764
|
+
* does for the workflow engine, and for the same reason (incident kit-workflow-engine-missing).
|
|
765
|
+
* This function looked ONLY at `../scripts/anchor-resolve.js`, which exists in a PMOS checkout and
|
|
766
|
+
* nowhere else: the published package ships bin/ payload/ workspace/, so in an adopter's install
|
|
767
|
+
* there is no `scripts/` beside the app and every OKR and spine read answered "the anchor resolver
|
|
768
|
+
* is not present in this install". Panel 1 showed no count and panel 2 no groups, in every release
|
|
769
|
+
* that had the surface.
|
|
770
|
+
*
|
|
771
|
+
* It was invisible to every existing check because a dev checkout always has the file one directory
|
|
772
|
+
* up — the identical blind spot that hid the engine. It was found by launching the INSTALLED app
|
|
773
|
+
* with egress blocked (.github/scripts/offline-workspace-smoke.js), which is the shape adopters get
|
|
774
|
+
* and the shape nothing used to run.
|
|
775
|
+
*
|
|
776
|
+
* 1. `../scripts/anchor-resolve.js` — a repo checkout, preferred so a dev panel and CI cannot
|
|
777
|
+
* disagree about what resolves.
|
|
778
|
+
* 2. `lib/engine/anchor-resolve.js` — the copy `scripts/vendor-workspace-engine.js` vendors into
|
|
779
|
+
* the bundle (it is in the engine's own require closure, so it is already shipped). This is
|
|
780
|
+
* what an adopter runs. */
|
|
781
|
+
const ANCHOR_LIB_PATHS = [
|
|
782
|
+
path.join(__dirname, '..', 'scripts', 'anchor-resolve.js'),
|
|
783
|
+
path.join(__dirname, 'lib', 'engine', 'anchor-resolve.js'),
|
|
784
|
+
];
|
|
672
785
|
function anchorLib() {
|
|
673
|
-
|
|
674
|
-
|
|
786
|
+
for (const p of ANCHOR_LIB_PATHS) {
|
|
787
|
+
if (!fs.existsSync(p)) continue;
|
|
788
|
+
try { return require(p); } catch { return null; }
|
|
789
|
+
}
|
|
790
|
+
return null;
|
|
675
791
|
}
|
|
676
792
|
function surfaceOkr(root) {
|
|
677
793
|
const A = anchorLib();
|
|
@@ -717,6 +833,97 @@ function surfaceOkr(root) {
|
|
|
717
833
|
note: 'KR current/target and the lever tree are net-new state — shown as "— not tracked"',
|
|
718
834
|
};
|
|
719
835
|
}
|
|
836
|
+
// ── The PM spine (workspace-pm-spine): Objective → Key Result → Initiative. ────────────────────
|
|
837
|
+
// surfaceOkr() above reads the SAME objectives and KRs and deliberately stops short — its comment
|
|
838
|
+
// calls the initiative↔KR join "an explicit non-goal". That left the workspace able to say an
|
|
839
|
+
// anchor is VALID while unable to say what a KR is MADE OF, and left the UI rooted on the delivery
|
|
840
|
+
// substrate (repo → tree → session) with the initiative reduced to a string parsed out of a branch
|
|
841
|
+
// name. This function is that join, and nothing more: it re-uses anchor-resolve.js rather than
|
|
842
|
+
// re-parsing anything, so there is exactly one definition of what an anchor means (R1).
|
|
843
|
+
//
|
|
844
|
+
// It deliberately returns NO runtime state. Which working trees and sessions are on an initiative
|
|
845
|
+
// is a client-side attachment (the client already holds both, live over the socket) — keeping the
|
|
846
|
+
// server's answer purely the PM structure means a spine that is correct whether or not anything is
|
|
847
|
+
// currently checked out, which is the case R2 exists to make possible.
|
|
848
|
+
function surfaceSpine(root) {
|
|
849
|
+
const A = anchorLib();
|
|
850
|
+
if (!A) {
|
|
851
|
+
return { objectives: [], budgets: [], unanchored: [], unreadable: 'the anchor resolver (scripts/anchor-resolve.js) is not present in this install — the spine cannot be derived here' };
|
|
852
|
+
}
|
|
853
|
+
const index = A.parseOkrIndex(root);
|
|
854
|
+
// R5 — a read that FAILED is not a read that found nothing. Unreadable renders RED; the calm
|
|
855
|
+
// empty state is reserved for a successful read of genuinely nothing.
|
|
856
|
+
if (!index.ok) {
|
|
857
|
+
return { objectives: [], budgets: [], unanchored: [], unreadable: index.error || 'the OKR documents could not be read' };
|
|
858
|
+
}
|
|
859
|
+
const budgetsDoc = A.readBudgets(root);
|
|
860
|
+
|
|
861
|
+
// the empty spine, keyed for O(1) attachment below
|
|
862
|
+
const objectives = index.objectives.map((o) => ({
|
|
863
|
+
id: o.token, key: o.id, name: (o.name || '').slice(0, 140), file: o.file,
|
|
864
|
+
krs: o.krs.map((k) => ({ id: k.token, key: k.id, name: (k.name || '').slice(0, 160), initiatives: [] })),
|
|
865
|
+
}));
|
|
866
|
+
const krByKey = new Map();
|
|
867
|
+
for (const o of objectives) for (const k of o.krs) krByKey.set(`${o.key}::${k.key}`, k);
|
|
868
|
+
const objByKey = new Map(objectives.map((o) => [o.key, o]));
|
|
869
|
+
|
|
870
|
+
const budgets = new Map(); // health:<id> groups — peers of objectives (D59), not a footnote
|
|
871
|
+
const unanchored = []; // R6 — never dropped to make the tree tidy
|
|
872
|
+
|
|
873
|
+
for (const dir of findDirsNamed(root, 'initiatives', BUNDLE_MAX_DEPTH)) {
|
|
874
|
+
let files = [];
|
|
875
|
+
try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort(); } catch { continue; }
|
|
876
|
+
for (const f of files) {
|
|
877
|
+
if (f === 'README.md' || f === 'index.md') continue;
|
|
878
|
+
const fm = leadingFrontmatter(path.join(dir, f)) || {};
|
|
879
|
+
const id = String(fm.id || f.replace(/\.md$/, ''));
|
|
880
|
+
const rec = {
|
|
881
|
+
id,
|
|
882
|
+
title: String(fm.title || '').slice(0, 160),
|
|
883
|
+
lane: String(fm.lane || 'feature'),
|
|
884
|
+
file: path.relative(root, path.join(dir, f)),
|
|
885
|
+
// R7 — position is DERIVED by the workflow engine. No `stage:`/`status:` is read for it;
|
|
886
|
+
// `status` is carried only because the chore lane's intent lives there as prose.
|
|
887
|
+
has_spec: !!String(fm.prd_path || '').trim(),
|
|
888
|
+
has_rubric: !!String(fm.rubric_path || '').trim(),
|
|
889
|
+
};
|
|
890
|
+
const raw = fm.parent_kr == null ? '' : String(fm.parent_kr);
|
|
891
|
+
const r = A.resolveAnchor(raw, index, budgetsDoc);
|
|
892
|
+
|
|
893
|
+
if (r.kind === 'health' && r.status === 'resolved') {
|
|
894
|
+
const key = `health:${r.budget}`;
|
|
895
|
+
if (!budgets.has(key)) budgets.set(key, { id: key, name: r.budget, initiatives: [] });
|
|
896
|
+
budgets.get(key).initiatives.push(rec);
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
if (r.kind === 'okr' && r.status === 'resolved') {
|
|
900
|
+
const kr = r.kr ? krByKey.get(`${A.objectiveId(r.objective)}::${A.krId(r.kr)}`) : null;
|
|
901
|
+
if (kr) { kr.initiatives.push(rec); continue; }
|
|
902
|
+
// resolved to an OBJECTIVE with no KR named — real and legitimate, but it has no KR bucket
|
|
903
|
+
const obj = objByKey.get(A.objectiveId(r.objective));
|
|
904
|
+
if (obj) {
|
|
905
|
+
if (!obj.direct) obj.direct = [];
|
|
906
|
+
obj.direct.push(rec);
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
// R6 — anything that did not resolve is LISTED, with the resolver's own reason. This is the
|
|
911
|
+
// set surfaceOkr() only ever counted.
|
|
912
|
+
unanchored.push(Object.assign({}, rec, {
|
|
913
|
+
raw: raw.trim().slice(0, 100),
|
|
914
|
+
status: r.status,
|
|
915
|
+
reason: r.reason || 'this anchor does not resolve against the OKR documents or the health-budget registry',
|
|
916
|
+
}));
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return {
|
|
920
|
+
objectives,
|
|
921
|
+
budgets: [...budgets.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
|
922
|
+
unanchored,
|
|
923
|
+
unreadable: null,
|
|
924
|
+
budgets_readable: !!(budgetsDoc && budgetsDoc.ok),
|
|
925
|
+
};
|
|
926
|
+
}
|
|
720
927
|
function surfaceReport(root, w) {
|
|
721
928
|
const s = surfaceInitiatives(root, w);
|
|
722
929
|
const line = (i) => `${i.id}${i.title ? ' — ' + i.title : ''}`;
|
|
@@ -798,12 +1005,19 @@ function fireSchedule(sched) {
|
|
|
798
1005
|
// AGENT task — fire as a real PTY session running its skill (async; the skill files its own
|
|
799
1006
|
// deliverable). The workspace starts it + stamps 'started'; run-now and the tick share this path.
|
|
800
1007
|
if (task && task.kind === 'agent') {
|
|
1008
|
+
// The schedule's recorded profile travels with the process it creates (profiles R8.3). Only an
|
|
1009
|
+
// AGENT-kind task has a PTY, which is why `profile_applies` is false for the skill-kind rows
|
|
1010
|
+
// below rather than the control simply being hidden.
|
|
801
1011
|
const s = spawnSession({ name: sched.name, agent: task.agent || 'claude', ws: sched.ws,
|
|
802
|
-
origin: 'schedule:' + sched.id, initialInput: task.prompt ? task.prompt + '\r' : null
|
|
1012
|
+
origin: 'schedule:' + sched.id, initialInput: task.prompt ? task.prompt + '\r' : null,
|
|
1013
|
+
profile: sched.profile == null ? null : sched.profile });
|
|
803
1014
|
const list = readSchedules(); const cur = list.find((x) => x.id === sched.id);
|
|
804
1015
|
const started = s.state !== 'exited';
|
|
805
1016
|
if (cur) { cur.lastRun = nowISO(); cur.lastStatus = started ? 'started' : 'fail'; cur.nextRun = scheduleLogic.nextRun(cur); writeSchedules(list); }
|
|
806
|
-
|
|
1017
|
+
// A run that started WITHOUT the profile it recorded is reported as such rather than as a
|
|
1018
|
+
// clean start: the session is real, the environment is not the one the schedule declared.
|
|
1019
|
+
return { ok: started, kind: 'agent', sessionId: s.id,
|
|
1020
|
+
reason: started ? (s.profileRefusal || '') : (s.note || 'could not start the session') };
|
|
807
1021
|
}
|
|
808
1022
|
const runId = `sched-${sched.id}-${Date.now()}`;
|
|
809
1023
|
let ok = false, deliverable = null, reason = '';
|
|
@@ -855,6 +1069,11 @@ function persist(s) {
|
|
|
855
1069
|
// so a session survives even if the workspace registry is lost (it re-registers its root).
|
|
856
1070
|
const meta = { id: s.id, ws: s.ws, root: s.root, name: s.name, agent: s.agent, cwd: s.cwd,
|
|
857
1071
|
state: s.state, note: s.note || '', created: s.created, tmux: s.tmux || null, origin: s.origin || null };
|
|
1072
|
+
// The profile ID ONLY — never the descriptor and never its env, which is where a credential
|
|
1073
|
+
// would live (D08). The key is written only for a session PMOS created, so its ABSENCE stays a
|
|
1074
|
+
// fact: a record persisted before profiles existed restores as "not recorded", and inventing a
|
|
1075
|
+
// key for the past would be a lie about it.
|
|
1076
|
+
if ('profile' in s) meta.profile = s.profile == null ? null : s.profile;
|
|
858
1077
|
try {
|
|
859
1078
|
fs.writeFileSync(path.join(SESS_DIR, s.id + '.json'), JSON.stringify(meta));
|
|
860
1079
|
fs.writeFileSync(path.join(SESS_DIR, s.id + '.scrollback'), s.buf);
|
|
@@ -866,7 +1085,7 @@ function unpersist(id) {
|
|
|
866
1085
|
}
|
|
867
1086
|
}
|
|
868
1087
|
// Scrollback written by the PRE-control-mode architecture carries the tmux CLIENT's own screen
|
|
869
|
-
// setup, and that gets replayed verbatim into a fresh xterm.js on attach (public/
|
|
1088
|
+
// setup, and that gets replayed verbatim into a fresh xterm.js on attach (public/pm/tab-term.js) — putting
|
|
870
1089
|
// the terminal straight back into the alternate buffer, where there is no scrollback. Strip it.
|
|
871
1090
|
//
|
|
872
1091
|
// The two signatures below are the client's, not an application's: the alt-screen switch IMMEDIATELY
|
|
@@ -1078,6 +1297,12 @@ function harnessOf(s) {
|
|
|
1078
1297
|
}
|
|
1079
1298
|
function sessionList() {
|
|
1080
1299
|
const now = Date.now();
|
|
1300
|
+
// ONE profiles read per broadcast, not one per session: the line is a formatter over the
|
|
1301
|
+
// descriptor, and re-reading the directory per row would turn a 2s heartbeat into a directory
|
|
1302
|
+
// scan per pane. A read that FAILED is passed through as-is — profileLine() needs to see the
|
|
1303
|
+
// failure to avoid claiming "no profiles are configured on this machine" when PMOS never looked.
|
|
1304
|
+
const pread = readProfilesNow();
|
|
1305
|
+
const byId = new Map((pread.profiles || []).map((p) => [p.id, p]));
|
|
1081
1306
|
return [...sessions.values()].map((s) => {
|
|
1082
1307
|
const d = harness.deriveState(s.state, s.lastOutputAt, now);
|
|
1083
1308
|
const h = harnessOf(s);
|
|
@@ -1098,6 +1323,24 @@ function sessionList() {
|
|
|
1098
1323
|
// double the transcript work the TX_REREAD_MS floor exists to bound.
|
|
1099
1324
|
hx,
|
|
1100
1325
|
missing: h.missing,
|
|
1326
|
+
// ── profiles (workspace-profiles R6). Two fields, and the second is a SENTENCE. ──
|
|
1327
|
+
// `profile_line` is always a string, computed here rather than on the client, because the
|
|
1328
|
+
// three no-descriptor cases are distinguished by the ARGUMENT'S OWN TYPE and `undefined`
|
|
1329
|
+
// cannot survive JSON.stringify: a session PMOS never started (no `profile` key at all →
|
|
1330
|
+
// "not recorded") and one PMOS started with nothing picked (`null` → "none was chosen") would
|
|
1331
|
+
// arrive on the wire identical. Computing the sentence server-side is what preserves them.
|
|
1332
|
+
// A recorded id whose descriptor has since been deleted resolves to no descriptor, and the
|
|
1333
|
+
// line says so rather than substituting another profile (R9).
|
|
1334
|
+
// A recorded id whose descriptor has since been deleted is handed to profileLine() AS THE
|
|
1335
|
+
// BARE ID — the module rejects a bare string on purpose ("the recorded value is not a
|
|
1336
|
+
// profile descriptor"), which is the honest answer. Resolving it to `null` instead would
|
|
1337
|
+
// print "none was chosen" over a session that did choose one (R9).
|
|
1338
|
+
profile_id: 'profile' in s ? (s.profile == null ? null : s.profile) : null,
|
|
1339
|
+
profile_line: profilesLib.profileLine(
|
|
1340
|
+
!('profile' in s) ? undefined
|
|
1341
|
+
: s.profile == null ? null
|
|
1342
|
+
: (byId.get(s.profile) || s.profile),
|
|
1343
|
+
pread),
|
|
1101
1344
|
};
|
|
1102
1345
|
});
|
|
1103
1346
|
}
|
|
@@ -1241,7 +1484,10 @@ function writeTmuxConf() {
|
|
|
1241
1484
|
}
|
|
1242
1485
|
// A tmux CONTROL command (not the attach PTY). Uses the CLEANED session env so that the cold
|
|
1243
1486
|
// start this may trigger seeds the tmux server's own environment marker-free (D62 / sessionEnv).
|
|
1244
|
-
|
|
1487
|
+
// `env` is an OPT-IN override and only `new-session` ever passes one (profiles R3). has-session /
|
|
1488
|
+
// kill-session / list-sessions / display-message have no session context, so handing them a
|
|
1489
|
+
// profile's overlay would apply it to whichever pane the command happened to touch — a silent bug.
|
|
1490
|
+
function tmuxCtl(args, env) { return spawnSync(TMUX, ['-L', TMUX_SOCK, ...args], { env: env || sessionEnv(), encoding: 'utf8' }); }
|
|
1245
1491
|
function tmuxHasSession(name) { return !!TMUX && tmuxCtl(['has-session', '-t', name]).status === 0; }
|
|
1246
1492
|
function tmuxKillSession(name) { if (TMUX && name) tmuxCtl(['kill-session', '-t', name]); }
|
|
1247
1493
|
// Create the tmux session a FRESH spawn runs in. It REFUSES a name that already exists rather
|
|
@@ -1249,10 +1495,25 @@ function tmuxKillSession(name) { if (TMUX && name) tmuxCtl(['kill-session', '-t'
|
|
|
1249
1495
|
// name is someone else's process, in someone else's directory. Silently reusing it is how a new
|
|
1250
1496
|
// session came up in the wrong repo. freshTmuxName makes the collision practically impossible;
|
|
1251
1497
|
// this is the floor that keeps it impossible even if it were not.
|
|
1252
|
-
|
|
1498
|
+
//
|
|
1499
|
+
// THE PROFILE OVERLAY TRAVELS AS `-e`, NEVER AS THIS PROCESS'S ENV. A pane does not inherit the
|
|
1500
|
+
// environment of the process that ran `new-session` — it inherits the tmux SERVER's, which was
|
|
1501
|
+
// fixed at whatever cold-started the daemon. Handing the overlay to the client therefore did two
|
|
1502
|
+
// wrong things at once, both measured on tmux 3.4 before this line changed:
|
|
1503
|
+
// · the FIRST profiled session cold-started the server and seeded ITS overlay into the daemon,
|
|
1504
|
+
// so every later pane on the socket inherited it — a `personal` session and a session with no
|
|
1505
|
+
// profile at all both came up carrying the `work` overlay;
|
|
1506
|
+
// · while the pane's own profile line went on naming the profile that session had asked for.
|
|
1507
|
+
// A workspace that runs a personal session under work credentials and then TELLS the PM it did
|
|
1508
|
+
// not is the honest-state contract inverted, so this is a correctness fix and not a tidy-up.
|
|
1509
|
+
// `new-session -e K=V` (tmux ≥3.2) sets the environment of THAT session only; a session that
|
|
1510
|
+
// declares no profile is left genuinely unset rather than inheriting a neighbour's.
|
|
1511
|
+
function createTmuxSession(name, cmd, workdir, cols, rows, overlay) {
|
|
1253
1512
|
if (tmuxHasSession(name)) return false;
|
|
1513
|
+
const eArgs = [];
|
|
1514
|
+
for (const [k, v] of Object.entries(overlay || {})) eArgs.push('-e', `${k}=${v}`);
|
|
1254
1515
|
const r = tmuxCtl(['-f', TMUX_CONF, 'new-session', '-d', '-s', name,
|
|
1255
|
-
'-x', String(cols || 80), '-y', String(rows || 24), '-c', workdir, '--', cmd]);
|
|
1516
|
+
...eArgs, '-x', String(cols || 80), '-y', String(rows || 24), '-c', workdir, '--', cmd]);
|
|
1256
1517
|
return r.status === 0;
|
|
1257
1518
|
}
|
|
1258
1519
|
// Every session alive on our private socket, with the directory its pane is actually sitting in.
|
|
@@ -1396,8 +1657,8 @@ function attachPty(s, { seed = false } = {}) {
|
|
|
1396
1657
|
}
|
|
1397
1658
|
|
|
1398
1659
|
// Try a command, falling back to `sh` on posix if the preferred shell can't spawn.
|
|
1399
|
-
function spawnPty(cmd, workdir) {
|
|
1400
|
-
const opts = { name: 'xterm-256color', cols: 80, rows: 24, cwd: workdir, env: sessionEnv() };
|
|
1660
|
+
function spawnPty(cmd, workdir, env) {
|
|
1661
|
+
const opts = { name: 'xterm-256color', cols: 80, rows: 24, cwd: workdir, env: env || sessionEnv() };
|
|
1401
1662
|
const chain = process.platform === 'win32' ? [cmd] : [...new Set([cmd, 'sh', '/bin/sh'])];
|
|
1402
1663
|
let lastErr;
|
|
1403
1664
|
for (const c of chain) {
|
|
@@ -1432,7 +1693,24 @@ function nextSessionId() {
|
|
|
1432
1693
|
return id;
|
|
1433
1694
|
}
|
|
1434
1695
|
|
|
1435
|
-
|
|
1696
|
+
// Resolve a requested profile id BEFORE anything is spawned (profiles R3/R9). The rule the whole
|
|
1697
|
+
// feature turns on: a session must never claim a profile whose overlay was not applied. So the
|
|
1698
|
+
// three outcomes are kept apart — nothing asked for, asked for and readable, asked for and gone —
|
|
1699
|
+
// and callers that can refuse (the composer) refuse rather than start something mislabelled.
|
|
1700
|
+
function resolveProfileForSpawn(id) {
|
|
1701
|
+
if (id == null || id === '') return { ok: true, id: null, profile: null };
|
|
1702
|
+
const { read, profile } = profileById(String(id));
|
|
1703
|
+
if (profile) return { ok: true, id: profile.id, profile };
|
|
1704
|
+
if (!read.ok) return { ok: false, id: null, profile: null, error: `profile "${id}" could not be checked — ${read.error}` };
|
|
1705
|
+
const bad = (read.broken || []).find((b) => b.file === `${id}.json`);
|
|
1706
|
+
if (bad) return { ok: false, id: null, profile: null, error: `profile "${id}" is not usable — ${bad.file} ${bad.error}` };
|
|
1707
|
+
return { ok: false, id: null, profile: null,
|
|
1708
|
+
error: read.absent
|
|
1709
|
+
? `profile "${id}" was asked for, but ${read.dir} does not exist — no descriptor was read and nothing would be applied`
|
|
1710
|
+
: `profile "${id}" is not among the descriptors in ${read.dir} — PMOS will not substitute another` };
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
|
|
1436
1714
|
const id = nextSessionId();
|
|
1437
1715
|
const w = getWs(ws);
|
|
1438
1716
|
const root = w ? w.root : SELF_ROOT;
|
|
@@ -1445,7 +1723,14 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
|
|
|
1445
1723
|
const cmd = isShell ? (commandExists(SHELL) ? SHELL : 'sh') : agent;
|
|
1446
1724
|
// a session's working directory is confined to its OWN workspace root (R3)
|
|
1447
1725
|
const workdir = cwd && insideRoot(root, path.resolve(root, cwd)) ? path.resolve(root, cwd) : root;
|
|
1448
|
-
|
|
1726
|
+
// Resolved once, here, so every path below spawns under the SAME environment the record claims.
|
|
1727
|
+
// `pres.id` is null whenever the overlay was not applied — including when a profile was asked for
|
|
1728
|
+
// and could not be read — so the pane line can never name a profile that did nothing (R3/R9).
|
|
1729
|
+
// `profileRefusal` is server-internal: callers that can refuse read it and say why.
|
|
1730
|
+
const pres = resolveProfileForSpawn(profile);
|
|
1731
|
+
const penv = sessionEnvWithProfile(pres.profile);
|
|
1732
|
+
const base = { id, ws: w ? w.id : wsId(SELF_ROOT), root, name: (name && String(name).trim()) || agent, agent, origin: tag,
|
|
1733
|
+
profile: pres.id, profileRefusal: pres.ok ? null : pres.error };
|
|
1449
1734
|
|
|
1450
1735
|
// A missing agent CLI is detected BEFORE any spawn (on Linux the exec-in-fork failure is
|
|
1451
1736
|
// otherwise silent) — the same honest exited pane on both the tmux and the direct path.
|
|
@@ -1460,7 +1745,9 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
|
|
|
1460
1745
|
const s = { ...base, cwd: workdir, state: 'working', pty: null, tmux: tname,
|
|
1461
1746
|
note: '', buf: '', cols: 80, rows: 24, clients: new Set(), created: nowISO() };
|
|
1462
1747
|
sessions.set(id, s);
|
|
1463
|
-
|
|
1748
|
+
// the OVERLAY, not the merged env — the merged env is for the direct-PTY path below, which
|
|
1749
|
+
// really does spawn the process itself. See createTmuxSession for why the two differ.
|
|
1750
|
+
if (!createTmuxSession(tname, cmd, workdir, 80, 24, profileOverlay(pres.profile))) {
|
|
1464
1751
|
const why = `could not start a tmux session for '${cmd}'. Run \`npm run doctor\` in workspace/.`;
|
|
1465
1752
|
s.state = 'exited'; s.tmux = null; s.note = why;
|
|
1466
1753
|
s.buf = `\x1b[38;5;203m✗ ${why}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`;
|
|
@@ -1475,8 +1762,8 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
|
|
|
1475
1762
|
const startedMs = Date.now();
|
|
1476
1763
|
let p, usedCmd = cmd;
|
|
1477
1764
|
try {
|
|
1478
|
-
if (isShell) { const r = spawnPty(cmd, workdir); p = r.pty; usedCmd = r.cmd; }
|
|
1479
|
-
else p = pty.spawn(cmd, [], { name: 'xterm-256color', cols: 80, rows: 24, cwd: workdir, env:
|
|
1765
|
+
if (isShell) { const r = spawnPty(cmd, workdir, penv); p = r.pty; usedCmd = r.cmd; }
|
|
1766
|
+
else p = pty.spawn(cmd, [], { name: 'xterm-256color', cols: 80, rows: 24, cwd: workdir, env: penv });
|
|
1480
1767
|
} catch (e) {
|
|
1481
1768
|
return exitedSession(base, workdir, spawnFailureAdvice(e, cmd, isShell));
|
|
1482
1769
|
}
|
|
@@ -1518,9 +1805,14 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
|
|
|
1518
1805
|
// A FAILED git operation in a real repo must never spawn a session on the wrong branch (rubric
|
|
1519
1806
|
// hard floor): we return {ok:false} and spawn nothing. Kept as its own function so the
|
|
1520
1807
|
// git-then-maybe-spawn contract is unit-testable without a WS client.
|
|
1521
|
-
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate }) {
|
|
1808
|
+
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile }) {
|
|
1522
1809
|
const w = getWs(ws);
|
|
1523
1810
|
if (!w) return { ok: false, error: 'no workspace' };
|
|
1811
|
+
// The profile is checked BEFORE any git operation. A composer that picked a profile PMOS cannot
|
|
1812
|
+
// read must not leave a branch checked out and a session running under the wrong environment —
|
|
1813
|
+
// and a session that claims a profile it never got is the quietest failure this feature has.
|
|
1814
|
+
const pres = resolveProfileForSpawn(profile);
|
|
1815
|
+
if (!pres.ok) return { ok: false, error: pres.error };
|
|
1524
1816
|
let targetWs = w.id;
|
|
1525
1817
|
let notice = null;
|
|
1526
1818
|
// Isolation exists to protect OTHER sessions. Alone in a workspace there are none to protect, so
|
|
@@ -1555,7 +1847,7 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate }
|
|
|
1555
1847
|
broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
|
|
1556
1848
|
}
|
|
1557
1849
|
}
|
|
1558
|
-
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs });
|
|
1850
|
+
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id });
|
|
1559
1851
|
return { ok: true, session: s, notice };
|
|
1560
1852
|
}
|
|
1561
1853
|
|
|
@@ -1891,7 +2183,299 @@ function needsByWorkspace() {
|
|
|
1891
2183
|
}
|
|
1892
2184
|
// Anything that resolves or files a need makes the memo stale; drop it rather than wait out the TTL.
|
|
1893
2185
|
function invalidateNeedsAgg(id) { if (id) needsAgg.delete(id); else needsAgg.clear(); }
|
|
1894
|
-
|
|
2186
|
+
// ── repo ingest (workspace-repo-ingest): clone → scan → review → accept. ──────────────────────
|
|
2187
|
+
// The whole design is the boundary between stages 2 and 3. `lib/repo-scan.js` is PURE — it opens
|
|
2188
|
+
// files and returns a PROPOSAL; it cannot write, shell, or decide. Every write in this pipeline
|
|
2189
|
+
// lives below, and there is exactly one of them that touches a record: the confirm handler.
|
|
2190
|
+
//
|
|
2191
|
+
// Proposals persist BESIDE state/workspaces.json, never inside the repository being scanned (R3):
|
|
2192
|
+
// a proposal that is never accepted must leave the clone byte-identical.
|
|
2193
|
+
const INGEST_CLONE_TIMEOUT_MS = Number(process.env.PMOS_INGEST_TIMEOUT_MS || 120_000);
|
|
2194
|
+
const INGEST_CREDENTIAL_NOTE = 'PMOS holds no GitHub credential and will not ask for one — the clone used the git credential helper this machine already has.';
|
|
2195
|
+
const GITHUB_REF = /^(?:https?:\/\/(?:www\.)?github\.com\/)?([A-Za-z0-9][\w.-]*)\/([A-Za-z0-9][\w.-]*?)(?:\.git)?\/?$/;
|
|
2196
|
+
|
|
2197
|
+
function ingestPath(id) { return path.join(INGEST_DIR, id + '.json'); }
|
|
2198
|
+
function readProposal(id) {
|
|
2199
|
+
if (!/^ing-\d+$/.test(String(id || ''))) return null;
|
|
2200
|
+
try { return JSON.parse(fs.readFileSync(ingestPath(id), 'utf8')); } catch { return null; }
|
|
2201
|
+
}
|
|
2202
|
+
function writeProposal(pr) {
|
|
2203
|
+
try { fs.mkdirSync(INGEST_DIR, { recursive: true }); fs.writeFileSync(ingestPath(pr.id), JSON.stringify(pr)); return null; }
|
|
2204
|
+
catch (e) { warnWriteFail('state/ingest (the ingest proposal store)', e); return `the proposal could not be saved — ${String((e && e.code) || 'write failed')}`; }
|
|
2205
|
+
}
|
|
2206
|
+
function listProposals() {
|
|
2207
|
+
let names = [];
|
|
2208
|
+
try { names = fs.readdirSync(INGEST_DIR).filter((f) => /^ing-\d+\.json$/.test(f)); } catch { return []; }
|
|
2209
|
+
return names.map((f) => readProposal(f.replace(/\.json$/, ''))).filter(Boolean)
|
|
2210
|
+
.sort((a, b) => Number(a.id.slice(4)) - Number(b.id.slice(4)));
|
|
2211
|
+
}
|
|
2212
|
+
function nextIngestId() {
|
|
2213
|
+
let max = 0;
|
|
2214
|
+
try { for (const f of fs.readdirSync(INGEST_DIR)) { const m = /^ing-(\d+)\.json$/.exec(f); if (m && Number(m[1]) > max) max = Number(m[1]); } } catch {}
|
|
2215
|
+
return 'ing-' + (max + 1);
|
|
2216
|
+
}
|
|
2217
|
+
// The three DERIVED fields the UI prints, computed from the module's own field names. The PRD
|
|
2218
|
+
// sketches `anchor:{source,value,evidence}`; the module returns declaredAnchor/guessedAnchor/why,
|
|
2219
|
+
// and the wire uses the module's names PLUS these — translating between the two shapes in four
|
|
2220
|
+
// places independently is exactly the drift this pipeline cannot afford.
|
|
2221
|
+
function ingestItemWire(row) {
|
|
2222
|
+
return {
|
|
2223
|
+
...row,
|
|
2224
|
+
anchor_state: row.declaredAnchor ? 'declared' : row.guessedAnchor ? 'guessed' : 'none',
|
|
2225
|
+
anchor_value: row.declaredAnchor || row.guessedAnchor || null,
|
|
2226
|
+
evidence: row.why == null ? null : row.why,
|
|
2227
|
+
disposition: row.disposition || 'open',
|
|
2228
|
+
};
|
|
2229
|
+
}
|
|
2230
|
+
function pushIngest(pr) { broadcast({ t: 'ingest', id: pr.id, stage: pr.stage, proposal: pr }); }
|
|
2231
|
+
|
|
2232
|
+
// The clone. Asynchronous and OFF the request path (R1) — the POST returns an id immediately and
|
|
2233
|
+
// the result arrives over the socket. GIT_TERMINAL_PROMPT=0 is what turns a private repository
|
|
2234
|
+
// from a hang into a stated failure: git's own credential helper still runs, but nothing waits on
|
|
2235
|
+
// a username nobody is there to type.
|
|
2236
|
+
function startIngestClone(pr) {
|
|
2237
|
+
const dir = path.join(CLONE_DIR, pr.id);
|
|
2238
|
+
// 'error' and 'close' BOTH fire when the binary is missing, and `close` arrives with code null —
|
|
2239
|
+
// which would overwrite "git is not on PATH" with "git exited with code null" and collapse two
|
|
2240
|
+
// of the three failures R9 requires kept apart. First outcome wins; the rest are dropped.
|
|
2241
|
+
// The guard is on the child's LIFECYCLE, not on fail() itself — the scan below runs after the
|
|
2242
|
+
// clone has settled and must still be able to report its own failure.
|
|
2243
|
+
let settled = false;
|
|
2244
|
+
const fail = (error, extra) => {
|
|
2245
|
+
const cur = readProposal(pr.id) || pr;
|
|
2246
|
+
Object.assign(cur, { stage: 'review', ok: false, error, scanned_at: nowISO() }, extra || {});
|
|
2247
|
+
cur.log.push({ level: 'error', path: null, message: error });
|
|
2248
|
+
writeProposal(cur); pushIngest(cur);
|
|
2249
|
+
};
|
|
2250
|
+
try { fs.mkdirSync(CLONE_DIR, { recursive: true }); }
|
|
2251
|
+
catch (e) { return fail(`the clone directory could not be created — ${String((e && e.code) || 'write failed')}`); }
|
|
2252
|
+
// A re-run of the same id must not clone into a directory that already holds a tree.
|
|
2253
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
2254
|
+
|
|
2255
|
+
const env = { ...sessionEnv(), GIT_TERMINAL_PROMPT: '0' };
|
|
2256
|
+
let child;
|
|
2257
|
+
try {
|
|
2258
|
+
child = spawn('git', ['clone', '--depth', '1', '--', pr.source.url, dir],
|
|
2259
|
+
{ env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
2260
|
+
} catch (e) {
|
|
2261
|
+
// Thrown synchronously on some platforms; the same fact as the ENOENT below.
|
|
2262
|
+
return fail('git is not on PATH — PMOS did not run a clone');
|
|
2263
|
+
}
|
|
2264
|
+
let stderr = '';
|
|
2265
|
+
let timedOut = false;
|
|
2266
|
+
child.stderr.on('data', (d) => { stderr = (stderr + String(d)).slice(-8000); });
|
|
2267
|
+
const timer = setTimeout(() => { timedOut = true; try { child.kill('SIGKILL'); } catch {} }, INGEST_CLONE_TIMEOUT_MS);
|
|
2268
|
+
if (timer.unref) timer.unref();
|
|
2269
|
+
child.on('error', (e) => {
|
|
2270
|
+
clearTimeout(timer);
|
|
2271
|
+
if (settled) return;
|
|
2272
|
+
settled = true;
|
|
2273
|
+
// THREE distinct failures, and they must not collapse (R9): git absent is not a clone that
|
|
2274
|
+
// failed, and neither is a clone that timed out.
|
|
2275
|
+
if (e && e.code === 'ENOENT') return fail('git is not on PATH — PMOS did not run a clone');
|
|
2276
|
+
fail(`git clone failed: ${String((e && e.message) || 'the clone could not be started')}`);
|
|
2277
|
+
});
|
|
2278
|
+
child.on('close', (code) => {
|
|
2279
|
+
clearTimeout(timer);
|
|
2280
|
+
if (settled) return; // 'error' already reported the real reason — do not restate it worse
|
|
2281
|
+
settled = true;
|
|
2282
|
+
if (timedOut) {
|
|
2283
|
+
return fail(`git clone timed out after ${Math.round(INGEST_CLONE_TIMEOUT_MS / 1000)}s — the clone did not complete and nothing was scanned`);
|
|
2284
|
+
}
|
|
2285
|
+
if (code !== 0) {
|
|
2286
|
+
// git's OWN stderr line, verbatim. A rewritten error is a fact the PM cannot check.
|
|
2287
|
+
const line = stderr.split('\n').map((l) => l.trim()).filter(Boolean).pop() || `git exited with code ${code}`;
|
|
2288
|
+
const auth = /authentication|could not read Username|Permission denied|terminal prompts disabled|not found|403/i.test(stderr);
|
|
2289
|
+
return fail(`git clone failed: ${line}`, auth ? { credential_note: INGEST_CREDENTIAL_NOTE } : null);
|
|
2290
|
+
}
|
|
2291
|
+
// The commit is read, never assumed. A proposal that cannot name the commit it scanned says
|
|
2292
|
+
// null rather than a branch name standing in for one.
|
|
2293
|
+
let commit = null;
|
|
2294
|
+
try {
|
|
2295
|
+
const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: dir, env, encoding: 'utf8' });
|
|
2296
|
+
if (r.status === 0) commit = String(r.stdout || '').trim() || null;
|
|
2297
|
+
} catch {}
|
|
2298
|
+
|
|
2299
|
+
const cur = readProposal(pr.id) || pr;
|
|
2300
|
+
cur.stage = 'scan'; cur.source.commit = commit; cur.root = dir;
|
|
2301
|
+
writeProposal(cur); pushIngest(cur);
|
|
2302
|
+
|
|
2303
|
+
let scan;
|
|
2304
|
+
try { scan = repoScan.scanRepo(dir); }
|
|
2305
|
+
catch (e) { return fail(`the scan failed — ${String((e && e.message) || 'error').slice(0, 160)}`); }
|
|
2306
|
+
Object.assign(cur, {
|
|
2307
|
+
stage: 'review', ok: scan.ok, error: scan.error, root: scan.root,
|
|
2308
|
+
scanned_at: nowISO(),
|
|
2309
|
+
log: cur.log.concat(scan.log),
|
|
2310
|
+
unreadable: scan.unreadable,
|
|
2311
|
+
initiatives: scan.initiatives.map(ingestItemWire),
|
|
2312
|
+
adrs: scan.adrs.map((a) => ({ ...a, disposition: 'open' })),
|
|
2313
|
+
});
|
|
2314
|
+
writeProposal(cur); pushIngest(cur);
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
// The ONE record write in this pipeline. `parent_kr` is set into the LEADING frontmatter through
|
|
2319
|
+
// applyChange, whose contract is REFUSE ON MISMATCH — if the record moved between the scan and the
|
|
2320
|
+
// confirm, the write is rejected loudly rather than merged. Grep this file for `parent_kr`: this
|
|
2321
|
+
// function and the confirm handler are the only write site, which is R4 held structurally.
|
|
2322
|
+
function anchorEdit(text, anchor) {
|
|
2323
|
+
const lines = text.split('\n');
|
|
2324
|
+
if (lines[0].trim() !== '---') {
|
|
2325
|
+
return { error: 'this record has no leading frontmatter block — PMOS will not add one during an import' };
|
|
2326
|
+
}
|
|
2327
|
+
let close = -1;
|
|
2328
|
+
for (let i = 1; i < lines.length; i++) { if (lines[i].trim() === '---') { close = i; break; } }
|
|
2329
|
+
if (close === -1) return { error: 'this record\'s leading frontmatter block is never closed — it is malformed, and PMOS will not guess where it ends' };
|
|
2330
|
+
const head = lines.slice(0, close);
|
|
2331
|
+
const at = head.findIndex((l) => /^parent_kr:/.test(l));
|
|
2332
|
+
if (at !== -1) return { proposal: { before: head[at], after: `parent_kr: ${anchor}` } };
|
|
2333
|
+
// No key at all: insert after `type:`, which D63 requires on every tracked record and which is
|
|
2334
|
+
// therefore the one line guaranteed to exist exactly once in this block.
|
|
2335
|
+
const t = head.findIndex((l) => /^type:/.test(l));
|
|
2336
|
+
if (t === -1) return { error: 'this record declares no `type:` — PMOS has no anchor line to insert after and will not guess a position' };
|
|
2337
|
+
return { proposal: { after: `\nparent_kr: ${anchor}` }, opts: { anchor: head[t] } };
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
// ── focus (workspace-focus): the three reads lib/focus.js cannot perform itself. ───────────────
|
|
2341
|
+
// focus.js is pure — it stats nothing and opens nothing. Two of its outputs are therefore SILENTLY
|
|
2342
|
+
// degraded when the caller does not supply an input, and both degradations are correct-looking:
|
|
2343
|
+
// without `skills` no ▶ delegate button ever appears anywhere, and without `budgetRegistry` every
|
|
2344
|
+
// budget cap reads `null`. They are supplied here, and their failures are passed through as
|
|
2345
|
+
// failures rather than as absences.
|
|
2346
|
+
|
|
2347
|
+
// Every `.skill` file that EXISTS ON DISK under this repo's skills/ and build-skills/, as the
|
|
2348
|
+
// repo-relative paths focus.js matches against (`(?:build-)?skills/….skill`). One readdir per
|
|
2349
|
+
// directory per call — an offer PMOS cannot honour is worse than no offer, so the index is the
|
|
2350
|
+
// filesystem's answer and never a list of names we expect to be there.
|
|
2351
|
+
const SKILL_INDEX_MAX = 2000;
|
|
2352
|
+
function skillIndexFor(root) {
|
|
2353
|
+
const out = [];
|
|
2354
|
+
const walk = (dir, rel, depth) => {
|
|
2355
|
+
if (depth > 4 || out.length >= SKILL_INDEX_MAX) return;
|
|
2356
|
+
let ents = [];
|
|
2357
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
2358
|
+
for (const e of ents) {
|
|
2359
|
+
if (out.length >= SKILL_INDEX_MAX) return;
|
|
2360
|
+
if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) walk(path.join(dir, e.name), `${rel}/${e.name}`, depth + 1); }
|
|
2361
|
+
else if (e.name.endsWith('.skill')) out.push(`${rel}/${e.name}`);
|
|
2362
|
+
}
|
|
2363
|
+
};
|
|
2364
|
+
for (const base of ['skills', 'build-skills']) walk(path.join(root, base), base, 0);
|
|
2365
|
+
return out;
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
// The health-budget registry (D59), parsed out of planning/okrs/health-budgets.md's own table.
|
|
2369
|
+
// {ok:false,error} and "not supplied" are DIFFERENT to focus.js — the first renders
|
|
2370
|
+
// budgets.state:'error', the second 'not-read' — so a failed read is never returned as an absence.
|
|
2371
|
+
const BUDGET_ROW = /^\|\s*`?health:([a-z0-9][\w-]*)`?\s*\|([^|]*)\|([^|]*)\|/i;
|
|
2372
|
+
function budgetRegistryFor(root) {
|
|
2373
|
+
const rel = 'planning/okrs/health-budgets.md';
|
|
2374
|
+
let text;
|
|
2375
|
+
try { text = fs.readFileSync(path.join(root, rel), 'utf8'); }
|
|
2376
|
+
catch (e) {
|
|
2377
|
+
if (e && e.code === 'ENOENT') return { ok: false, error: `${rel} does not exist in this repository — no health budget declares a cap here` };
|
|
2378
|
+
return { ok: false, error: `${rel} could not be read — ${String((e && e.code) || 'read failed')}` };
|
|
2379
|
+
}
|
|
2380
|
+
const budgets = [];
|
|
2381
|
+
for (const line of text.split('\n')) {
|
|
2382
|
+
const m = BUDGET_ROW.exec(line.trim());
|
|
2383
|
+
if (!m) continue;
|
|
2384
|
+
const cap = m[3].trim();
|
|
2385
|
+
budgets.push({ id: `health:${m[1].toLowerCase()}`, covers: m[2].trim() || null, cap: cap || null });
|
|
2386
|
+
}
|
|
2387
|
+
// A file that parsed to nothing is reported as read-and-empty, not as an error: a repo may
|
|
2388
|
+
// legitimately declare no budgets, and the caps column is the only thing being claimed here.
|
|
2389
|
+
return { ok: true, budgets, path: rel };
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
// One entry per DISTINCT repo_key — a PRODUCT, not a working tree. Several worktrees of one repo
|
|
2393
|
+
// are one product with one board, and counting them separately is what put the same initiative in
|
|
2394
|
+
// the queue three times. boardCache.board() ONLY (§6.8): consoleState.workflow() is ~30s and
|
|
2395
|
+
// synchronous, and on a request path it freezes every PTY socket for the whole half minute.
|
|
2396
|
+
function focusProducts() {
|
|
2397
|
+
const trees = wsList();
|
|
2398
|
+
const groups = new Map(); // repo_key -> { id, name, trees[] }
|
|
2399
|
+
for (const t of trees) {
|
|
2400
|
+
const key = t.repo_key || t.root;
|
|
2401
|
+
if (!groups.has(key)) groups.set(key, { id: key, name: t.repo_name || t.name, trees: [] });
|
|
2402
|
+
groups.get(key).trees.push(t);
|
|
2403
|
+
}
|
|
2404
|
+
const rows = sessionList();
|
|
2405
|
+
return [...groups.values()].map((g) => {
|
|
2406
|
+
// The tree whose root IS the repo — the main worktree — is the one whose planning/ and okf/ are
|
|
2407
|
+
// canonical. Absent it (only linked worktrees registered), the first registered tree stands in.
|
|
2408
|
+
const lead = g.trees.find((t) => path.resolve(t.root) === path.resolve(g.id)) || g.trees[0];
|
|
2409
|
+
const w = workspaces.get(lead.id) || null;
|
|
2410
|
+
const root = w ? w.root : lead.root;
|
|
2411
|
+
const ids = new Set(g.trees.map((t) => t.id));
|
|
2412
|
+
let needs;
|
|
2413
|
+
try { needs = needsResult(w); }
|
|
2414
|
+
catch (e) { needs = { ok: false, error: 'the needs-you queue could not be read — ' + String((e && e.code) || 'error').slice(0, 60) }; }
|
|
2415
|
+
let spine;
|
|
2416
|
+
try { spine = surfaceSpine(root); }
|
|
2417
|
+
catch (e) { spine = { unreadable: 'the spine could not be derived — ' + String((e && e.message) || 'error').slice(0, 120) }; }
|
|
2418
|
+
return {
|
|
2419
|
+
id: g.id, name: g.name,
|
|
2420
|
+
board: boardCache.board(root),
|
|
2421
|
+
needs,
|
|
2422
|
+
spine,
|
|
2423
|
+
// Partitioned by each row's OWN `ws`, because focus.js matches sessions to products by id and
|
|
2424
|
+
// a session's `ws` is a working-tree id, not a repo key — the join has to happen here.
|
|
2425
|
+
sessions: rows.filter((s) => ids.has(s.ws)),
|
|
2426
|
+
};
|
|
2427
|
+
});
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
// ── workflow templates (jira-handoff R13). ────────────────────────────────────────────────────
|
|
2431
|
+
// A DECLARED template is a file a human wrote. Nothing derives from it — the engine
|
|
2432
|
+
// (scripts/workflow-state.js) builds one fixed ladder per lane and never reads `flow:` — so the
|
|
2433
|
+
// stage list must come out of the document's own body and `declared:true` is shipped so the client
|
|
2434
|
+
// can label the group without inventing the word.
|
|
2435
|
+
const STAGE_CHAIN = /([a-z][a-z0-9_-]{1,24})(?:\s*(?:→|->)\s*([a-z][a-z0-9_-]{1,24}))+/i;
|
|
2436
|
+
function templateStages(text, fm) {
|
|
2437
|
+
// A declared `stages:` list in frontmatter wins — it is the document saying so directly.
|
|
2438
|
+
const declared = fm && typeof fm.stages === 'string' ? fm.stages : null;
|
|
2439
|
+
const line = declared || (STAGE_CHAIN.test(text) ? STAGE_CHAIN.exec(text)[0] : null);
|
|
2440
|
+
if (!line) return []; // never a default six-stage list: that would be this server declaring it
|
|
2441
|
+
return line.split(/\s*(?:→|->|,)\s*/).map((s) => s.trim().replace(/^[`'"]|[`'"]$/g, ''))
|
|
2442
|
+
.filter((s) => /^[a-z][a-z0-9_-]*$/i.test(s));
|
|
2443
|
+
}
|
|
2444
|
+
function workflowTemplates(root) {
|
|
2445
|
+
const rel = 'planning/workflows';
|
|
2446
|
+
const dir = path.join(root, rel);
|
|
2447
|
+
let ents;
|
|
2448
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
2449
|
+
catch (e) {
|
|
2450
|
+
// ABSENT is calm and stated — most repositories have none — and is not the same answer as
|
|
2451
|
+
// "the directory is there and PMOS could not look inside it".
|
|
2452
|
+
if (e && e.code === 'ENOENT') return { ok: true, empty: true, dir: rel, templates: [], absent: true };
|
|
2453
|
+
return { ok: false, error: `${rel} could not be listed — ${String((e && e.code) || 'read failed')}` };
|
|
2454
|
+
}
|
|
2455
|
+
const files = ents.filter((e) => !e.isDirectory() && /\.md$/i.test(e.name)).map((e) => e.name).sort();
|
|
2456
|
+
const templates = [];
|
|
2457
|
+
for (const nm of files) {
|
|
2458
|
+
if (nm === 'index.md' || nm === 'log.md') continue; // OKF-reserved, never a concept document
|
|
2459
|
+
let text;
|
|
2460
|
+
try { text = fs.readFileSync(path.join(dir, nm), 'utf8'); }
|
|
2461
|
+
catch (e) { return { ok: false, error: `${rel}/${nm} could not be read — ${String((e && e.code) || 'read failed')}` }; }
|
|
2462
|
+
const fm = fmParse.parseLeadingFrontmatter(text) || {};
|
|
2463
|
+
// D63 titles are quoted in frontmatter; the quotes are syntax, not part of the title. `null`
|
|
2464
|
+
// rather than the filename when there is none — a filename standing in for a title is a title
|
|
2465
|
+
// the document does not have.
|
|
2466
|
+
const title = String(fm.title == null ? '' : fm.title).trim().replace(/^["']|["']$/g, '').trim();
|
|
2467
|
+
templates.push({
|
|
2468
|
+
id: nm.replace(/\.md$/i, ''), path: `${rel}/${nm}`,
|
|
2469
|
+
title: title || null,
|
|
2470
|
+
stages: templateStages(text, fm),
|
|
2471
|
+
declared: true,
|
|
2472
|
+
});
|
|
2473
|
+
}
|
|
2474
|
+
if (!templates.length) return { ok: true, empty: true, dir: rel, templates: [] };
|
|
2475
|
+
return { ok: true, empty: false, dir: rel, templates };
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
function buildConsole(name, w, root, params) {
|
|
1895
2479
|
const stateDir = stateDirFor(w);
|
|
1896
2480
|
const snapshotDir = latestSnapshotDir(root);
|
|
1897
2481
|
if (name === 'needs') return needsResult(w);
|
|
@@ -1905,6 +2489,75 @@ function buildConsole(name, w, root) {
|
|
|
1905
2489
|
// child; the payload carries `as_of` / `stale` / `pending` so the client can say how old the
|
|
1906
2490
|
// answer is instead of implying it is current.
|
|
1907
2491
|
if (name === 'workflow') return boardCache.board(w ? w.root : root);
|
|
2492
|
+
// ONE initiative's ladder. The board caps itself at what is in flight and reports the rest as
|
|
2493
|
+
// `hidden`; a panel asked about a named initiative must not read that cap as "no record", so it
|
|
2494
|
+
// derives the one it was asked for. Single engine run — safe on the request path, unlike a board.
|
|
2495
|
+
if (name === 'ladder') return consoleState.ladder({ root: w ? w.root : root, id: params && params.get('id') });
|
|
2496
|
+
// The PM spine belongs to the console family, not the deliverable family: it is read-only
|
|
2497
|
+
// file-mode state and it owes the same honesty contract — a failed read renders RED, never a
|
|
2498
|
+
// green empty tree (R5). It reads the OPENED workspace's repo for the same reason the ladder
|
|
2499
|
+
// does — objectives, budgets and initiative records are properties of that repo.
|
|
2500
|
+
if (name === 'spine') {
|
|
2501
|
+
const s = surfaceSpine(w ? w.root : root);
|
|
2502
|
+
if (s.unreadable) return { ok: false, error: s.unreadable };
|
|
2503
|
+
const placed = s.objectives.reduce((n, o) =>
|
|
2504
|
+
n + o.krs.reduce((m, k) => m + k.initiatives.length, 0) + (o.direct ? o.direct.length : 0), 0)
|
|
2505
|
+
+ s.budgets.reduce((n, b) => n + b.initiatives.length, 0);
|
|
2506
|
+
// `empty` is a SUCCESSFUL read of genuinely nothing — a repo with no initiative records at all.
|
|
2507
|
+
// It is a supported thing to point the workspace at, so it renders calm, not red.
|
|
2508
|
+
return { ok: true, empty: placed === 0 && s.unanchored.length === 0, ...s };
|
|
2509
|
+
}
|
|
2510
|
+
// ── focus (workspace-focus). CROSS-PRODUCT: `ws` is accepted and IGNORED. ──
|
|
2511
|
+
// Focus joins every registered workspace, so scoping it to the selected tree would hide the one
|
|
2512
|
+
// item that is actually blocking the PM behind whichever repo they happened to be standing in.
|
|
2513
|
+
if (name === 'focus') {
|
|
2514
|
+
const products = focusProducts();
|
|
2515
|
+
// The skill index is per-product and unioned: focus.js matches a step's `next` against
|
|
2516
|
+
// repo-relative paths, and a skill that exists in one repo is a real offer for that repo's
|
|
2517
|
+
// ladder. Supplying `[]` (rather than nothing) when no repo has any is deliberate — it means
|
|
2518
|
+
// "we looked and found none", which correctly yields no offers, not "we did not look".
|
|
2519
|
+
// `product.id` IS the repo's main-worktree directory (repoIdentity's key), so it is the root to
|
|
2520
|
+
// read — no second lookup, and no chance of indexing a different tree's skills.
|
|
2521
|
+
const skills = new Set();
|
|
2522
|
+
for (const pr of products) for (const s of skillIndexFor(pr.id)) skills.add(s);
|
|
2523
|
+
// ONE registry, read from the home repo when there is one — health budgets are a PMOS-wide
|
|
2524
|
+
// declaration (D59), and every product's spine is matched against the same caps.
|
|
2525
|
+
const regRoot = HOME_ENABLED ? SELF_ROOT : (products.length ? products[0].id : SELF_ROOT);
|
|
2526
|
+
const view = focus.buildFocus({
|
|
2527
|
+
products,
|
|
2528
|
+
sessions: sessionList(),
|
|
2529
|
+
skills: [...skills],
|
|
2530
|
+
budgetRegistry: budgetRegistryFor(regRoot),
|
|
2531
|
+
});
|
|
2532
|
+
return { ok: true, ...view };
|
|
2533
|
+
}
|
|
2534
|
+
// ── handoff (workspace-jira-handoff R1). ──
|
|
2535
|
+
// The record's LEADING frontmatter, which no existing payload carries, parsed by the real
|
|
2536
|
+
// parser — a hand-rolled regex here would be a second opinion about what a record declares.
|
|
2537
|
+
// `links.connected` is false on every code path readLinks has; there is no argument that
|
|
2538
|
+
// changes it and none is passed.
|
|
2539
|
+
if (name === 'handoff') {
|
|
2540
|
+
const id = params && params.get('id');
|
|
2541
|
+
if (!id) return { ok: false, error: 'an initiative id is required', http_status: 400 };
|
|
2542
|
+
// Same path guard as every other read on this surface — an id is a filename, not a path.
|
|
2543
|
+
if (!/^[A-Za-z0-9][\w.-]*$/.test(id)) return { ok: false, error: 'that initiative id is not a record name', http_status: 400 };
|
|
2544
|
+
const rel = `planning/initiatives/${id}.md`;
|
|
2545
|
+
const file = path.join(w ? w.root : root, 'planning', 'initiatives', `${id}.md`);
|
|
2546
|
+
let text;
|
|
2547
|
+
try { text = fs.readFileSync(file, 'utf8'); }
|
|
2548
|
+
catch (e) {
|
|
2549
|
+
// A record that does not exist is NOT a failed read — the panel already has a "no record"
|
|
2550
|
+
// state for it. Anything else (EACCES, EISDIR, EIO) is PMOS failing to look, and is red.
|
|
2551
|
+
if (e && e.code === 'ENOENT') return { ok: true, id, record: rel, links: null, absent: true };
|
|
2552
|
+
return { ok: false, error: `${rel} could not be read — ${String((e && e.code) || 'read failed')}` };
|
|
2553
|
+
}
|
|
2554
|
+
let links;
|
|
2555
|
+
try { links = handoff.readLinks(fmParse.parseLeadingFrontmatter(text)); }
|
|
2556
|
+
catch (e) { return { ok: false, error: `${rel} — ${String((e && e.message) || 'the frontmatter could not be parsed').slice(0, 160)}` }; }
|
|
2557
|
+
return { ok: true, id, record: rel, links };
|
|
2558
|
+
}
|
|
2559
|
+
// ── workflow templates (jira-handoff R13). ──
|
|
2560
|
+
if (name === 'workflows') return workflowTemplates(w ? w.root : root);
|
|
1908
2561
|
return { ok: false, error: 'unknown console surface: ' + name };
|
|
1909
2562
|
}
|
|
1910
2563
|
|
|
@@ -2044,7 +2697,7 @@ function serveFile(res, file, type, noCache) {
|
|
|
2044
2697
|
fs.readFile(file, (err, buf) => {
|
|
2045
2698
|
if (err) { res.writeHead(404); res.end('not found'); return; }
|
|
2046
2699
|
const headers = { 'Content-Type': type || TYPES[path.extname(file)] || 'application/octet-stream' };
|
|
2047
|
-
// The app SHELL (index.html +
|
|
2700
|
+
// The app SHELL (index.html + the /pm/ module graph) must revalidate every load: without this a tab left open
|
|
2048
2701
|
// across a `git merge` + server restart keeps running the pre-merge JS, so a fix that IS being
|
|
2049
2702
|
// served never reaches the screen. `no-cache` = "always revalidate", not "never store".
|
|
2050
2703
|
if (noCache) headers['Cache-Control'] = 'no-cache';
|
|
@@ -2065,9 +2718,13 @@ async function handleRequest(req, res) {
|
|
|
2065
2718
|
const p = url.pathname;
|
|
2066
2719
|
|
|
2067
2720
|
if (p === '/' || p === '/index.html') return serveFile(res, path.join(PUB, 'index.html'), null, true);
|
|
2068
|
-
|
|
2721
|
+
// NB: there is no `/app.js` route. The single-file front end that owned it (public/app.js +
|
|
2722
|
+
// public/styles.css) was RETIRED with the four-panel design — the shell is index.html plus the
|
|
2723
|
+
// /pm/ module graph below, and one front end is the point. A stray <script src="/app.js"> now
|
|
2724
|
+
// 404s loudly rather than silently resurrecting a second, diverging surface.
|
|
2069
2725
|
if (p === '/lib/preview-kind.js') return serveFile(res, path.join(__dirname, 'lib', 'preview-kind.js'));
|
|
2070
2726
|
if (p === '/lib/layout.js') return serveFile(res, path.join(__dirname, 'lib', 'layout.js'));
|
|
2727
|
+
if (p === '/lib/mobile-nav.js') return serveFile(res, path.join(__dirname, 'lib', 'mobile-nav.js'));
|
|
2071
2728
|
if (p === '/lib/session-state.js') return serveFile(res, path.join(__dirname, 'lib', 'session-state.js'));
|
|
2072
2729
|
// The editor computes its live diff with the SAME module the server saves through, so what the PM
|
|
2073
2730
|
// sees while typing and what gets written cannot disagree. NB: /lib/ is an explicit allowlist, not
|
|
@@ -2075,7 +2732,19 @@ async function handleRequest(req, res) {
|
|
|
2075
2732
|
// client feature that depends on it fails in a way no server-side test can see.
|
|
2076
2733
|
if (p === '/lib/apply-change.js') return serveFile(res, path.join(__dirname, 'lib', 'apply-change.js'));
|
|
2077
2734
|
if (p === '/lib/related-docs.js') return serveFile(res, path.join(__dirname, 'lib', 'related-docs.js'));
|
|
2078
|
-
|
|
2735
|
+
// …and no `/styles.css` route either: the token layer and every component rule now ship as
|
|
2736
|
+
// /pm/tokens.css + /pm/mobile.css through the /pm/ directory route below.
|
|
2737
|
+
// Every browser asks for /favicon.ico unprompted. Without a route it 404s on every load, which
|
|
2738
|
+
// puts a permanent red line in the console and in this suite's `page errors seen` list — noise
|
|
2739
|
+
// that trains you to ignore the one place a real client-side failure would show up. An inline SVG
|
|
2740
|
+
// answers it with no asset to ship: the PMOS mark, sized to work at 16px.
|
|
2741
|
+
if (p === '/favicon.ico' || p === '/favicon.svg') {
|
|
2742
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
|
|
2743
|
+
`<rect width="16" height="16" rx="3" fill="#111"/>` +
|
|
2744
|
+
`<path d="M4 12V4h3.2a2.4 2.4 0 010 4.8H6" stroke="#fff" stroke-width="1.6" fill="none"/></svg>`;
|
|
2745
|
+
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'max-age=86400' });
|
|
2746
|
+
return res.end(svg);
|
|
2747
|
+
}
|
|
2079
2748
|
// xterm assets straight from node_modules (offline; no CDN)
|
|
2080
2749
|
if (p === '/vendor/xterm.js') return serveFile(res, path.join(XROOT, 'xterm', 'lib', 'xterm.js'));
|
|
2081
2750
|
if (p === '/vendor/xterm.css') return serveFile(res, path.join(XROOT, 'xterm', 'css', 'xterm.css'));
|
|
@@ -2086,7 +2755,28 @@ async function handleRequest(req, res) {
|
|
|
2086
2755
|
// because the terminal renders bold — with only the regular face the browser synthesises it.
|
|
2087
2756
|
if (p === '/vendor/space-grotesk-var.woff2') return serveFile(res, path.join(PUB, 'vendor', 'space-grotesk-var.woff2'));
|
|
2088
2757
|
if (p === '/vendor/ibm-plex-mono-400.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-400.woff2'));
|
|
2758
|
+
// 500 joined 400/700 with the four-panel design, whose section labels and pills are medium-weight
|
|
2759
|
+
// mono. Without the face the browser synthesises it, and a synthesised weight is exactly the kind
|
|
2760
|
+
// of difference the design-parity comparator reports and a human eye does not.
|
|
2761
|
+
if (p === '/vendor/ibm-plex-mono-500.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-500.woff2'));
|
|
2089
2762
|
if (p === '/vendor/ibm-plex-mono-700.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-700.woff2'));
|
|
2763
|
+
// ── the four-panel front end (public/pm/) ──────────────────────────────────────────────────────
|
|
2764
|
+
// A DIRECTORY route, deliberately, where /lib/ above is an allowlist. The allowlist's own comment
|
|
2765
|
+
// records why it is a trap: a file missing from it 404s silently and the feature that needed it
|
|
2766
|
+
// fails somewhere no server-side test looks. That cost is tolerable for six stable modules and is
|
|
2767
|
+
// not tolerable for a front end split into fifteen, where every new module would be one forgotten
|
|
2768
|
+
// line away from a blank panel. The traversal guard is what makes the directory form safe: the
|
|
2769
|
+
// resolved path must still sit inside PM_DIR, so '..' segments and absolute-looking names cannot
|
|
2770
|
+
// reach a file outside it. Extension-restricted as well — this route serves code and styles, not
|
|
2771
|
+
// whatever else happens to land in the folder.
|
|
2772
|
+
if (p.startsWith('/pm/')) {
|
|
2773
|
+
const rel = p.slice('/pm/'.length);
|
|
2774
|
+
const file = path.resolve(PM_DIR, rel);
|
|
2775
|
+
if (!file.startsWith(PM_DIR + path.sep) || !['.js', '.css', '.json'].includes(path.extname(file))) {
|
|
2776
|
+
res.writeHead(404); return res.end('not found');
|
|
2777
|
+
}
|
|
2778
|
+
return serveFile(res, file, null, true);
|
|
2779
|
+
}
|
|
2090
2780
|
|
|
2091
2781
|
if (p === '/api/workspaces' && req.method === 'GET') return json(res, { workspaces: wsList() });
|
|
2092
2782
|
if (p === '/api/workspaces' && req.method === 'POST') {
|
|
@@ -2145,6 +2835,191 @@ async function handleRequest(req, res) {
|
|
|
2145
2835
|
if (!isDir(root)) return json(res, { ok: false, error: `not a directory: ${root}` }, 400);
|
|
2146
2836
|
return json(res, { ok: true, root });
|
|
2147
2837
|
}
|
|
2838
|
+
// ── repo ingest (workspace-repo-ingest R1/R2/R9). ────────────────────────────────────────────
|
|
2839
|
+
if (p === '/api/ingest' && req.method === 'POST') {
|
|
2840
|
+
let body = {};
|
|
2841
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
2842
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
2843
|
+
// R2 enforced in CODE, not only in copy: a local folder is REGISTERED (POST /api/workspaces)
|
|
2844
|
+
// and nothing is imported from it. Accepting `folder` here and quietly registering it would
|
|
2845
|
+
// make the two paths indistinguishable to anything but the copy under the radio button.
|
|
2846
|
+
if (body.source === 'folder' || body.source === 'local') {
|
|
2847
|
+
return json(res, { ok: false, error: 'a local folder is registered, never ingested — nothing is imported from a bare folder' }, 400);
|
|
2848
|
+
}
|
|
2849
|
+
if (body.source !== 'github') return json(res, { ok: false, error: 'source must be "github"' }, 400);
|
|
2850
|
+
const ref = String(body.ref == null ? '' : body.ref).trim();
|
|
2851
|
+
if (!ref) return json(res, { ok: false, error: 'a repository reference is required' }, 400);
|
|
2852
|
+
const m = GITHUB_REF.exec(ref);
|
|
2853
|
+
if (!m) return json(res, { ok: false, error: `"${ref.slice(0, 80)}" is not a GitHub repository reference — give owner/name or https://github.com/owner/name` }, 400);
|
|
2854
|
+
const slug = `${m[1]}/${m[2]}`;
|
|
2855
|
+
const pr = {
|
|
2856
|
+
id: nextIngestId(), stage: 'acquire',
|
|
2857
|
+
source: { kind: 'github', ref: slug, url: `https://github.com/${slug}.git`, commit: null },
|
|
2858
|
+
clone_dir: `state/clones/`, scanned_at: null,
|
|
2859
|
+
ok: true, error: null, root: null,
|
|
2860
|
+
log: [{ level: 'info', path: null, message: `cloning ${slug} — read-only; nothing is written into this repository or into PMOS until an item is confirmed` }],
|
|
2861
|
+
unreadable: [], initiatives: [], adrs: [],
|
|
2862
|
+
};
|
|
2863
|
+
pr.clone_dir = `state/clones/${pr.id}`;
|
|
2864
|
+
const err = writeProposal(pr);
|
|
2865
|
+
if (err) return json(res, { ok: false, error: err }, 500);
|
|
2866
|
+
pushIngest(pr);
|
|
2867
|
+
// Returns IMMEDIATELY. The clone runs off the request path and reports over the socket.
|
|
2868
|
+
startIngestClone(pr);
|
|
2869
|
+
return json(res, { ok: true, id: pr.id, stage: pr.stage });
|
|
2870
|
+
}
|
|
2871
|
+
if (p === '/api/ingest' && req.method === 'GET') {
|
|
2872
|
+
const id = url.searchParams.get('id');
|
|
2873
|
+
if (id) {
|
|
2874
|
+
const pr = readProposal(id);
|
|
2875
|
+
if (!pr) return json(res, { ok: false, error: 'no such ingest' });
|
|
2876
|
+
return json(res, { ok: true, proposal: pr });
|
|
2877
|
+
}
|
|
2878
|
+
return json(res, { ok: true, proposals: listProposals().filter((x) => x.stage !== 'closed') });
|
|
2879
|
+
}
|
|
2880
|
+
if (p === '/api/ingest/confirm' && req.method === 'POST') {
|
|
2881
|
+
let body = {};
|
|
2882
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
2883
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
2884
|
+
// ONE item. There is no array form, no `items`, no `all` — the tedium IS the gate (R4). A
|
|
2885
|
+
// bulk confirm is how a dozen guessed anchors land on the board with every check green.
|
|
2886
|
+
if (Array.isArray(body.item) || body.items != null || body.all != null) {
|
|
2887
|
+
return json(res, { ok: false, error: 'confirmation is per item' }, 400);
|
|
2888
|
+
}
|
|
2889
|
+
const pr = readProposal(body.id);
|
|
2890
|
+
if (!pr) return json(res, { ok: false, error: 'no such ingest' }, 400);
|
|
2891
|
+
const item = String(body.item == null ? '' : body.item);
|
|
2892
|
+
const row = (pr.initiatives || []).find((r) => r.id === item);
|
|
2893
|
+
if (!row) return json(res, { ok: false, error: `this proposal has no item "${item.slice(0, 60)}"` }, 400);
|
|
2894
|
+
// The anchor comes from the PM's action and nowhere else. There is no code path in which a
|
|
2895
|
+
// `guessedAnchor` reaches `parent_kr` without an explicit request carrying it.
|
|
2896
|
+
const anchor = String(body.anchor == null ? '' : body.anchor).trim();
|
|
2897
|
+
if (!anchor) return json(res, { ok: false, error: 'an anchor is required — a guess is not a declaration and D59 rejects unanchored work' }, 400);
|
|
2898
|
+
if (!pr.root) return json(res, { ok: false, error: 'this proposal has no scanned repository — nothing was cloned, so there is nothing to write to' }, 409);
|
|
2899
|
+
const full = path.resolve(pr.root, row.path);
|
|
2900
|
+
if (!insideRoot(pr.root, full)) return json(res, { ok: false, error: 'that record is outside the scanned repository' }, 400);
|
|
2901
|
+
|
|
2902
|
+
// The resolver's verdict is computed and reported BEFORE the write, so the PM is told whether
|
|
2903
|
+
// the anchor they chose names anything this repository declares — not after the fact.
|
|
2904
|
+
let resolves = false, resolverNote = 'the anchor resolver (scripts/anchor-resolve.js) is not present in this install — whether this anchor resolves was not checked';
|
|
2905
|
+
const A = anchorLib();
|
|
2906
|
+
if (A) {
|
|
2907
|
+
try {
|
|
2908
|
+
const r = A.resolveAnchor(anchor, A.parseOkrIndex(pr.root), A.readBudgets(pr.root));
|
|
2909
|
+
resolves = r.status === 'resolved';
|
|
2910
|
+
resolverNote = r.reason || `anchor status: ${r.status}`;
|
|
2911
|
+
} catch (e) { resolverNote = `the anchor could not be resolved — ${String((e && e.message) || 'error').slice(0, 120)}`; }
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
let current;
|
|
2915
|
+
try { current = fs.readFileSync(full, 'utf8'); }
|
|
2916
|
+
catch (e) { return json(res, { ok: false, error: `${row.path} could not be read — ${String((e && e.code) || 'read failed')}` }, 409); }
|
|
2917
|
+
const edit = anchorEdit(current, anchor);
|
|
2918
|
+
if (edit.error) return json(res, { ok: false, error: `${row.path} — ${edit.error}` }, 409);
|
|
2919
|
+
const applied = applyChange.applyChange(current, edit.proposal, edit.opts);
|
|
2920
|
+
if (!applied.ok) {
|
|
2921
|
+
// applyChange's OWN refusal reason — the record moved between the scan and the confirm, and
|
|
2922
|
+
// a merged-anyway write is a diff the PM never saw.
|
|
2923
|
+
return json(res, { ok: false, error: `${row.path} — ${applied.detail || applied.reason}` }, 409);
|
|
2924
|
+
}
|
|
2925
|
+
try { fs.writeFileSync(full, applied.text); }
|
|
2926
|
+
catch (e) { return json(res, { ok: false, error: `${row.path} could not be written — ${String((e && e.code) || 'write failed')}` }, 409); }
|
|
2927
|
+
row.disposition = 'confirmed';
|
|
2928
|
+
row.declaredAnchor = anchor; row.guessedAnchor = null;
|
|
2929
|
+
row.anchor_state = 'declared'; row.anchor_value = anchor;
|
|
2930
|
+
row.why = `confirmed by the PM on ${nowISO()} — written into ${row.path} as \`parent_kr: ${anchor}\``;
|
|
2931
|
+
row.evidence = row.why;
|
|
2932
|
+
const werr = writeProposal(pr);
|
|
2933
|
+
pushIngest(pr);
|
|
2934
|
+
if (werr) return json(res, { ok: false, error: `${anchor} was written into ${row.path}, but ${werr} — the proposal on screen is now behind the file` }, 500);
|
|
2935
|
+
return json(res, { ok: true, path: row.path, anchor, resolves, resolver_note: resolverNote });
|
|
2936
|
+
}
|
|
2937
|
+
if (p === '/api/ingest/dismiss' && req.method === 'POST') {
|
|
2938
|
+
let body = {};
|
|
2939
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
2940
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
2941
|
+
const pr = readProposal(body.id);
|
|
2942
|
+
if (!pr) return json(res, { ok: false, error: 'no such ingest' }, 400);
|
|
2943
|
+
const row = (pr.initiatives || []).find((r) => r.id === String(body.item == null ? '' : body.item));
|
|
2944
|
+
if (!row) return json(res, { ok: false, error: `this proposal has no item "${String(body.item || '').slice(0, 60)}"` }, 400);
|
|
2945
|
+
row.disposition = 'dismissed';
|
|
2946
|
+
const err = writeProposal(pr);
|
|
2947
|
+
if (err) return json(res, { ok: false, error: err }, 500);
|
|
2948
|
+
pushIngest(pr);
|
|
2949
|
+
return json(res, { ok: true });
|
|
2950
|
+
}
|
|
2951
|
+
if (p === '/api/ingest/adr' && req.method === 'POST') {
|
|
2952
|
+
let body = {};
|
|
2953
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
2954
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
2955
|
+
const pr = readProposal(body.id);
|
|
2956
|
+
if (!pr) return json(res, { ok: false, error: 'no such ingest' }, 400);
|
|
2957
|
+
const disposition = body.disposition;
|
|
2958
|
+
if (disposition !== 'accepted' && disposition !== 'superseded') {
|
|
2959
|
+
return json(res, { ok: false, error: 'disposition must be "accepted" or "superseded"' }, 400);
|
|
2960
|
+
}
|
|
2961
|
+
const rel = String(body.path == null ? '' : body.path);
|
|
2962
|
+
const adr = (pr.adrs || []).find((a) => a.path === rel);
|
|
2963
|
+
if (!adr) return json(res, { ok: false, error: `this proposal has no ADR at "${rel.slice(0, 80)}"` }, 400);
|
|
2964
|
+
const w = getWs(body.ws);
|
|
2965
|
+
if (!w) return json(res, { ok: false, error: 'no workspace to accept this ADR into' }, 400);
|
|
2966
|
+
|
|
2967
|
+
let wrote = null;
|
|
2968
|
+
if (disposition === 'accepted') {
|
|
2969
|
+
if (!pr.root) return json(res, { ok: false, error: 'this proposal has no scanned repository — there is no file to copy' }, 409);
|
|
2970
|
+
const src = path.resolve(pr.root, rel);
|
|
2971
|
+
if (!insideRoot(pr.root, src)) return json(res, { ok: false, error: 'that ADR is outside the scanned repository' }, 400);
|
|
2972
|
+
// The RECEIVING repo's own adr/ directory. PMOS does not invent one: a repository with no
|
|
2973
|
+
// OKF bundle has nowhere an ADR belongs, and creating a tree during an import would be this
|
|
2974
|
+
// server deciding where another repo's knowledge lives.
|
|
2975
|
+
const dirs = findDirsNamed(path.join(w.root, 'okf'), 'adr', 3);
|
|
2976
|
+
if (!dirs.length) {
|
|
2977
|
+
return json(res, { ok: false, error: `“${w.name}” has no \`adr/\` directory under okf/ — PMOS will not create one during an import; make the directory and accept again` }, 409);
|
|
2978
|
+
}
|
|
2979
|
+
let text;
|
|
2980
|
+
try { text = fs.readFileSync(src, 'utf8'); }
|
|
2981
|
+
catch (e) { return json(res, { ok: false, error: `${rel} could not be read — ${String((e && e.code) || 'read failed')}` }, 409); }
|
|
2982
|
+
const name = path.basename(rel);
|
|
2983
|
+
const dest = path.join(dirs[0], name);
|
|
2984
|
+
// Never overwrite. An ADR of that name already there is a collision only a human can settle.
|
|
2985
|
+
if (fs.existsSync(dest)) {
|
|
2986
|
+
return json(res, { ok: false, error: `${path.relative(w.root, dest)} already exists — PMOS will not overwrite an ADR during an import` }, 409);
|
|
2987
|
+
}
|
|
2988
|
+
// The PROVENANCE goes into the FILE. The `ingested` badge is derived from `ingested_from`
|
|
2989
|
+
// being present in the document — never from a stored flag — so a badge cannot outlive the
|
|
2990
|
+
// fact it claims, and a copy someone moves by hand carries its own origin with it.
|
|
2991
|
+
// The scan only proposes a file that already parsed with a `type: ADR` frontmatter block, so
|
|
2992
|
+
// the two lines go inside the block that is known to be there rather than in a new one.
|
|
2993
|
+
const stamp = `ingested_from: ${pr.source.ref}@${pr.source.commit || 'unknown-commit'}\ningested_at: ${nowISO().slice(0, 10)}`;
|
|
2994
|
+
const lines = text.split('\n');
|
|
2995
|
+
if (lines[0].trim() !== '---') {
|
|
2996
|
+
return json(res, { ok: false, error: `${rel} has no leading frontmatter block — PMOS cannot record where it came from, and will not import a document it cannot stamp` }, 409);
|
|
2997
|
+
}
|
|
2998
|
+
lines.splice(1, 0, stamp);
|
|
2999
|
+
try { fs.writeFileSync(dest, lines.join('\n')); }
|
|
3000
|
+
catch (e) { return json(res, { ok: false, error: `${path.relative(w.root, dest)} could not be written — ${String((e && e.code) || 'write failed')}` }, 409); }
|
|
3001
|
+
wrote = path.relative(w.root, dest);
|
|
3002
|
+
}
|
|
3003
|
+
// The disposition itself is a decision record, appended to the workspace's own file-mode
|
|
3004
|
+
// state, keyed by ADR path. It is NOT written to acceptances.jsonl: that file is the PM
|
|
3005
|
+
// Acceptance Gate's log and metrics.py counts its rows for the north star — filing an import
|
|
3006
|
+
// decision there would inflate a metric with something that was never an agent run.
|
|
3007
|
+
const d = stateDirFor(w, { create: true });
|
|
3008
|
+
if (!d) return json(res, { ok: false, error: `the disposition could not be recorded — “${w.name}” has no file-mode state directory and PMOS will not create one in a repository of unknown shape`, wrote }, 500);
|
|
3009
|
+
const rec = { at: nowISO(), date: nowISO().slice(0, 10), kind: 'adr', adr_path: rel,
|
|
3010
|
+
disposition, ingest: pr.id, source: `${pr.source.ref}@${pr.source.commit || 'unknown-commit'}`,
|
|
3011
|
+
wrote, workspace: w.name, root: w.root, pm: true };
|
|
3012
|
+
try { fs.appendFileSync(path.join(d, 'ingest.jsonl'), JSON.stringify(rec) + '\n'); }
|
|
3013
|
+
catch (e) {
|
|
3014
|
+
warnWriteFail('ingest.jsonl (ADR dispositions)', e);
|
|
3015
|
+
return json(res, { ok: false, error: 'the disposition could not be recorded — the file-mode state directory is unwritable; the decision was NOT saved', wrote }, 500);
|
|
3016
|
+
}
|
|
3017
|
+
adr.disposition = disposition;
|
|
3018
|
+
const err = writeProposal(pr);
|
|
3019
|
+
if (err) return json(res, { ok: false, error: err, wrote }, 500);
|
|
3020
|
+
pushIngest(pr);
|
|
3021
|
+
return json(res, { ok: true, wrote });
|
|
3022
|
+
}
|
|
2148
3023
|
if (p === '/api/fs/list') {
|
|
2149
3024
|
const listing = fsList(url.searchParams.get('path'));
|
|
2150
3025
|
if (!listing) return json(res, { ok: false, error: 'not a directory' }, 404);
|
|
@@ -2224,10 +3099,64 @@ async function handleRequest(req, res) {
|
|
|
2224
3099
|
skill: { id: built.slug, label: built.label, skill: built.slug,
|
|
2225
3100
|
description: built.description, output: built.output, source: 'user' } });
|
|
2226
3101
|
}
|
|
3102
|
+
// ── profiles (workspace-profiles R1/R2/R4). ──────────────────────────────────────────────────
|
|
3103
|
+
// Four states, mapped straight from readProfiles() and NEVER collapsed: the directory does not
|
|
3104
|
+
// exist (calm, path named) · it exists and holds nothing (a successful read of nothing, also
|
|
3105
|
+
// calm, different words) · it holds descriptors, some of which are broken (both lists ship —
|
|
3106
|
+
// one bad file must not turn the surface into a stated absence) · it could not be read (RED).
|
|
3107
|
+
//
|
|
3108
|
+
// `env` values are absent from this payload BY CONSTRUCTION (profileWire). An overlay is where a
|
|
3109
|
+
// credential lives, and D08's discipline covers anything the browser receives; `env_keys` exists
|
|
3110
|
+
// so the pane can say WHAT was applied without shipping what it was.
|
|
3111
|
+
if (p === '/api/profiles' && req.method === 'GET') {
|
|
3112
|
+
const read = readProfilesNow();
|
|
3113
|
+
return json(res, {
|
|
3114
|
+
ok: read.ok !== false,
|
|
3115
|
+
absent: !!read.absent,
|
|
3116
|
+
dir: read.dir,
|
|
3117
|
+
profiles: (read.profiles || []).map((x) => profileWire(x, read)),
|
|
3118
|
+
broken: (read.broken || []).map((b) => ({ file: b.file, error: b.error })),
|
|
3119
|
+
prefs: readPrefs(),
|
|
3120
|
+
error: read.error || null,
|
|
3121
|
+
});
|
|
3122
|
+
}
|
|
3123
|
+
if (p === '/api/profiles/prefer' && req.method === 'POST') {
|
|
3124
|
+
let body = {};
|
|
3125
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
3126
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
3127
|
+
const agent = typeof body.agent === 'string' ? body.agent.trim() : '';
|
|
3128
|
+
if (!agent) return json(res, { ok: false, error: 'an agent name is required' }, 400);
|
|
3129
|
+
// "Unknown agent" has to mean something CHECKABLE, and PMOS keeps no agent registry — the
|
|
3130
|
+
// roster is derived from live sessions, which is a client-side fact. What the server can check
|
|
3131
|
+
// is the only thing that matters here: whether it could ever start that agent. A preference
|
|
3132
|
+
// for a CLI that is not on this machine would preselect the composer for something that
|
|
3133
|
+
// cannot spawn. `shell` is the one built-in kind and has no binary of its own.
|
|
3134
|
+
if (agent !== 'shell' && !commandExists(agent)) {
|
|
3135
|
+
return json(res, { ok: false, error: `“${agent.slice(0, 40)}” is not on PATH — PMOS will not remember a preference for an agent it cannot start` }, 400);
|
|
3136
|
+
}
|
|
3137
|
+
// PMOS does not remember a preference for a descriptor it cannot read. The preference is its
|
|
3138
|
+
// OWN memory (it preselects the composer and starts nothing), so a stored id that resolves to
|
|
3139
|
+
// nothing would silently preselect a chip that is not on screen.
|
|
3140
|
+
const pres = resolveProfileForSpawn(body.profile);
|
|
3141
|
+
if (!pres.ok) return json(res, { ok: false, error: pres.error }, 400);
|
|
3142
|
+
const prefs = readPrefs();
|
|
3143
|
+
if (pres.id == null) delete prefs[agent]; else prefs[agent] = pres.id;
|
|
3144
|
+
const err = writePrefs(prefs);
|
|
3145
|
+
if (err) return json(res, { ok: false, error: err }, 500);
|
|
3146
|
+
return json(res, { ok: true, prefs });
|
|
3147
|
+
}
|
|
2227
3148
|
if (p === '/api/schedules' && req.method === 'GET') {
|
|
2228
3149
|
const wsFilter = getWs(url.searchParams.get('ws'));
|
|
2229
3150
|
const id = wsFilter ? wsFilter.id : null;
|
|
2230
|
-
const list = readSchedules().filter((s) => !id || s.ws === id).map((s) =>
|
|
3151
|
+
const list = readSchedules().filter((s) => !id || s.ws === id).map((s) => {
|
|
3152
|
+
// `profile_applies` is the difference between "no profile was chosen" and "a profile could
|
|
3153
|
+
// not apply here at all": a skill-kind task runs IN THIS PROCESS and never creates a PTY, so
|
|
3154
|
+
// the detail row states that instead of offering a control that would do nothing (R8.3).
|
|
3155
|
+
const t = taskById(s.task || s.skill);
|
|
3156
|
+
return { ...s, next: scheduleLogic.nextRun(s),
|
|
3157
|
+
profile: s.profile == null ? null : s.profile,
|
|
3158
|
+
profile_applies: !!(t && t.kind === 'agent') };
|
|
3159
|
+
});
|
|
2231
3160
|
return json(res, { schedules: list, now: nowISO() });
|
|
2232
3161
|
}
|
|
2233
3162
|
if (p === '/api/schedules' && req.method === 'POST') {
|
|
@@ -2236,7 +3165,11 @@ async function handleRequest(req, res) {
|
|
|
2236
3165
|
const norm = scheduleLogic.normalizeSchedule(body);
|
|
2237
3166
|
if (!norm.ok) return json(res, { ok: false, error: norm.error }, 400);
|
|
2238
3167
|
const w = getWs(body.ws);
|
|
2239
|
-
|
|
3168
|
+
// A profile named on a schedule is checked NOW, not at fire time — the PM is here to be told.
|
|
3169
|
+
const pres = resolveProfileForSpawn(body.profile);
|
|
3170
|
+
if (!pres.ok) return json(res, { ok: false, error: pres.error }, 400);
|
|
3171
|
+
const sched = { id: 'sc-' + Date.now().toString(36), ws: w ? w.id : wsId(SELF_ROOT), ...norm.schedule,
|
|
3172
|
+
profile: pres.id, lastRun: null, lastStatus: null };
|
|
2240
3173
|
sched.nextRun = scheduleLogic.nextRun(sched);
|
|
2241
3174
|
const list = readSchedules(); list.unshift(sched); writeSchedules(list);
|
|
2242
3175
|
return json(res, { ok: true, schedule: sched });
|
|
@@ -2247,6 +3180,13 @@ async function handleRequest(req, res) {
|
|
|
2247
3180
|
if (!s) return json(res, { ok: false, error: 'no such schedule' }, 404);
|
|
2248
3181
|
if (typeof body.enabled === 'boolean') s.enabled = body.enabled;
|
|
2249
3182
|
for (const k of ['name', 'cadence', 'day', 'dayOfMonth', 'time', 'task', 'skill']) if (body[k] != null) s[k] = body[k];
|
|
3183
|
+
// `profile` is settable to null (clear it) as well as to an id, so it is checked for PRESENCE
|
|
3184
|
+
// of the key rather than for a non-null value — the other fields above cannot be cleared.
|
|
3185
|
+
if ('profile' in body) {
|
|
3186
|
+
const pres = resolveProfileForSpawn(body.profile);
|
|
3187
|
+
if (!pres.ok) return json(res, { ok: false, error: pres.error }, 400);
|
|
3188
|
+
s.profile = pres.id;
|
|
3189
|
+
}
|
|
2250
3190
|
s.nextRun = scheduleLogic.nextRun(s); writeSchedules(list);
|
|
2251
3191
|
return json(res, { ok: true, schedule: s });
|
|
2252
3192
|
}
|
|
@@ -2375,9 +3315,96 @@ async function handleRequest(req, res) {
|
|
|
2375
3315
|
const w = getWs(url.searchParams.get('ws'));
|
|
2376
3316
|
const root = w ? w.root : SELF_ROOT;
|
|
2377
3317
|
let out;
|
|
2378
|
-
try { out = buildConsole(name, w, root); }
|
|
3318
|
+
try { out = buildConsole(name, w, root, url.searchParams); }
|
|
2379
3319
|
catch (e) { out = { ok: false, error: 'console read failed: ' + String((e && e.message) || 'error').slice(0, 120) }; }
|
|
2380
|
-
|
|
3320
|
+
// A surface may name an HTTP status for a MALFORMED REQUEST only (400 — a missing required
|
|
3321
|
+
// param). Every discriminated READ result stays 200, including {ok:false}: the client's
|
|
3322
|
+
// honest-state rendering is driven by the body, and a transport code cannot carry the reason.
|
|
3323
|
+
// Stripped by spreading rather than deleting — some surfaces return frozen objects.
|
|
3324
|
+
const code = (out && Number.isFinite(out.http_status)) ? out.http_status : 200;
|
|
3325
|
+
if (out && out.http_status != null) { out = { ...out }; delete out.http_status; }
|
|
3326
|
+
return json(res, { name, ...out }, code);
|
|
3327
|
+
}
|
|
3328
|
+
// ── ONE initiative, assembled (the four-panel surface). ────────────────────────────────────
|
|
3329
|
+
// Panel 3 needs the record, its artifacts, its ladder, its external references, its anchor and
|
|
3330
|
+
// its sessions to answer a single question about a single initiative. Every one of those is
|
|
3331
|
+
// already produced somewhere here; what did not exist was the JOIN, so each client had to make
|
|
3332
|
+
// it — four round-trips per selection, and four chances for two surfaces to disagree about one
|
|
3333
|
+
// record. This route calls the SAME producers the existing endpoints call and joins them once.
|
|
3334
|
+
// The existing endpoints are deliberately unchanged: other surfaces still read them.
|
|
3335
|
+
//
|
|
3336
|
+
// GET → the assembled payload.
|
|
3337
|
+
// POST → append one declaration to a file-mode store (see fourPanel's header for why a link is
|
|
3338
|
+
// a declaration and never an outbound call).
|
|
3339
|
+
if (p === '/api/initiative' && req.method === 'GET') {
|
|
3340
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
3341
|
+
const root = w ? w.root : SELF_ROOT;
|
|
3342
|
+
const id = url.searchParams.get('id') || '';
|
|
3343
|
+
// An id is a filename, not a path — the same guard the handoff surface applies, and the reason
|
|
3344
|
+
// this route can join a path without a traversal check at every use site.
|
|
3345
|
+
if (!fourPanel.validId(id)) {
|
|
3346
|
+
return json(res, { ok: false, error: 'an initiative id is required, and it must be a record name' }, 400);
|
|
3347
|
+
}
|
|
3348
|
+
const engine = consoleState.loadEngine();
|
|
3349
|
+
if (!engine.ok) return json(res, { ok: false, id, error: engine.error });
|
|
3350
|
+
const L = engine.engine.layout(root);
|
|
3351
|
+
// The ENGINE's frontmatter parser, not the workspace's — see lib/four-panel.js on why the two
|
|
3352
|
+
// disagree and why this endpoint's `frontmatter` has to agree with its own `ladder`.
|
|
3353
|
+
const rec = fourPanel.readRecord({ root, dir: L.initiatives, id, parse: engine.engine.parseLeadingFrontmatter });
|
|
3354
|
+
// A record that would not READ is the one fact the panel must show, and it is the whole
|
|
3355
|
+
// answer: deriving a ladder over a file that could not be parsed would be deriving over
|
|
3356
|
+
// nothing, and reporting the result beside the failure would invite reading it as data.
|
|
3357
|
+
let ladder = null, linksOut = null;
|
|
3358
|
+
if (rec.ok) {
|
|
3359
|
+
try { ladder = consoleState.ladder({ root, id }); }
|
|
3360
|
+
catch (e) { ladder = { ok: false, error: 'the ladder could not be derived: ' + String((e && e.message) || 'error').slice(0, 140) }; }
|
|
3361
|
+
// An ABSENT record declares nothing, so there is nothing for the handoff reader to read —
|
|
3362
|
+
// distinct from a record that declares no handoff, which readLinks reports as empty.
|
|
3363
|
+
if (!rec.absent) {
|
|
3364
|
+
try { linksOut = handoff.readLinks(rec.frontmatter || {}); }
|
|
3365
|
+
catch (e) { linksOut = null; void e; }
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
let index = { initiatives: [] };
|
|
3369
|
+
try { if (wsShape(root) === 'pmos') index = initiativeIndex(root); }
|
|
3370
|
+
catch (e) { void e; }
|
|
3371
|
+
const stateDir = fourPanel.storeDir(engine.engine, root);
|
|
3372
|
+
const out = fourPanel.assemble({
|
|
3373
|
+
id, rec, ladder, links: linksOut, index,
|
|
3374
|
+
sessions: sessionList(), workspaces: wsList(),
|
|
3375
|
+
anchor: rec.ok && !rec.absent
|
|
3376
|
+
? fourPanel.anchorFor({ anchors: anchorLib(), root, raw: (rec.frontmatter || {}).parent_kr })
|
|
3377
|
+
: null,
|
|
3378
|
+
artifactLinks: fourPanel.readDeclarations(stateDir, fourPanel.ARTIFACT_LINKS),
|
|
3379
|
+
initiativeLinks: fourPanel.readDeclarations(stateDir, fourPanel.INITIATIVE_LINKS),
|
|
3380
|
+
});
|
|
3381
|
+
// Discriminated READ results stay 200 — the body carries the reason, the transport code cannot.
|
|
3382
|
+
return json(res, { ws: w ? w.id : null, ...out });
|
|
3383
|
+
}
|
|
3384
|
+
if (p === '/api/initiative/link' && req.method === 'POST') {
|
|
3385
|
+
// Recording that an artifact corresponds to an external page. This writes a DECLARATION and
|
|
3386
|
+
// makes no request: PMOS holds no credential, so it can state what the PM says the mapping is
|
|
3387
|
+
// and it cannot state anything about the destination's contents.
|
|
3388
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
3389
|
+
let body = {};
|
|
3390
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
3391
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
3392
|
+
const w2 = body.ws ? getWs(body.ws) : w;
|
|
3393
|
+
const kind = String(body.store || 'artifact');
|
|
3394
|
+
const built = kind === 'initiative'
|
|
3395
|
+
? fourPanel.initiativeLinkRow(body)
|
|
3396
|
+
: fourPanel.artifactLinkRow(body);
|
|
3397
|
+
if (!built.ok) return json(res, { ok: false, error: built.error }, 400);
|
|
3398
|
+
const engine = consoleState.loadEngine();
|
|
3399
|
+
if (!engine.ok) return json(res, { ok: false, error: engine.error }, 500);
|
|
3400
|
+
const stateDir = fourPanel.storeDir(engine.engine, w2 ? w2.root : SELF_ROOT);
|
|
3401
|
+
const wrote = fourPanel.appendDeclaration(
|
|
3402
|
+
stateDir,
|
|
3403
|
+
kind === 'initiative' ? fourPanel.INITIATIVE_LINKS : fourPanel.ARTIFACT_LINKS,
|
|
3404
|
+
built.row,
|
|
3405
|
+
);
|
|
3406
|
+
// A write that failed returns 500 and says so — never a false green (the /api/gate rule).
|
|
3407
|
+
return json(res, { ...wrote, connectors: fourPanel.connectors() }, wrote.ok ? 200 : 500);
|
|
2381
3408
|
}
|
|
2382
3409
|
if (p === '/api/search') {
|
|
2383
3410
|
// Episodic recall over this workspace's own past sessions. Localhost only, like everything
|
|
@@ -2811,15 +3838,25 @@ wss.on('connection', (ws) => {
|
|
|
2811
3838
|
} else if (m.t === 'resize') {
|
|
2812
3839
|
const s = sessions.get(m.id); if (s && s.pty) { try { s.pty.resize(m.cols, m.rows); s.cols = m.cols; s.rows = m.rows; } catch {} }
|
|
2813
3840
|
} else if (m.t === 'new') {
|
|
2814
|
-
const r = spawnWithBranch({ name: m.name, agent: m.agent, cwd: m.cwd, ws: m.ws, branch: m.branch, createBranch: m.createBranch, isolate: m.isolate });
|
|
2815
|
-
|
|
3841
|
+
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 });
|
|
3842
|
+
// A refused profile is its own sentence, not "branch: …" — the composer's error line names
|
|
3843
|
+
// what PMOS could not read, and nothing was started.
|
|
3844
|
+
if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^profile /.test(r.error || '') ? '' : 'branch: ') + r.error })); return; }
|
|
2816
3845
|
// a non-fatal truth about what just happened (a worktree's path, a branch ignored on a
|
|
2817
3846
|
// non-repo) — said out loud, never swallowed
|
|
2818
3847
|
if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
|
|
2819
3848
|
ws.send(JSON.stringify({ t: 'created', id: r.session.id }));
|
|
2820
3849
|
} else if (m.t === 'respawn') {
|
|
2821
3850
|
const old = sessions.get(m.id); if (!old || old.pty) return;
|
|
2822
|
-
|
|
3851
|
+
// `respawn` is UNCHANGED on the wire (profiles R9): the profile is read off the record the
|
|
3852
|
+
// server already holds, not re-sent by the client — a client that forgot it would otherwise
|
|
3853
|
+
// silently restart the pane on a different environment, and the pane would look identical.
|
|
3854
|
+
const carried = 'profile' in old ? old.profile : null;
|
|
3855
|
+
const s = spawnSession({ name: old.name, agent: old.agent, cwd: path.relative(old.root, old.cwd), ws: old.ws, profile: carried });
|
|
3856
|
+
// A descriptor deleted since the original spawn does NOT get substituted. The respawn goes
|
|
3857
|
+
// ahead with no profile — the pane line already says so — and the reason is said out loud
|
|
3858
|
+
// rather than left for the PM to notice.
|
|
3859
|
+
if (s.profileRefusal) ws.send(JSON.stringify({ t: 'notice', notice: `respawned without a profile — ${s.profileRefusal}` }));
|
|
2823
3860
|
removeSession(old.id); // the corpse doesn't linger — respawn replaces, not accumulates
|
|
2824
3861
|
ws.send(JSON.stringify({ t: 'created', id: s.id }));
|
|
2825
3862
|
} else if (m.t === 'kill') {
|
|
@@ -2915,4 +3952,8 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
|
|
|
2915
3952
|
trimBuf, SCROLLBACK,
|
|
2916
3953
|
// the liveness heartbeat's guard, exported pure so the "idle machine stays idle" half can be
|
|
2917
3954
|
// asserted without a server that has no clients (the suite always has both).
|
|
2918
|
-
shouldHeartbeat, LIVENESS_MS, TX_REREAD_MS
|
|
3955
|
+
shouldHeartbeat, LIVENESS_MS, TX_REREAD_MS,
|
|
3956
|
+
// where the anchor resolver is looked for — exported so the suite can assert the INSTALL
|
|
3957
|
+
// location is in the list. A dev checkout satisfies entry 1 and would hide the loss of entry 2
|
|
3958
|
+
// exactly as it hid the workflow engine's absence for four releases (PKG4).
|
|
3959
|
+
ANCHOR_LIB_PATHS };
|