create-pmos 0.52.0 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/mods/pmos-cockpit/README.md +13 -0
- package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +2 -1
- package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +41 -11
- package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +228 -8
- package/payload/mods/pmos-cockpit/hooks/register.tsx +11 -3
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/plan-items-stage.js +271 -0
- package/payload/scripts/pmos-log.sh +18 -7
- package/payload/scripts/workflow-state.js +28 -2
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/bin/pmos.js +15 -1
- package/workspace/cut-stub.js +40 -0
- package/workspace/first-paint-audit.js +145 -0
- package/workspace/fixtures/capture/requirements/capture-2026-10-04-1.md +10 -0
- package/workspace/fixtures/initiatives-api/list.json +176 -0
- package/workspace/fixtures/initiatives-api/post-403.json +5 -0
- package/workspace/fixtures/initiatives-api/post-ok.json +10 -0
- package/workspace/fixtures/initiatives-api/roadmap.json +208 -0
- package/workspace/fixtures/initiatives-api/sheet.json +112 -0
- package/workspace/fixtures/report-api/draft.json +177 -0
- package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +97 -0
- package/workspace/fixtures/today-api/provider-rows.json +204 -0
- package/workspace/fixtures/today-api/seam-probe-kinds.json +5 -0
- package/workspace/guide/board.md +81 -0
- package/workspace/guide/breakdown.md +154 -0
- package/workspace/guide/command-palette.md +75 -0
- package/workspace/guide/index.md +27 -17
- package/workspace/guide/initiatives-roadmap.md +130 -0
- package/workspace/guide/initiatives-screen.md +89 -0
- package/workspace/guide/initiatives-timeline.md +57 -0
- package/workspace/guide/integrated-work.md +94 -0
- package/workspace/guide/knowledge-graph.md +3 -68
- package/workspace/guide/layout.md +98 -32
- package/workspace/guide/live-notes.md +3 -3
- package/workspace/guide/pm-only-acts.md +89 -0
- package/workspace/guide/pm-only-lines.md +61 -0
- package/workspace/guide/reports-page.md +80 -0
- package/workspace/guide/reports.md +73 -0
- package/workspace/guide/timeline.md +70 -0
- package/workspace/guide/today.md +85 -0
- package/workspace/guide/validate-with-typesafe.md +1 -1
- package/workspace/lib/actor.js +91 -0
- package/workspace/lib/board-cache.js +32 -2
- package/workspace/lib/board-cli.js +108 -0
- package/workspace/lib/board-fold.js +232 -0
- package/workspace/lib/breakdown-export.js +80 -0
- package/workspace/lib/capture.js +98 -0
- package/workspace/lib/engine/plan-items-stage.js +271 -0
- package/workspace/lib/engine/workflow-state.js +28 -2
- package/workspace/lib/factory-join.js +158 -0
- package/workspace/lib/grant-mint.js +113 -0
- package/workspace/lib/health-rules-factory.js +92 -0
- package/workspace/lib/health-rules-plan.js +164 -0
- package/workspace/lib/health.js +79 -0
- package/workspace/lib/initiative-cli.js +222 -0
- package/workspace/lib/initiative-display.js +183 -0
- package/workspace/lib/initiative-needs.js +127 -0
- package/workspace/lib/initiative-rows.js +206 -0
- package/workspace/lib/initiative-writer.js +141 -0
- package/workspace/lib/issues.js +206 -0
- package/workspace/lib/item-cli.js +195 -0
- package/workspace/lib/item-spans.js +137 -0
- package/workspace/lib/measure-cli.js +100 -0
- package/workspace/lib/measurements.js +189 -0
- package/workspace/lib/mobile-nav.js +57 -53
- package/workspace/lib/needs-providers.js +120 -0
- package/workspace/lib/placements.js +440 -0
- package/workspace/lib/plan-items.js +922 -0
- package/workspace/lib/plan-ledgers.js +85 -0
- package/workspace/lib/plan-rules.js +72 -0
- package/workspace/lib/plan-tree.js +367 -0
- package/workspace/lib/pm-guard.js +174 -0
- package/workspace/lib/pm-lines-needs.js +331 -0
- package/workspace/lib/pmos-verbs.js +66 -0
- package/workspace/lib/proactive.js +7 -4
- package/workspace/lib/report-cli.js +152 -0
- package/workspace/lib/report-draft.js +579 -0
- package/workspace/lib/report-needs.js +213 -0
- package/workspace/lib/route-modules.js +128 -0
- package/workspace/lib/routes-board.js +91 -0
- package/workspace/lib/routes-capture.js +52 -0
- package/workspace/lib/routes-initiatives.js +371 -0
- package/workspace/lib/routes-reports.js +163 -0
- package/workspace/lib/routes-timeline.js +197 -0
- package/workspace/lib/routes-today.js +32 -0
- package/workspace/lib/today-cli.js +99 -0
- package/workspace/lib/today.js +333 -0
- package/workspace/lib/triage.js +163 -0
- package/workspace/lib/verify-work-cli.js +81 -0
- package/workspace/lib/work-check-needs.js +99 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +4 -5
- package/workspace/public/index.html +29 -19
- package/workspace/public/pm/app.js +299 -30
- package/workspace/public/pm/bd-board.js +113 -0
- package/workspace/public/pm/bd-drag.js +59 -0
- package/workspace/public/pm/bd-model.js +152 -0
- package/workspace/public/pm/bd-timeline.js +233 -0
- package/workspace/public/pm/bd-tree.js +244 -0
- package/workspace/public/pm/board-copy.js +57 -0
- package/workspace/public/pm/breakdown-api.js +58 -0
- package/workspace/public/pm/breakdown-copy.js +138 -0
- package/workspace/public/pm/components.css +3054 -0
- package/workspace/public/pm/cycle-review.js +125 -0
- package/workspace/public/pm/data.js +40 -187
- package/workspace/public/pm/dest-board.js +438 -0
- package/workspace/public/pm/dest-initiatives.js +134 -0
- package/workspace/public/pm/dest-reports.js +270 -0
- package/workspace/public/pm/dest-settings.js +217 -0
- package/workspace/public/pm/dest-today.js +176 -0
- package/workspace/public/pm/dialogs.js +88 -0
- package/workspace/public/pm/display-word.js +56 -0
- package/workspace/public/pm/dock.js +105 -0
- package/workspace/public/pm/fixture.js +8 -227
- package/workspace/public/pm/frame-copy.js +94 -0
- package/workspace/public/pm/handoff-box.js +82 -0
- package/workspace/public/pm/http.js +88 -0
- package/workspace/public/pm/icons.js +53 -0
- package/workspace/public/pm/initiative-list.js +218 -0
- package/workspace/public/pm/initiative-sheet.js +261 -0
- package/workspace/public/pm/initiatives-api.js +59 -0
- package/workspace/public/pm/initiatives-copy.js +215 -0
- package/workspace/public/pm/initiatives-timeline-copy.js +59 -0
- package/workspace/public/pm/initiatives-timeline.js +410 -0
- package/workspace/public/pm/initiatives-ui.js +123 -0
- package/workspace/public/pm/item-sheet.js +251 -0
- package/workspace/public/pm/keep-card.js +204 -0
- package/workspace/public/pm/lifecycle.js +7 -1
- package/workspace/public/pm/mobile.css +49 -20
- package/workspace/public/pm/mobile.js +157 -29
- package/workspace/public/pm/modes-live.js +7 -883
- package/workspace/public/pm/modes.js +32 -1024
- package/workspace/public/pm/new-initiative-sheet.js +74 -0
- package/workspace/public/pm/next-step-ui.js +3 -2
- package/workspace/public/pm/palette-copy.js +51 -0
- package/workspace/public/pm/palette.js +312 -0
- package/workspace/public/pm/panel-breakdown.js +702 -0
- package/workspace/public/pm/panel-cockpit.js +63 -295
- package/workspace/public/pm/panel-detail.js +25 -172
- package/workspace/public/pm/panel-initiatives.js +1 -19
- package/workspace/public/pm/panel-repos.js +31 -81
- package/workspace/public/pm/panel-tabs.js +197 -110
- package/workspace/public/pm/panel-trees.js +5 -2
- package/workspace/public/pm/portfolio-report.js +61 -0
- package/workspace/public/pm/primitives.js +656 -0
- package/workspace/public/pm/print.css +33 -0
- package/workspace/public/pm/provider-cards.js +136 -0
- package/workspace/public/pm/registry.js +194 -0
- package/workspace/public/pm/release-card.js +110 -0
- package/workspace/public/pm/report-view.js +309 -0
- package/workspace/public/pm/reports-copy.js +114 -0
- package/workspace/public/pm/roadmap.js +271 -0
- package/workspace/public/pm/sig-watch.js +41 -0
- package/workspace/public/pm/state.js +8 -4
- package/workspace/public/pm/status-banner.js +13 -5
- package/workspace/public/pm/tab-doc.js +2 -4
- package/workspace/public/pm/tab-term.js +12 -2
- package/workspace/public/pm/tab-web.js +3 -2
- package/workspace/public/pm/timeline-copy.js +48 -0
- package/workspace/public/pm/today-cards.js +244 -0
- package/workspace/public/pm/today-copy.js +97 -0
- package/workspace/public/pm/tokens.css +16 -18
- package/workspace/public/pm/topbar.js +102 -0
- package/workspace/public/pm/triage-card.js +60 -0
- package/workspace/public/pm/undo-hold.js +103 -0
- package/workspace/public/pm/view-addrepo.js +2 -1
- package/workspace/public/pm/view-livenotes.js +3 -1
- package/workspace/public/pm/view-today.js +7 -162
- package/workspace/public/vendor/Sortable.min.js +2 -0
- package/workspace/public/vendor/lucide-sprite.js +79 -0
- package/workspace/replaced-tests.json +2864 -0
- package/workspace/run-tests.js +56 -5
- package/workspace/server.js +312 -36
- package/workspace/sheet-answer.js +61 -0
- package/workspace/skills/measure.skill +41 -0
- package/workspace/skills/report-biweekly.skill +23 -25
- package/workspace/skills/tracker-refresh.skill +50 -0
- package/workspace/test-files.json +19 -3
- package/workspace/test-lists/base.json +6 -0
- package/workspace/test-lists/run2-a.json +6 -0
- package/workspace/test-lists/run2-b.json +13 -0
- package/workspace/test-lists/run2-c.json +19 -0
- package/workspace/test-lists/run2-d.json +22 -0
- package/workspace/test-lists/run3-0.json +5 -0
- package/workspace/test-lists/run3-a.json +5 -0
- package/workspace/test-lists/run3-b.json +6 -0
- package/workspace/test-lists/run3-c.json +15 -0
- package/workspace/test-lists/run3-d.json +6 -0
- package/workspace/test-lists/run4-a.json +8 -0
- package/workspace/test-lists/run4-b.json +6 -0
- package/workspace/test-lists/run4-c.json +13 -0
- package/workspace/test-lists/run4-d.json +9 -0
- package/workspace/test-lists/run5-a.json +6 -0
- package/workspace/test-lists/run5-b.json +6 -0
- package/workspace/test-lists/run5-c.json +15 -0
- package/workspace/test-run.js +27 -1
- package/workspace/third-party/README.md +44 -0
- package/workspace/third-party/lucide-static-1.52.0.LICENSE +43 -0
- package/workspace/third-party/sortablejs-1.15.7.LICENSE +21 -0
- package/workspace/design-parity.js +0 -673
- package/workspace/public/pm/tab-task.js +0 -86
- package/workspace/public/pm/view-guide.js +0 -141
- package/workspace/public/pm/view-knowledge.js +0 -225
- package/workspace/public/pm/view-memory.js +0 -122
- package/workspace/public/pm/view-portfolio.js +0 -153
- package/workspace/public/pm/view-reports.js +0 -190
- package/workspace/public/pm/view-scheduled.js +0 -161
- package/workspace/public/pm/view-skills.js +0 -216
package/workspace/server.js
CHANGED
|
@@ -124,6 +124,13 @@ const profilesLib = require('./lib/profiles'); // readProfiles(homeDir)
|
|
|
124
124
|
const focus = require('./lib/focus'); // buildFocus(input) → FocusView (cross-product)
|
|
125
125
|
const handoff = require('./lib/handoff'); // readLinks(parsedFrontmatterObject) — never a path
|
|
126
126
|
const fourPanel = require('./lib/four-panel'); // GET /api/initiative — the one-initiative join + the declaration stores
|
|
127
|
+
const planItems = require('./lib/plan-items.js'); // pm-os-absorb Run 1 — the ONE writer and fold of plan-items.jsonl (control graph)
|
|
128
|
+
const planTree = require('./lib/plan-tree.js'); // …and the Breakdown layout: derived stages, owed rows, the Board columns
|
|
129
|
+
const routeModules = require('./lib/route-modules.js'); // pm-os-absorb Runs 2–5 — each run's routes in its own lib/routes-<name>.js
|
|
130
|
+
const needsProviders = require('./lib/needs-providers.js'); // …and each run's needs-you rows, folded into deriveNeeds
|
|
131
|
+
const pmGuard = require('./lib/pm-guard.js'); // pm-os-absorb Run 2 (R25) — Origin, nonce and writer fields on every PM press
|
|
132
|
+
const actorLib = require('./lib/actor.js'); // …the session-token hash persisted with each agent session
|
|
133
|
+
const grantMint = require('./lib/grant-mint.js'); // …one D81 grant for a bulk lifecycle press (501 until 2B fills it)
|
|
127
134
|
// The "home" repo this server manages. In PMOS-self the app ships INSIDE its repo
|
|
128
135
|
// (pmos/workspace) so the parent dir IS the repo. But a kit/tenant copy is installed standalone
|
|
129
136
|
// — `npm i -g` puts it under node_modules/pmos-workspace, or it runs from an npx bundle — and
|
|
@@ -279,9 +286,12 @@ const SHELL = defaultShell();
|
|
|
279
286
|
// "child session" marker and disables its own transcript saving. Strip those markers here: the
|
|
280
287
|
// harness inside the PTY must run as though the PM launched it directly. Login/subscription do
|
|
281
288
|
// NOT live in these vars (they're in the CLI's own config/keychain), so this never affects auth.
|
|
289
|
+
// PMOS_SESSION and PMOS_BIN are the same kind of marker (pm-os-absorb Run 1): a server started from inside an agent
|
|
290
|
+
// session holds that session's token, and a shell that inherited it would record the PM's `pmos item` as the agent's.
|
|
291
|
+
// spawnSession gives them to an agent session only, as its own values.
|
|
282
292
|
const INHERITED_SESSION_MARKERS = [
|
|
283
293
|
'CLAUDECODE', 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_ENTRYPOINT',
|
|
284
|
-
'CLAUDE_CODE_EXECPATH', 'CLAUDE_PID', 'AI_AGENT', 'CLAUDE_EFFORT',
|
|
294
|
+
'CLAUDE_CODE_EXECPATH', 'CLAUDE_PID', 'AI_AGENT', 'CLAUDE_EFFORT', 'PMOS_SESSION', 'PMOS_BIN',
|
|
285
295
|
];
|
|
286
296
|
function sessionEnv() {
|
|
287
297
|
const env = { ...process.env };
|
|
@@ -1847,7 +1857,7 @@ function surfaceInitiatives(root, w) {
|
|
|
1847
1857
|
const acc = readAcceptances(w);
|
|
1848
1858
|
const life = readLifecycle(w);
|
|
1849
1859
|
const runs = readRunCounts(w);
|
|
1850
|
-
const needsRaw = readNeeds(w) || [];
|
|
1860
|
+
const needsRaw = (readNeeds(w) || []).filter(notPlanNeed);
|
|
1851
1861
|
const needsBy = new Map();
|
|
1852
1862
|
for (const n of needsRaw) if (n && n.initiative_id) needsBy.set(n.initiative_id, (needsBy.get(n.initiative_id) || 0) + 1);
|
|
1853
1863
|
|
|
@@ -2189,6 +2199,11 @@ const SEED_SETS = {
|
|
|
2189
2199
|
{ name: 'Bi-weekly report draft', skill: 'report-biweekly', cadence: 'bi-weekly', day: 'Fri', time: '16:00' },
|
|
2190
2200
|
{ name: 'Monthly report draft', skill: 'report-monthly', cadence: 'monthly', dayOfMonth: 1, time: '08:00' },
|
|
2191
2201
|
],
|
|
2202
|
+
// Run 5 (X19): the agent step that brings tracker-owned plan items up to date from local files. It is its own set, not a
|
|
2203
|
+
// fifth line of `reports`, so pressing `reports` still makes the four report schedules. It is weekly, Friday 15:30: the
|
|
2204
|
+
// bi-weekly draft (Friday 16:00) fires every other week from whichever Friday it was set up, and a weekly refresh is
|
|
2205
|
+
// never out of step with it. A refresh that finds no change writes no line.
|
|
2206
|
+
tracker: [{ name: 'Tracker refresh', skill: 'tracker-refresh', cadence: 'weekly', day: 'Fri', time: '15:30' }],
|
|
2192
2207
|
};
|
|
2193
2208
|
function readSchedules() {
|
|
2194
2209
|
try { const a = JSON.parse(fs.readFileSync(schedFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; }
|
|
@@ -2336,6 +2351,12 @@ function persist(s) {
|
|
|
2336
2351
|
// so a session survives even if the workspace registry is lost (it re-registers its root).
|
|
2337
2352
|
const meta = { id: s.id, ws: s.ws, root: s.root, name: s.name, agent: s.agent, cwd: s.cwd,
|
|
2338
2353
|
state: s.state, note: s.note || '', created: s.created, tmux: s.tmux || null, origin: s.origin || null };
|
|
2354
|
+
// The breakdown item a Hand to agent session is bound to, so a restart keeps the binding (pm-os-absorb Run 1).
|
|
2355
|
+
if (s.plan_item) meta.plan_item = s.plan_item;
|
|
2356
|
+
// pm-os-absorb Run 2 (R25): the SHA-256 of the agent's PMOS_SESSION token, never the token (D08). A restored session
|
|
2357
|
+
// has no token in memory, so its stored hash is written back; lib/actor.js sessionForToken reads the map after a restart.
|
|
2358
|
+
const th = s.token ? actorLib.tokenHash(s.token) : (typeof s.tokenHash === 'string' ? s.tokenHash : null);
|
|
2359
|
+
if (th) meta.tokenHash = th;
|
|
2339
2360
|
// The profile ID ONLY — never the descriptor and never its env, which is where a credential
|
|
2340
2361
|
// would live (D08). The key is written only for a session PMOS created, so its ABSENCE stays a
|
|
2341
2362
|
// fact: a record persisted before profiles existed restores as "not recorded", and inventing a
|
|
@@ -2604,6 +2625,8 @@ function sessionList() {
|
|
|
2604
2625
|
id: s.id, ws: s.ws, name: s.name, agent: s.agent, state: s.state, created: s.created,
|
|
2605
2626
|
model: 'model' in s ? s.model : null, model_line: modelLib.line('model' in s ? s.model : null),
|
|
2606
2627
|
cwd: path.relative(s.root, s.cwd) || '.', note: s.note || '', origin: s.origin || null,
|
|
2628
|
+
plan_item: s.plan_item || null, // Hand to agent: the breakdown item this session is bound to (pm-os-absorb Run 1)
|
|
2629
|
+
// No `token` and no `tokenHash` here, ever: the page never sees a session token (F5; r25-guards.test.js checks it).
|
|
2607
2630
|
// Derived presentation state, JOINED from both sources: the cadence clock above and the
|
|
2608
2631
|
// transcript's answer to whose turn it is (lib/session-state). working | quiet | exited |
|
|
2609
2632
|
// needs | blocked. `shell`-ness stays on `agent`, because it is orthogonal to state;
|
|
@@ -2836,10 +2859,12 @@ function tmuxKillSession(name) { if (TMUX && name) tmuxCtl(['kill-session', '-t'
|
|
|
2836
2859
|
// not is the honest-state contract inverted, so this is a correctness fix and not a tidy-up.
|
|
2837
2860
|
// `new-session -e K=V` (tmux ≥3.2) sets the environment of THAT session only; a session that
|
|
2838
2861
|
// declares no profile is left genuinely unset rather than inheriting a neighbour's.
|
|
2839
|
-
|
|
2862
|
+
// `extra` carries what PMOS itself gives one session (PMOS_SESSION, its token for `pmos item`); the profile
|
|
2863
|
+
// overlay stays LAST so nothing in `extra` can unset the workspace flag the overlay carries.
|
|
2864
|
+
function createTmuxSession(name, cmd, workdir, cols, rows, extra, overlay) {
|
|
2840
2865
|
if (tmuxHasSession(name)) return false;
|
|
2841
2866
|
const eArgs = [];
|
|
2842
|
-
for (const [k, v] of Object.entries(overlay || {})) eArgs.push('-e', `${k}=${v}`);
|
|
2867
|
+
for (const [k, v] of Object.entries({ ...(extra || {}), ...(overlay || {}) })) eArgs.push('-e', `${k}=${v}`);
|
|
2843
2868
|
const r = tmuxCtl(['-f', TMUX_CONF, 'new-session', '-d', '-s', name,
|
|
2844
2869
|
...eArgs, '-x', String(cols || 80), '-y', String(rows || 24), '-c', workdir, '--', cmd]);
|
|
2845
2870
|
return r.status === 0;
|
|
@@ -3049,8 +3074,16 @@ function resolveProfileForSpawn(id) {
|
|
|
3049
3074
|
: `profile "${id}" is not among the descriptors in ${read.dir} — PMOS will not substitute another` };
|
|
3050
3075
|
}
|
|
3051
3076
|
|
|
3052
|
-
function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, initiative, modelAsk }) {
|
|
3077
|
+
function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, initiative, modelAsk, planItem }) {
|
|
3053
3078
|
const id = nextSessionId();
|
|
3079
|
+
/* pm-os-absorb Run 1 — every AGENT session gets a token, `<id>.<8 hex>`, in its environment as PMOS_SESSION, and
|
|
3080
|
+
* PMOS_BIN, this workspace's own bin/pmos.js. `pmos item` run inside it is then attributed `agent:<token>` (the token
|
|
3081
|
+
* wins over a terminal, lib/plan-items.js actorFromCli). A plain shell session is the PM's own terminal, so it gets
|
|
3082
|
+
* neither (both set empty, never the server's own), and its `pmos item` stays the PM's. The token is an attribution,
|
|
3083
|
+
* not a secret, and Run 2's nonce is the guard. The ledger keeps it whole (it is committed with the repository); the
|
|
3084
|
+
* routes the page reads send `agent:sess-<n>` without it (lib/plan-items.js layoutForPage and needsFor), and the
|
|
3085
|
+
* session list does not carry it. */
|
|
3086
|
+
const sessionToken = `${id}.${crypto.randomBytes(4).toString('hex')}`;
|
|
3054
3087
|
const w = getWs(ws);
|
|
3055
3088
|
const root = w ? w.root : SELF_ROOT;
|
|
3056
3089
|
const isShell = agent === 'shell';
|
|
@@ -3071,8 +3104,17 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
3071
3104
|
// `profileRefusal` is server-internal: callers that can refuse read it and say why.
|
|
3072
3105
|
const pres = resolveProfileForSpawn(profile);
|
|
3073
3106
|
const penv = sessionEnvWithProfile(pres.profile);
|
|
3107
|
+
// sessionEnvWithProfile returns a fresh object (sessionEnv copies process.env), so these touch this session only.
|
|
3108
|
+
// sessionEnv() already strips both from the server's own environment. A shell still gets them SET EMPTY: on tmux they
|
|
3109
|
+
// travel as `-e`, which overrides a daemon an older server seeded with its token, and an empty PMOS_SESSION fails
|
|
3110
|
+
// SESSION_RE, so a `pmos item` typed in the shell is the PM's.
|
|
3111
|
+
const itemEnv = isShell ? { PMOS_SESSION: '', PMOS_BIN: '' } : { PMOS_SESSION: sessionToken, PMOS_BIN: path.join(__dirname, 'bin', 'pmos.js') };
|
|
3112
|
+
Object.assign(penv, itemEnv);
|
|
3074
3113
|
const base = { id, ws: w ? w.id : wsId(SELF_ROOT), root, name: (name && String(name).trim()) || agent, agent, origin: tag,
|
|
3075
3114
|
profile: pres.id, profileRefusal: pres.ok ? null : pres.error };
|
|
3115
|
+
// Hand to agent: the breakdown item this session was started for, or null. Set only by /api/plan-items/handoff/go.
|
|
3116
|
+
base.plan_item = planItem || null;
|
|
3117
|
+
base.token = sessionToken;
|
|
3076
3118
|
|
|
3077
3119
|
/* WHICH MODEL (D75). Resolved once, here, with the profile, so the record can say only what was applied.
|
|
3078
3120
|
* Three states are kept apart — not asked, applied, and "this binary has no model control" — and a choice that
|
|
@@ -3127,7 +3169,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
3127
3169
|
// the OVERLAY, not the merged env — the merged env is for the direct-PTY path below, which
|
|
3128
3170
|
// really does spawn the process itself. See createTmuxSession for why the two differ.
|
|
3129
3171
|
// The launch args are plain tokens by construction (pmos-model's strict pattern), so joining them is safe.
|
|
3130
|
-
if (!createTmuxSession(tname, launchArgs.length ? [cmd, ...launchArgs].join(' ') : cmd, workdir, 80, 24, sessionFlag.tmuxOverlay(profileOverlay(pres.profile)))) {
|
|
3172
|
+
if (!createTmuxSession(tname, launchArgs.length ? [cmd, ...launchArgs].join(' ') : cmd, workdir, 80, 24, itemEnv, sessionFlag.tmuxOverlay(profileOverlay(pres.profile)))) {
|
|
3131
3173
|
const why = `could not start a tmux session for '${cmd}'. Run \`npm run doctor\` in workspace/.`;
|
|
3132
3174
|
s.state = 'exited'; s.tmux = null; s.note = why;
|
|
3133
3175
|
s.buf = `\x1b[38;5;203m✗ ${why}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`;
|
|
@@ -3191,7 +3233,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
3191
3233
|
// A FAILED git operation in a real repo must never spawn a session on the wrong branch (rubric
|
|
3192
3234
|
// hard floor): we return {ok:false} and spawn nothing. Kept as its own function so the
|
|
3193
3235
|
// git-then-maybe-spawn contract is unit-testable without a WS client.
|
|
3194
|
-
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative, modelAsk, origin, initialInput }) {
|
|
3236
|
+
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative, modelAsk, origin, initialInput, planItem }) {
|
|
3195
3237
|
const w = getWs(ws);
|
|
3196
3238
|
if (!w) return { ok: false, error: 'no workspace' };
|
|
3197
3239
|
// The profile is checked BEFORE any git operation. A composer that picked a profile PMOS cannot
|
|
@@ -3238,7 +3280,7 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
|
|
|
3238
3280
|
broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
|
|
3239
3281
|
}
|
|
3240
3282
|
}
|
|
3241
|
-
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative, modelAsk, origin, initialInput });
|
|
3283
|
+
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative, modelAsk, origin, initialInput, planItem });
|
|
3242
3284
|
// The co-tenancy fact, on the ordinary path too. Note where this sits: OUTSIDE the `if (branch)`
|
|
3243
3285
|
// block above. The isolate default already computes that other sessions would be disturbed — and
|
|
3244
3286
|
// then the whole git block is skipped when no branch was picked, which is the common path and the
|
|
@@ -3341,7 +3383,7 @@ function startPreview({ initiative, repoKey, ws }) {
|
|
|
3341
3383
|
// One click. Resolves the policy, then hands the SAME seam spawnWithBranch already exposes the
|
|
3342
3384
|
// branch/isolate decision it made — so the inherited hard floor (a failed git operation spawns
|
|
3343
3385
|
// nothing) is enforced in exactly one place and is not re-implemented here.
|
|
3344
|
-
function startWork({ initiative, repoKey, ws, agent, name, profile, origin, initialInput }) {
|
|
3386
|
+
function startWork({ initiative, repoKey, ws, agent, name, profile, origin, initialInput, planItem }) {
|
|
3345
3387
|
const key = repoKeyFor({ repoKey, ws });
|
|
3346
3388
|
if (!key) return { ok: false, error: 'no repository selected' };
|
|
3347
3389
|
const trees = treesOfRepo(key);
|
|
@@ -3367,6 +3409,8 @@ function startWork({ initiative, repoKey, ws, agent, name, profile, origin, init
|
|
|
3367
3409
|
// next-step-actions: a session started from a next step carries its origin (so the budget can count it) and its
|
|
3368
3410
|
// prompt. Only the /api/next-step/go route passes either; the socket's `start` message never does.
|
|
3369
3411
|
origin, initialInput,
|
|
3412
|
+
// Hand to agent (pm-os-absorb Run 1): only /api/plan-items/handoff/go passes it.
|
|
3413
|
+
planItem,
|
|
3370
3414
|
});
|
|
3371
3415
|
if (!r.ok) return r;
|
|
3372
3416
|
return { ok: true, session: r.session, notice: r.notice || plan.why, plan: plan.plan };
|
|
@@ -3434,7 +3478,7 @@ function nextStepsFor(w, id) {
|
|
|
3434
3478
|
const acc = readAcceptances(w).get(id) || {};
|
|
3435
3479
|
const lc = readLifecycle(w).get(id) || null;
|
|
3436
3480
|
const ns = deriveNextSteps({ id, accepted: acc.action === 'accept', returned: acc.action === 'return',
|
|
3437
|
-
lifecycle: lc && lc.state !== 'active' ? lc : null, ladder, needs: (readNeeds(w) || []).filter((n) => n && n.initiative_id === id),
|
|
3481
|
+
lifecycle: lc && lc.state !== 'active' ? lc : null, ladder, needs: (readNeeds(w) || []).filter((n) => n && n.initiative_id === id && notPlanNeed(n)),
|
|
3438
3482
|
sessions: sessRows.filter((x) => x.initiative === id || liveIds.has(x.id)) });
|
|
3439
3483
|
nextStepActions.annotate(ns, (it) => nextStepPathOf(root, it));
|
|
3440
3484
|
attachRuns(ns, id);
|
|
@@ -3469,6 +3513,55 @@ function prepareNextStep(body) {
|
|
|
3469
3513
|
judgment: { on: judgmentOn, outside_service: judgmentOn, needs_key: judgmentOn, note: judgmentOn ? 'The question text leaves this machine for the Typesafe service. It needs TYPESAFE_API_KEY in the session.' : 'No outside service is used.' } };
|
|
3470
3514
|
}
|
|
3471
3515
|
|
|
3516
|
+
// ── Hand to agent (pm-os-absorb Run 1, PRD R14) ────────────────────────────────────────────────────────────────
|
|
3517
|
+
// A breakdown item is PREPARED (read-only: the first prompt line, where the session will run, the full prompt folded
|
|
3518
|
+
// away) and then STARTED by the PM's press with exactly that prompt — the next-step pattern. The session is bound to
|
|
3519
|
+
// the item (`plan_item`) and reports with `pmos item`. Neither step writes plan-items.jsonl or planning/work-items.jsonl:
|
|
3520
|
+
// materialization arrives in Run 4. Nothing else starts a bound session — no drag or drop reaches this code.
|
|
3521
|
+
const HANDOFF_WHERE = { 'new-worktree': 'new branch', 'reuse-worktree': 'its branch', 'main-tree-on-branch': 'its branch', 'main-tree': 'this tree' };
|
|
3522
|
+
// The next-step budget, with Hand to agent sessions counted beside next-step ones: both are sessions the workspace
|
|
3523
|
+
// starts on the PM's behalf. nsBudget() itself is unchanged, so the next-step limit reads exactly as before.
|
|
3524
|
+
function handoffBudget() {
|
|
3525
|
+
const today = new Date().toDateString();
|
|
3526
|
+
const all = [...sessions.values()];
|
|
3527
|
+
const running = all.filter((x) => (x.origin === 'next-step' || x.origin === 'plan-item') && x.state !== 'exited').length;
|
|
3528
|
+
const startedToday = nsRead(NS_RUNS()).filter((r) => r && r.at && new Date(r.at).toDateString() === today).length
|
|
3529
|
+
+ all.filter((x) => x.origin === 'plan-item' && x.created && new Date(x.created).toDateString() === today).length;
|
|
3530
|
+
return sessionBudget.check({ running, startedToday, limits: nsLimits() });
|
|
3531
|
+
}
|
|
3532
|
+
function prepareHandoff(body) {
|
|
3533
|
+
const w = body && body.ws ? workspaces.get(String(body.ws)) : null;
|
|
3534
|
+
if (!w) return { ok: false, status: 404, error: 'no such workspace' };
|
|
3535
|
+
const itemId = String((body && body.item) || '');
|
|
3536
|
+
if (!planItems.ID_RE.test(itemId)) return { ok: false, status: 400, error: 'item must be an item id, <initiative>.<10 characters>' };
|
|
3537
|
+
let fold;
|
|
3538
|
+
try { fold = planItems.readFold(w.root); }
|
|
3539
|
+
catch (e) { return { ok: false, status: 500, error: 'could not read the plan-items ledger: ' + String((e && e.message) || 'error').slice(0, 140) }; }
|
|
3540
|
+
const it = fold.items[itemId];
|
|
3541
|
+
if (!it) return { ok: false, status: 404, error: `no item ${itemId} in this repository` };
|
|
3542
|
+
const live = [...sessions.values()].find((x) => x.plan_item === itemId && x.state !== 'exited');
|
|
3543
|
+
if (live) return { ok: false, status: 409, error: 'a session is already working on this item', session: live.id };
|
|
3544
|
+
/* Hand to agent starts an AGENT, never a shell: a prompt typed into a shell would run as commands, and an item's note
|
|
3545
|
+
* is text an agent can write. The page never sends `agent`; anything but claude is refused rather than swapped. */
|
|
3546
|
+
if (body.agent != null && body.agent !== 'claude') return { ok: false, status: 400, error: 'Hand to agent starts claude' };
|
|
3547
|
+
const agent = 'claude';
|
|
3548
|
+
let stageLabel = it.stage;
|
|
3549
|
+
try { const st = planTree.stagesFor(w.root, it.initiative).find((x) => x.id === it.stage); if (st) stageLabel = st.label; } catch { /* the id stands in */ }
|
|
3550
|
+
const gone = new Set(fold.withdrawnIds || []);
|
|
3551
|
+
const { first, prompt } = planItems.handoffPrompt({ ...it, after: (it.after || []).filter((x) => !gone.has(x)) }, it.initiative, stageLabel);
|
|
3552
|
+
const preview = startPreview({ initiative: it.initiative, ws: w.id });
|
|
3553
|
+
if (!preview.ok) return { ok: false, status: 409, error: preview.error || 'no branch and tree could be chosen for this item' };
|
|
3554
|
+
const where = HANDOFF_WHERE[preview.plan] || preview.plan;
|
|
3555
|
+
return {
|
|
3556
|
+
ok: true, item: itemId, initiative: it.initiative, agent, plan: preview.plan, where, first,
|
|
3557
|
+
line: `Agent will: ${first} · ${where}`, prompt,
|
|
3558
|
+
// Go starts exactly what was shown: the prompt, the plan, the agent, the branch and the tree it was shown for.
|
|
3559
|
+
prompt_sha: crypto.createHash('sha256').update([prompt, preview.plan, agent, preview.branch || '', preview.wsId || preview.dir || '', w.id].join('\n')).digest('hex'),
|
|
3560
|
+
details: { branch: preview.branch || null, tree: preview.dir || null, session_name: 'item: ' + it.title.slice(0, 40) },
|
|
3561
|
+
ws: w.id,
|
|
3562
|
+
};
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3472
3565
|
// ── Background prepare, slice D3 (initiative proactive-engine) ──────────────────────────────────────────────────
|
|
3473
3566
|
// The PM approves ONE plan hash from Today. The 60-second tick later starts that session through the SAME path a next-step
|
|
3474
3567
|
// `go` uses (startWork with origin next-step and the prompt that was shown), only while background prepare is on, outside quiet
|
|
@@ -3560,7 +3653,7 @@ function proactiveCtx(now, settings) {
|
|
|
3560
3653
|
const lc = life.get(b.id); const stopped = !!(lc && lc.state !== 'active');
|
|
3561
3654
|
entry.initiatives.push({ id: b.id, title: b.title || b.id, active: !b.accepted && !stopped, lane: b.lane || null, lastRun: lastRun.has(b.id) ? lastRun.get(b.id) : null, hasRuns: hasRuns.has(b.id) });
|
|
3562
3655
|
}
|
|
3563
|
-
entry.needs = (readNeeds(w) || []).filter((n) => n).map((n) => ({ initiative_id: n.initiative_id || null, question: n.question || '', run_id: n.run_id || null }));
|
|
3656
|
+
entry.needs = (readNeeds(w) || []).filter((n) => n && notPlanNeed(n)).map((n) => ({ initiative_id: n.initiative_id || null, question: n.question || '', run_id: n.run_id || null }));
|
|
3564
3657
|
for (const [id, a] of acc) if (a && a.action === 'return') entry.returned.push({ initiative_id: id, note: 'returned at the Acceptance Gate', at: a.at });
|
|
3565
3658
|
entry.schedules = allSchedules.filter((x) => r.ids.has(x.ws)).map((x) => ({ id: x.id, name: x.name || x.label || x.skill || x.id, lastStatus: x.lastStatus || null, lastRun: x.lastRun || null }));
|
|
3566
3659
|
} catch (e) { entry.error = 'the repository could not be read — ' + String((e && e.message) || 'error').slice(0, 120); }
|
|
@@ -3875,10 +3968,33 @@ function leadingFrontmatter(file) { return fmParse.leadingFrontmatter(file); }
|
|
|
3875
3968
|
// The outcome rule mirrors kit/scripts/metrics.py EXACTLY, including "a missing horizon counts
|
|
3876
3969
|
// as due" — a bet with no judgment date can never come due by itself (horizon-gaming-by-omission).
|
|
3877
3970
|
// Derivation only ever reports; it never disposes anything (D57).
|
|
3971
|
+
/* The plan-items ledger belongs to the REPOSITORY (lib/plan-items.js ledgerRoot), not to a working tree, so its needs
|
|
3972
|
+
* rows are derived for ONE registered tree of it: the tree at the ledger's root, else the first registered. Otherwise a
|
|
3973
|
+
* repository with a linked worktree registered would count every Keep card twice. */
|
|
3974
|
+
function isPlanNeedsTree(w) {
|
|
3975
|
+
let mine; try { mine = planItems.ledgerRoot(w.root); } catch { return true; }
|
|
3976
|
+
const same = [...workspaces.values()].filter((t) => { try { return planItems.ledgerRoot(t.root) === mine; } catch { return false; } });
|
|
3977
|
+
if (!same.some((t) => t.id === w.id)) return true;
|
|
3978
|
+
const lead = same.find((t) => path.resolve(t.root) === mine) || same[0];
|
|
3979
|
+
return lead.id === w.id;
|
|
3980
|
+
}
|
|
3878
3981
|
function deriveNeeds(w) {
|
|
3982
|
+
if (!w) return [];
|
|
3983
|
+
/* pm-os-absorb Run 1 — the breakdown's needs come FIRST and do not depend on a state directory: one Keep card
|
|
3984
|
+
* per agent batch, an agent Done with no evidence, and a Keep line that was not written from the page. A ledger
|
|
3985
|
+
* that cannot be read is said here, as a row, rather than turned into an empty list or a 500 for every caller. */
|
|
3986
|
+
let needs = [];
|
|
3987
|
+
if (isPlanNeedsTree(w)) {
|
|
3988
|
+
try { needs = planItems.needsFor(w.root).map((n) => ({ ...n, ws: w.id })); }
|
|
3989
|
+
catch (e) {
|
|
3990
|
+
needs = [{ run_id: 'plan-items:unreadable', initiative_id: null, kind: 'plan-flag', derived: true, doc: null, ws: w.id,
|
|
3991
|
+
question: 'The plan-items ledger could not be read: ' + String((e && e.message) || 'error').slice(0, 160) }];
|
|
3992
|
+
}
|
|
3993
|
+
}
|
|
3994
|
+
// pm-os-absorb Runs 2–5 — the planning layer's other needs rows, one provider file per run (lib/needs-providers.js).
|
|
3995
|
+
needs = needs.concat(needsProviders.rowsFor(w, { lead: isPlanNeedsTree(w) }));
|
|
3879
3996
|
const dir = stateDirFor(w);
|
|
3880
|
-
if (!dir) return
|
|
3881
|
-
const needs = [];
|
|
3997
|
+
if (!dir) return needs;
|
|
3882
3998
|
const settled = new Set();
|
|
3883
3999
|
for (const a of readLedger(w, 'acceptances.jsonl')) if (a.run_id) settled.add(a.run_id);
|
|
3884
4000
|
for (const e of readLedger(w, 'evals.jsonl')) {
|
|
@@ -3923,6 +4039,13 @@ function readNeeds(w) {
|
|
|
3923
4039
|
const seen = new Set(queued.map((n) => n && n.run_id).filter(Boolean));
|
|
3924
4040
|
return queued.concat(deriveNeeds(w).filter((n) => !seen.has(n.run_id)));
|
|
3925
4041
|
}
|
|
4042
|
+
/* pm-os-absorb Run 1 — the breakdown's needs rows (a Keep card, an agent Done with no evidence, a forged Keep) are the
|
|
4043
|
+
* PM's to act on in needs-you and Today, so /api/needs and the per-tree counts carry them. They are NOT evidence for
|
|
4044
|
+
* an initiative's derived state, its next steps or the portfolio's needs list: those read the rows they read before
|
|
4045
|
+
* Run 1, so a pending Keep does not turn an initiative `blocked` with a sentence naming needs.json, and Today does not
|
|
4046
|
+
* show the same batch twice (once as a Keep card, once as an "Unblock" initiative card). */
|
|
4047
|
+
const PLAN_NEED_KINDS = new Set(['keep', 'evidence', 'plan-flag']);
|
|
4048
|
+
const notPlanNeed = (n) => !(n && n.derived === true && PLAN_NEED_KINDS.has(n.kind)) && !needsProviders.isProviderRow(n);
|
|
3926
4049
|
|
|
3927
4050
|
// ── Read-only console surfaces over file-mode state (D64 Track B). ──────────────────────
|
|
3928
4051
|
// The committed hosted snapshot is the seed/demo source when no LIVE file-mode state exists; the
|
|
@@ -3959,6 +4082,10 @@ function needsResult(w) {
|
|
|
3959
4082
|
run_id: n.run_id || null, kind: n.kind || 'question', initiative_id: n.initiative_id || null,
|
|
3960
4083
|
question: n.question || n.t || '(unstated)', derived: !!n.derived, doc: n.doc || null,
|
|
3961
4084
|
deliverable: n.deliverable || null,
|
|
4085
|
+
// pm-os-absorb Run 1 — a breakdown row keeps what its card needs; other rows are unchanged (no new keys).
|
|
4086
|
+
...(n.ws ? { ws: n.ws } : {}), ...(n.batch ? { batch: n.batch } : {}), ...(n.item ? { item: n.item } : {}), ...(n.flag ? { flag: n.flag } : {}),
|
|
4087
|
+
// pm-os-absorb Runs 2–5 — a provider row's Today card (lib/needs-providers.js); absent on every other row.
|
|
4088
|
+
...(n.today && typeof n.today === 'object' ? { today: n.today } : {}),
|
|
3962
4089
|
}));
|
|
3963
4090
|
// `all` is the RAW combined queue — what readNeeds(w) computes — carried so /api/needs can serve
|
|
3964
4091
|
// both the strict state and the queue from one derivation instead of deriving twice per request.
|
|
@@ -3996,10 +4123,14 @@ const needsAgg = new Map(); // ws id -> { at, sig, value }
|
|
|
3996
4123
|
// requirement is about. `none` and `unreadable` are distinct signatures on purpose; a file that
|
|
3997
4124
|
// appears, vanishes or becomes unstattable each invalidate.
|
|
3998
4125
|
function needsSig(w) {
|
|
4126
|
+
// The plan-items ledger feeds needs too (Keep, no evidence, forged Keep), so its signature is part of this one.
|
|
4127
|
+
let plan = 'none';
|
|
4128
|
+
try { plan = planItems.ledgerSigs([{ id: w.id, root: w.root }], null)[w.id].items; } catch { plan = 'unreadable'; }
|
|
4129
|
+
plan += '|' + needsProviders.sigFor(w); // pm-os-absorb Runs 2–5 — and every needs provider's
|
|
3999
4130
|
const f = needsFileFor(w);
|
|
4000
|
-
if (!f) return
|
|
4001
|
-
try { const st = fs.statSync(f); return `${st.mtimeMs}:${st.size}`; }
|
|
4002
|
-
catch { return
|
|
4131
|
+
if (!f) return `none|${plan}`;
|
|
4132
|
+
try { const st = fs.statSync(f); return `${st.mtimeMs}:${st.size}|${plan}`; }
|
|
4133
|
+
catch { return `unreadable|${plan}`; }
|
|
4003
4134
|
}
|
|
4004
4135
|
function needsCountCached(w) {
|
|
4005
4136
|
const hit = needsAgg.get(w.id);
|
|
@@ -4793,7 +4924,18 @@ async function handleRequest(req, res) {
|
|
|
4793
4924
|
const url = new URL(req.url, 'http://localhost');
|
|
4794
4925
|
const p = url.pathname;
|
|
4795
4926
|
|
|
4796
|
-
if (p === '/' || p === '/index.html')
|
|
4927
|
+
if (p === '/' || p === '/index.html') {
|
|
4928
|
+
// pm-os-absorb Run 2 (R25, X11): the per-launch nonce goes into the base meta line, nowhere else. no-store, so a
|
|
4929
|
+
// restarted server never answers a press from a page that holds the previous launch's value.
|
|
4930
|
+
const n = pmGuard.launchNonce();
|
|
4931
|
+
if (!n) return serveFile(res, path.join(PUB, 'index.html'), null, true);
|
|
4932
|
+
return fs.readFile(path.join(PUB, 'index.html'), 'utf8', (err, html) => {
|
|
4933
|
+
if (err) { res.writeHead(404); res.end('not found'); return; }
|
|
4934
|
+
const out = html.replace('<meta name="' + pmGuard.NONCE_META + '" content="" />', '<meta name="' + pmGuard.NONCE_META + '" content="' + n + '" />');
|
|
4935
|
+
res.writeHead(200, { 'Content-Type': TYPES['.html'], 'Cache-Control': 'no-store' });
|
|
4936
|
+
res.end(out);
|
|
4937
|
+
});
|
|
4938
|
+
}
|
|
4797
4939
|
// NB: there is no `/app.js` route. The single-file front end that owned it (public/app.js +
|
|
4798
4940
|
// public/styles.css) was RETIRED with the four-panel design — the shell is index.html plus the
|
|
4799
4941
|
// /pm/ module graph below, and one front end is the point. A stray <script src="/app.js"> now
|
|
@@ -4818,6 +4960,10 @@ async function handleRequest(req, res) {
|
|
|
4818
4960
|
// client feature that depends on it fails in a way no server-side test can see.
|
|
4819
4961
|
if (p === '/lib/apply-change.js') return serveFile(res, path.join(__dirname, 'lib', 'apply-change.js'));
|
|
4820
4962
|
if (p === '/lib/related-docs.js') return serveFile(res, path.join(__dirname, 'lib', 'related-docs.js'));
|
|
4963
|
+
// pm-os-absorb — the words an initiative shows (R3), one mapping for the page and for node over every record.
|
|
4964
|
+
if (p === '/lib/initiative-display.js') return serveFile(res, path.join(__dirname, 'lib', 'initiative-display.js'));
|
|
4965
|
+
// pm-os-absorb — the dated bars of a breakdown (R20): Breakdown › Timeline, Initiatives › Timeline and the report strip.
|
|
4966
|
+
if (p === '/lib/item-spans.js') return serveFile(res, path.join(__dirname, 'lib', 'item-spans.js'));
|
|
4821
4967
|
// …and no `/styles.css` route either: the token layer and every component rule now ship as
|
|
4822
4968
|
// /pm/tokens.css + /pm/mobile.css through the /pm/ directory route below.
|
|
4823
4969
|
// Every browser asks for /favicon.ico unprompted. Without a route it 404s on every load, which
|
|
@@ -4846,6 +4992,10 @@ async function handleRequest(req, res) {
|
|
|
4846
4992
|
// of difference the design-parity comparator reports and a human eye does not.
|
|
4847
4993
|
if (p === '/vendor/ibm-plex-mono-500.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-500.woff2'));
|
|
4848
4994
|
if (p === '/vendor/ibm-plex-mono-700.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-700.woff2'));
|
|
4995
|
+
// D86 — the two vendored browser libraries of pm-os-absorb Run 1, committed under public/vendor/ (no CDN, no
|
|
4996
|
+
// package.json entry). SortableJS does every drag; the sprite holds the lucide icons the Breakdown uses.
|
|
4997
|
+
if (p === '/vendor/Sortable.min.js') return serveFile(res, path.join(PUB, 'vendor', 'Sortable.min.js'));
|
|
4998
|
+
if (p === '/vendor/lucide-sprite.js') return serveFile(res, path.join(PUB, 'vendor', 'lucide-sprite.js'));
|
|
4849
4999
|
// ── the four-panel front end (public/pm/) ──────────────────────────────────────────────────────
|
|
4850
5000
|
// A DIRECTORY route, deliberately, where /lib/ above is an allowlist. The allowlist's own comment
|
|
4851
5001
|
// records why it is a trap: a file missing from it 404s silently and the feature that needed it
|
|
@@ -5060,6 +5210,9 @@ async function handleRequest(req, res) {
|
|
|
5060
5210
|
let body = {};
|
|
5061
5211
|
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
5062
5212
|
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5213
|
+
// R25: writing parent_kr is the anchor change, a PM-only act.
|
|
5214
|
+
const g = pmGuard.requirePmPress(req, body, { act: 'anchor' });
|
|
5215
|
+
if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
|
|
5063
5216
|
// ONE item. There is no array form, no `items`, no `all` — the tedium IS the gate (R4). A
|
|
5064
5217
|
// bulk confirm is how a dozen guessed anchors land on the board with every check green.
|
|
5065
5218
|
if (Array.isArray(body.item) || body.items != null || body.all != null) {
|
|
@@ -5623,7 +5776,11 @@ async function handleRequest(req, res) {
|
|
|
5623
5776
|
}
|
|
5624
5777
|
// `version` lets the launcher notice a server still running OLD code after an upgrade: files on disk
|
|
5625
5778
|
// advance under a live process, so the UI would otherwise call routes that process never learned.
|
|
5626
|
-
|
|
5779
|
+
// `ledgers` (pm-os-absorb Run 1) — the plan-items signature of every registered workspace, so an open Breakdown
|
|
5780
|
+
// knows to refetch: `items` is one stat per workspace; `evidence` is computed only for the ids in ?ledgers=
|
|
5781
|
+
// (at most 4). Computed on this request and never stored (D33).
|
|
5782
|
+
if (p === '/api/sessions') return json(res, { sessions: sessionList(), version: APP_VERSION,
|
|
5783
|
+
ledgers: planItems.ledgerSigs([...workspaces.values()].map((w) => ({ id: w.id, root: w.root })), url.searchParams.get('ledgers')) });
|
|
5627
5784
|
if (p === '/api/git/branches') {
|
|
5628
5785
|
// Read-only branch awareness for a workspace. A non-repo returns {ok:false} (never a fake
|
|
5629
5786
|
// branch). Fixed git args, no caller input in the read path (R1).
|
|
@@ -5788,13 +5945,16 @@ async function handleRequest(req, res) {
|
|
|
5788
5945
|
// per-tree counts a COLLAPSED sidebar row shows, which is the whole reason a row can be collapsed.
|
|
5789
5946
|
if (p === '/api/needs') {
|
|
5790
5947
|
const w = getWs(url.searchParams.get('ws'));
|
|
5948
|
+
// The signature is taken BEFORE the read, as needsCountCached takes it: a write between the two then leaves an
|
|
5949
|
+
// older signature on the cached count (read again on the next call), never a newer one on an older count.
|
|
5950
|
+
let sig = null;
|
|
5951
|
+
if (w) { try { sig = needsSig(w); } catch { sig = 'unreadable'; } }
|
|
5791
5952
|
const strict = needsResult(w);
|
|
5792
5953
|
// One derivation per request, not three: the strict read already holds the combined queue
|
|
5793
5954
|
// (`all` — identical to readNeeds when the read succeeds; on a corrupt file readNeeds keeps its
|
|
5794
5955
|
// lenient derived-only fallback), and the by_ws cache is seeded from the same read so the
|
|
5795
5956
|
// selected tree is not derived again — fresher than any cache hit, so R10 still holds.
|
|
5796
5957
|
if (w) {
|
|
5797
|
-
let sig; try { sig = needsSig(w); } catch { sig = 'unreadable'; }
|
|
5798
5958
|
needsAgg.set(w.id, { at: Date.now(), sig, value: strict.ok
|
|
5799
5959
|
? { count: strict.empty ? 0 : strict.data.items.length, error: null }
|
|
5800
5960
|
: { count: null, error: strict.error } });
|
|
@@ -5904,7 +6064,7 @@ async function handleRequest(req, res) {
|
|
|
5904
6064
|
nextSteps = deriveNextSteps({
|
|
5905
6065
|
id, accepted: acc.action === 'accept', returned: acc.action === 'return',
|
|
5906
6066
|
lifecycle: lc && lc.state !== 'active' ? lc : null, ladder,
|
|
5907
|
-
needs: (readNeeds(w) || []).filter((n) => n && n.initiative_id === id),
|
|
6067
|
+
needs: (readNeeds(w) || []).filter((n) => n && n.initiative_id === id && notPlanNeed(n)),
|
|
5908
6068
|
sessions: sessRows.filter((x) => (x.initiative === id) || conversations.some((c) => c.id === x.id && c.live)),
|
|
5909
6069
|
});
|
|
5910
6070
|
} catch (e) { nextSteps = { state: 'unreadable', summary: 'The next steps could not be derived — ' + String((e && e.message) || 'error').slice(0, 120), items: [] }; }
|
|
@@ -5984,6 +6144,9 @@ async function handleRequest(req, res) {
|
|
|
5984
6144
|
let body = {};
|
|
5985
6145
|
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
5986
6146
|
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6147
|
+
// R25 / R23: binding decides which initiatives an agent session works on, so an agent cannot bind itself.
|
|
6148
|
+
const g = pmGuard.requirePmPress(req, body, { act: 'bind' });
|
|
6149
|
+
if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
|
|
5987
6150
|
const s = sessions.get(String(body.id || ''));
|
|
5988
6151
|
if (!s) return json(res, { ok: false, error: 'no such session' }, 404);
|
|
5989
6152
|
const clearing = body.initiative === null || body.initiative === '';
|
|
@@ -6141,6 +6304,10 @@ async function handleRequest(req, res) {
|
|
|
6141
6304
|
let body = {};
|
|
6142
6305
|
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
6143
6306
|
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6307
|
+
// R25: every action this route records is the PM's — acceptance, review approve and return, document approval,
|
|
6308
|
+
// contract approval and a D60 disposition. One guard before the action is read, so no action escapes it.
|
|
6309
|
+
const g = pmGuard.requirePmPress(req, body, { act: 'accept' });
|
|
6310
|
+
if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
|
|
6144
6311
|
if (!body.action || !body.run_id) return json(res, { ok: false, error: 'action and run_id are required' }, 400);
|
|
6145
6312
|
const w = getWs(body.ws);
|
|
6146
6313
|
const rec = { at: nowISO(), date: nowISO().slice(0, 10),
|
|
@@ -6158,7 +6325,7 @@ async function handleRequest(req, res) {
|
|
|
6158
6325
|
// it, validated against the same closed set, and simply absent when they do not.
|
|
6159
6326
|
accepted: body.action === 'accept' ? true : body.action === 'return' ? false : undefined,
|
|
6160
6327
|
...(body.pm_label === 'pass' || body.pm_label === 'fail' ? { pm_label: body.pm_label } : {}),
|
|
6161
|
-
note: body.note || '', workspace: w ? w.name : null, root: w ? w.root : null, pm: true };
|
|
6328
|
+
note: body.note || '', workspace: w ? w.name : null, root: w ? w.root : null, pm: true, by: g.actor.by, via: g.actor.via };
|
|
6162
6329
|
// A malformed label is refused rather than dropped. Silently discarding it would put the row in
|
|
6163
6330
|
// the unlabeled pile while the PM believed they had labelled it — the same shape as inferring.
|
|
6164
6331
|
if (body.pm_label !== undefined && body.pm_label !== 'pass' && body.pm_label !== 'fail') {
|
|
@@ -6321,6 +6488,69 @@ async function handleRequest(req, res) {
|
|
|
6321
6488
|
const wrote = nsAppend(NS_RUNS(), rec, 'next-step run');
|
|
6322
6489
|
return json(res, { ok: true, session: { id: started.session.id, name: started.session.name }, notice: started.notice || null, plan: r.plan, run: wrote.ok ? rec.id : null, recorded: wrote.ok });
|
|
6323
6490
|
}
|
|
6491
|
+
/* ── pm-os-absorb Run 1 — an initiative's breakdown (plan-items.jsonl) ──────────────────────────────────────────
|
|
6492
|
+
* GET is the Breakdown layout: derived stages with their owed rows, the items, the Board columns, the Keep batches.
|
|
6493
|
+
* POST writes through the ONE writer as the PM (`pm:wawan`, `via: ui`). Every POST needs an Origin header: the
|
|
6494
|
+
* global guard already refuses a foreign one, and a request with none did not come from the page. Header values
|
|
6495
|
+
* are never logged (D08). `ladder=0` skips the ladder (about 800 ms here) when only the items changed. */
|
|
6496
|
+
if (p === '/api/plan-items' && req.method === 'GET') {
|
|
6497
|
+
const wsParam = url.searchParams.get('ws');
|
|
6498
|
+
const w = wsParam ? workspaces.get(wsParam) : homeWorkspace();
|
|
6499
|
+
if (!w) return json(res, { ok: false, error: 'no such workspace' }, 404);
|
|
6500
|
+
const id = url.searchParams.get('id') || '';
|
|
6501
|
+
if (!planItems.INIT_RE.test(id)) return json(res, { ok: false, error: 'an initiative id is required, and it must be a record name' }, 400);
|
|
6502
|
+
try {
|
|
6503
|
+
if (!planTree.recordPath(w.root, id)) return json(res, { ok: false, error: `no initiative ${id} in this repository` }, 404);
|
|
6504
|
+
const now = Date.now();
|
|
6505
|
+
// The signature is taken before the ledger and the ladder are read (lib/plan-items.js readLayout says why).
|
|
6506
|
+
const { sig, layout: lay } = planItems.readLayout({ id: w.id, root: w.root }, id, { ladder: url.searchParams.get('ladder') !== '0', now });
|
|
6507
|
+
const out = { ok: true, ws: w.id, initiative: id, profile: lay.profile, lane: lay.lane, ladderIncluded: lay.ladderIncluded };
|
|
6508
|
+
if (lay.ladderIncluded) out.ladder = lay.ladder;
|
|
6509
|
+
Object.assign(out, { sig, now: new Date(now).toISOString(), progress: lay.progress, stages: lay.stages, other: lay.other,
|
|
6510
|
+
items: lay.items, board: lay.board, batches: lay.batches, flags: lay.flags, withdrawnCount: lay.withdrawnCount });
|
|
6511
|
+
return json(res, out);
|
|
6512
|
+
} catch (e) {
|
|
6513
|
+
return json(res, { ok: false, error: 'the breakdown could not be read: ' + String((e && e.message) || 'error').slice(0, 160) }, 500);
|
|
6514
|
+
}
|
|
6515
|
+
}
|
|
6516
|
+
if ((p === '/api/plan-items' || p === '/api/plan-items/handoff/prepare' || p === '/api/plan-items/handoff/go') && req.method === 'POST') {
|
|
6517
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6518
|
+
// R25: pm-guard's body, writer-field, Origin and nonce checks. A call that carries a Keep is the PM-only act `keep`.
|
|
6519
|
+
const keeps = p === '/api/plan-items' && body && Array.isArray(body.events) && body.events.some((e) => e && e.ev === 'kept');
|
|
6520
|
+
const g = keeps ? pmGuard.requirePmPress(req, body, { act: 'keep' }) : pmGuard.requirePagePress(req, body);
|
|
6521
|
+
if (!g.ok) return json(res, { ok: false, error: g.error, ...(p === '/api/plan-items' ? { index: null } : {}) }, g.status);
|
|
6522
|
+
if (p === '/api/plan-items') {
|
|
6523
|
+
const w = body.ws ? workspaces.get(String(body.ws)) : null;
|
|
6524
|
+
if (!w) return json(res, { ok: false, error: 'no such workspace', index: null }, 404);
|
|
6525
|
+
let r;
|
|
6526
|
+
try { r = planItems.appendFromUi(w.root, body.events, { actor: g.actor }); }
|
|
6527
|
+
catch (e) { warnWriteFail('plan-items.jsonl', e); return json(res, { ok: false, error: 'could not record — nothing was saved', index: null }, 500); }
|
|
6528
|
+
if (!r.ok) return json(res, { ok: false, error: r.error, index: r.index }, r.status || 400);
|
|
6529
|
+
invalidateNeedsAgg(); // the ledger is per repository: every tree of it may show a new Keep card
|
|
6530
|
+
const sig = planItems.ledgerSigs([{ id: w.id, root: w.root }], null)[w.id];
|
|
6531
|
+
return json(res, { ok: true, written: r.written, sig });
|
|
6532
|
+
}
|
|
6533
|
+
if (p === '/api/plan-items/handoff/prepare') {
|
|
6534
|
+
const r = prepareHandoff(body);
|
|
6535
|
+
if (!r.ok) { const { status, ...rest } = r; return json(res, rest, status || 400); }
|
|
6536
|
+
const { ws: _ws, ...shown } = r;
|
|
6537
|
+
return json(res, shown);
|
|
6538
|
+
}
|
|
6539
|
+
// go: only the PM's press reaches it. It prepares again and starts one session only when the PM confirmed AND the
|
|
6540
|
+
// prompt and destination are the ones they were shown (the sha). It writes no ledger line.
|
|
6541
|
+
if (body.confirm !== true) return json(res, { ok: false, error: 'confirm is required: this starts a session' }, 400);
|
|
6542
|
+
const r = prepareHandoff(body);
|
|
6543
|
+
if (!r.ok) { const { status, ...rest } = r; return json(res, rest, status || 400); }
|
|
6544
|
+
if (!body.prompt_sha || body.prompt_sha !== r.prompt_sha) return json(res, { ok: false, error: 'the item changed since you reviewed it — prepare it again', prompt_sha: r.prompt_sha }, 409);
|
|
6545
|
+
const budget = handoffBudget();
|
|
6546
|
+
if (!budget.allowed) return json(res, { ok: false, error: budget.reason, budget }, 409);
|
|
6547
|
+
const started = startWork({ initiative: r.initiative, ws: r.ws, agent: r.agent, name: r.details.session_name,
|
|
6548
|
+
origin: 'plan-item', initialInput: r.prompt + '\r', planItem: r.item });
|
|
6549
|
+
if (!started.ok) return json(res, { ok: false, error: started.error }, 409);
|
|
6550
|
+
// The session's token is never projected; the page gets where it runs and the item it is bound to.
|
|
6551
|
+
return json(res, { ok: true, session: { id: started.session.id, name: started.session.name, ws: started.session.ws, plan_item: started.session.plan_item || null },
|
|
6552
|
+
notice: started.notice || null, plan: started.plan });
|
|
6553
|
+
}
|
|
6324
6554
|
if (p === '/api/next-step/summary' && req.method === 'GET') {
|
|
6325
6555
|
// What an ended (or running) next-step session did: the files git shows on its branch against trunk, the end of the
|
|
6326
6556
|
// conversation, and every JUDGMENT line checked against its shape. Read-only.
|
|
@@ -6352,6 +6582,8 @@ async function handleRequest(req, res) {
|
|
|
6352
6582
|
// The PM's verdict on one step's run. It accepts or sends back THE STEP. It never records an acceptance of the
|
|
6353
6583
|
// initiative, never changes a status and never touches a lifecycle line.
|
|
6354
6584
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6585
|
+
const g = pmGuard.requirePmPress(req, body, { act: 'accept' }); // R25: accepting or returning a step is the PM's
|
|
6586
|
+
if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
|
|
6355
6587
|
const verdict = body.verdict === 'accept' ? 'accept' : body.verdict === 'send-back' ? 'send-back' : null;
|
|
6356
6588
|
if (!verdict) return json(res, { ok: false, error: 'verdict must be accept or send-back' }, 400);
|
|
6357
6589
|
const note = String(body.note || '').trim().slice(0, 500);
|
|
@@ -6361,7 +6593,7 @@ async function handleRequest(req, res) {
|
|
|
6361
6593
|
if (!run) return json(res, { ok: false, error: 'that session was not started from a next step' }, 404);
|
|
6362
6594
|
const live = sessions.get(sid);
|
|
6363
6595
|
if (live && live.state !== 'exited') return json(res, { ok: false, error: 'the session is still running — review it when it has ended' }, 409);
|
|
6364
|
-
const rec = { at: nowISO(), initiative_id: run.initiative_id, item_id: run.item_id, key: run.key, session_id: sid, verdict, note, pm: 'wawan' };
|
|
6596
|
+
const rec = { at: nowISO(), initiative_id: run.initiative_id, item_id: run.item_id, key: run.key, session_id: sid, verdict, note, pm: 'wawan', by: g.actor.by, via: g.actor.via };
|
|
6365
6597
|
const wrote = nsAppend(NS_REVIEWS(), rec, 'step review');
|
|
6366
6598
|
if (!wrote.ok) return json(res, { ok: false, error: wrote.error }, 500);
|
|
6367
6599
|
return json(res, { ok: true, recorded: rec });
|
|
@@ -6828,17 +7060,37 @@ async function handleRequest(req, res) {
|
|
|
6828
7060
|
let body = {};
|
|
6829
7061
|
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
6830
7062
|
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6831
|
-
const
|
|
6832
|
-
if (
|
|
7063
|
+
const g = pmGuard.requirePmPress(req, body, { act: 'lifecycle' }); // R25: a lifecycle change is the PM's
|
|
7064
|
+
if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
|
|
7065
|
+
const ID_OK = (v) => typeof v === 'string' && /^[a-z0-9][a-z0-9._-]*$/i.test(v);
|
|
7066
|
+
// The bulk body `{ ws, ids, state }` (D86 point 2, X6): at most 20 ids and one state, under ONE lifecycle-bulk grant.
|
|
7067
|
+
const bulk = body.ids !== undefined;
|
|
7068
|
+
if (bulk && body.initiative_id !== undefined) return json(res, { ok: false, error: 'send initiative_id or ids, not both' }, 400);
|
|
7069
|
+
const ids = bulk ? body.ids : [String(body.initiative_id || '')];
|
|
7070
|
+
if (bulk && (!Array.isArray(ids) || !ids.length || ids.length > 20 || !ids.every(ID_OK) || new Set(ids).size !== ids.length)) {
|
|
7071
|
+
return json(res, { ok: false, error: 'ids must name 1 to 20 different initiatives' }, 400);
|
|
7072
|
+
}
|
|
7073
|
+
const id = ids[0];
|
|
7074
|
+
if (!ID_OK(id)) return json(res, { ok: false, error: 'initiative_id is required' }, 400);
|
|
6833
7075
|
if (body.state !== 'active' && !STOPPED_STATES.includes(body.state)) {
|
|
6834
7076
|
return json(res, { ok: false, error: `state must be one of ${STOPPED_STATES.concat('active').join(', ')}` }, 400);
|
|
6835
7077
|
}
|
|
6836
7078
|
const w = getWs(body.ws);
|
|
6837
7079
|
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
6838
|
-
|
|
6839
|
-
|
|
7080
|
+
const known = new Set(initiativeIndex(w.root).initiatives.map((x) => x.id));
|
|
7081
|
+
const missing = ids.find((x) => !known.has(x));
|
|
7082
|
+
if (missing) return json(res, { ok: false, error: `no initiative '${missing}' in ${w.name}` }, 404);
|
|
7083
|
+
const reason = String(body.reason || '').slice(0, 500);
|
|
7084
|
+
let grant = null;
|
|
7085
|
+
if (bulk) {
|
|
7086
|
+
const m = grantMint.mintFromPress(w.root, { scope: 'lifecycle-bulk', ids, state: body.state,
|
|
7087
|
+
instruction: `set ${ids.length} initiatives to ${body.state}`, initiative: null }, { actor: g.actor });
|
|
7088
|
+
if (!m.ok) return json(res, { ok: false, error: m.error }, m.status || 500); // the writer refused or is missing: nothing is written
|
|
7089
|
+
grant = m.grant_id;
|
|
6840
7090
|
}
|
|
6841
|
-
const
|
|
7091
|
+
const at = nowISO();
|
|
7092
|
+
const recs = ids.map((x) => ({ initiative_id: x, state: body.state, reason, pm: 'wawan', by: g.actor.by, via: g.actor.via, at, ...(grant ? { grant } : {}) }));
|
|
7093
|
+
const rec = recs[0];
|
|
6842
7094
|
// The ledger the ENGINE reads for this repository's layout (scripts/workflow-state.js#layout): a repository
|
|
6843
7095
|
// with planning/initiatives/ keeps its ledgers in planning/runs/, a kit in pmos/state/. Writing anywhere else
|
|
6844
7096
|
// (R6 wrote the workspace's runtime state for PMOS-self) shows on the board but never clears the gate.
|
|
@@ -6849,13 +7101,18 @@ async function handleRequest(req, res) {
|
|
|
6849
7101
|
if (!d) return json(res, { ok: false, error: 'this repository has no state directory to record in — nothing was written' }, 500);
|
|
6850
7102
|
try { fs.mkdirSync(d, { recursive: true }); } catch { /* the guard reports it */ }
|
|
6851
7103
|
const sink = path.join(d, 'lifecycle.jsonl');
|
|
6852
|
-
|
|
7104
|
+
let wrote = 0;
|
|
7105
|
+
try { const sg = createSinkGuard({ sink, what: 'lifecycle' }); for (const r of recs) { sg.appendLine(sink, r); wrote++; } }
|
|
6853
7106
|
catch (e) {
|
|
6854
7107
|
warnWriteFail('lifecycle.jsonl', e);
|
|
6855
|
-
|
|
7108
|
+
// A partial bulk write names the grant that was used and the ids still to set, so the PM can press again for them.
|
|
7109
|
+
const rest = ids.slice(wrote);
|
|
7110
|
+
return json(res, { ok: false, error: wrote
|
|
7111
|
+
? `could not record the change — ${wrote} of ${recs.length} lines were saved${grant ? ` under grant ${grant}` : ''}; not saved: ${rest.join(', ')}. Set those again.`
|
|
7112
|
+
: 'could not record the change — nothing was saved', sink, ...(wrote ? { grant, unsaved: rest } : {}) }, 500);
|
|
6856
7113
|
}
|
|
6857
7114
|
boardCache.invalidate(w.root);
|
|
6858
|
-
return json(res, { ok: true, recorded: rec, sink });
|
|
7115
|
+
return json(res, bulk ? { ok: true, recorded: recs, grant, sink } : { ok: true, recorded: rec, sink });
|
|
6859
7116
|
}
|
|
6860
7117
|
if (p === '/api/doc/save' && req.method === 'POST') {
|
|
6861
7118
|
// The editor's save (workspace-doc-editor R3/R4). The ONLY write path on this surface, and it is
|
|
@@ -6865,6 +7122,9 @@ async function handleRequest(req, res) {
|
|
|
6865
7122
|
let body = {};
|
|
6866
7123
|
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
6867
7124
|
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
7125
|
+
// R25: a save can rewrite a record's parent_kr, so every save passes the anchor guard. `pm` is a writer field now.
|
|
7126
|
+
const g = pmGuard.requirePmPress(req, body, { act: 'anchor' });
|
|
7127
|
+
if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
|
|
6868
7128
|
|
|
6869
7129
|
const w = getWs(body.ws);
|
|
6870
7130
|
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
@@ -6902,7 +7162,7 @@ async function handleRequest(req, res) {
|
|
|
6902
7162
|
// needs.json and acceptances.jsonl use — a tenant product repo has no workspace/ folder, and
|
|
6903
7163
|
// assuming one is exactly the defect shipped in 0.7.1/0.7.2.
|
|
6904
7164
|
const region = applyChange.changedRegion(body.base, body.text);
|
|
6905
|
-
const rec = { file: String(body.path), at: nowISO(), pm:
|
|
7165
|
+
const rec = { file: String(body.path), at: nowISO(), pm: g.actor.by.slice('pm:'.length), via: g.actor.via,
|
|
6906
7166
|
note: typeof body.note === 'string' && body.note.trim() ? body.note.trim().slice(0, 400) : null,
|
|
6907
7167
|
at_line: region.at_line, added: region.added, removed: region.removed,
|
|
6908
7168
|
before: region.before, after: region.after };
|
|
@@ -7067,6 +7327,8 @@ async function handleRequest(req, res) {
|
|
|
7067
7327
|
let body = {};
|
|
7068
7328
|
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
7069
7329
|
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
7330
|
+
const g = pmGuard.requirePmPress(req, body, { act: 'anchor' }); // R25: an aligning edit can rewrite parent_kr too
|
|
7331
|
+
if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
|
|
7070
7332
|
const w = getWs(body.ws);
|
|
7071
7333
|
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
7072
7334
|
const rel = String(body.file || '');
|
|
@@ -7087,7 +7349,7 @@ async function handleRequest(req, res) {
|
|
|
7087
7349
|
const region = applyChange.changedRegion(body.before, body.after);
|
|
7088
7350
|
const dir = stateDirFor(w, { create: true });
|
|
7089
7351
|
if (dir) {
|
|
7090
|
-
const rec = { file: rel, at: nowISO(), pm:
|
|
7352
|
+
const rec = { file: rel, at: nowISO(), pm: g.actor.by.slice('pm:'.length), via: g.actor.via,
|
|
7091
7353
|
note: 'aligned with ' + (body.source || 'an earlier change') + (body.why ? ' — ' + String(body.why).slice(0, 200) : ''),
|
|
7092
7354
|
at_line: region.at_line, added: region.added, removed: region.removed,
|
|
7093
7355
|
before: region.before, after: region.after, aligned_from: body.source || null };
|
|
@@ -7114,6 +7376,8 @@ async function handleRequest(req, res) {
|
|
|
7114
7376
|
if (only) rows = rows.filter((r) => r.file === only);
|
|
7115
7377
|
return json(res, { ok: true, changes: rows.slice(-200).reverse(), total: rows.length });
|
|
7116
7378
|
}
|
|
7379
|
+
// pm-os-absorb Runs 2–5 — the planning layer's routes, one file per run (lib/route-modules.js). Last, so none shadows a route above.
|
|
7380
|
+
if (await routeModules.dispatch(req, res, url, { json, readBody, getWs, invalidateNeedsAgg, boardCache, consoleState, stateDirFor, sessionList })) return;
|
|
7117
7381
|
res.writeHead(404); res.end('not found');
|
|
7118
7382
|
}
|
|
7119
7383
|
|
|
@@ -7239,7 +7503,16 @@ function handleUpgrade(req, socket, head) {
|
|
|
7239
7503
|
socket.destroy(); // reject the upgrade; nothing about the request is logged (D08)
|
|
7240
7504
|
return;
|
|
7241
7505
|
}
|
|
7242
|
-
|
|
7506
|
+
// R25 (bind): a session started with an initiative is bound to it, a PM-only act. Only a socket the page opened with
|
|
7507
|
+
// this launch's nonce (`?pmos-nonce=`) may bind; any other socket still spawns, unbound, and is told why.
|
|
7508
|
+
const bind = pmGuard.requirePmUpgrade(req, { act: 'bind' });
|
|
7509
|
+
wss.handleUpgrade(req, socket, head, (ws) => { ws._bind = bind; wss.emit('connection', ws, req); });
|
|
7510
|
+
}
|
|
7511
|
+
/** The initiative a WS spawn may bind, or null with a notice sent when this socket may not bind (R25). */
|
|
7512
|
+
function wsBindable(ws, initiative) {
|
|
7513
|
+
if (initiative == null || initiative === '' || (ws._bind && ws._bind.ok)) return initiative;
|
|
7514
|
+
ws.send(JSON.stringify({ t: 'notice', notice: `the session starts without an initiative: ${(ws._bind && ws._bind.error) || 'only the PM can bind a session to an initiative, from the page'}` }));
|
|
7515
|
+
return null;
|
|
7243
7516
|
}
|
|
7244
7517
|
server.on('upgrade', handleUpgrade);
|
|
7245
7518
|
wss.on('connection', (ws) => {
|
|
@@ -7278,7 +7551,7 @@ wss.on('connection', (ws) => {
|
|
|
7278
7551
|
// `initiative` is what the client has always held and never sent: `+ session` is clicked on
|
|
7279
7552
|
// an initiative row, the tab carries its `slug`, and the spawn message named three other
|
|
7280
7553
|
// fields. An absent or unusable value leaves the session unattributed — never a refused spawn.
|
|
7281
|
-
const r = spawnWithBranch({ name: m.name, agent: m.agent, cwd: m.cwd, ws: m.ws, branch: m.branch, createBranch: m.createBranch, isolate: m.isolate, profile: m.profile, initiative: m.initiative,
|
|
7554
|
+
const r = spawnWithBranch({ name: m.name, agent: m.agent, cwd: m.cwd, ws: m.ws, branch: m.branch, createBranch: m.createBranch, isolate: m.isolate, profile: m.profile, initiative: wsBindable(ws, m.initiative),
|
|
7282
7555
|
// D75 — what the composer chose (a tier, or a model), carried like the profile. Absent = not asked.
|
|
7283
7556
|
modelAsk: (m.tier || m.model) ? { explicit: { tier: m.tier, model: m.model, effort: m.effort } } : null });
|
|
7284
7557
|
// A refused profile is its own sentence, not "branch: …" — the composer's error line names
|
|
@@ -7292,7 +7565,7 @@ wss.on('connection', (ws) => {
|
|
|
7292
7565
|
// repo-first R1 — the one-click path. It carries an INITIATIVE (or none) and a repository,
|
|
7293
7566
|
// never a branch and never an isolate flag: those are the policy's to decide, and a client
|
|
7294
7567
|
// that could still send them would be a second place the rule lives.
|
|
7295
|
-
const r = startWork({ initiative: m.initiative, repoKey: m.repoKey, ws: m.ws,
|
|
7568
|
+
const r = startWork({ initiative: wsBindable(ws, m.initiative), repoKey: m.repoKey, ws: m.ws,
|
|
7296
7569
|
agent: m.agent, name: m.name, profile: m.profile });
|
|
7297
7570
|
if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^profile /.test(r.error || '') ? '' : 'start: ') + r.error })); return; }
|
|
7298
7571
|
if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
|
|
@@ -7303,7 +7576,7 @@ wss.on('connection', (ws) => {
|
|
|
7303
7576
|
// server already holds, not re-sent by the client — a client that forgot it would otherwise
|
|
7304
7577
|
// silently restart the pane on a different environment, and the pane would look identical.
|
|
7305
7578
|
const carried = 'profile' in old ? old.profile : null;
|
|
7306
|
-
const s = spawnSession({ name: old.name, agent: old.agent, cwd: path.relative(old.root, old.cwd), ws: old.ws, profile: carried, modelAsk: old.modelAsk || null });
|
|
7579
|
+
const s = spawnSession({ name: old.name, agent: old.agent, cwd: path.relative(old.root, old.cwd), ws: old.ws, profile: carried, modelAsk: old.modelAsk || null, planItem: old.plan_item || null });
|
|
7307
7580
|
// A descriptor deleted since the original spawn does NOT get substituted. The respawn goes
|
|
7308
7581
|
// ahead with no profile — the pane line already says so — and the reason is said out loud
|
|
7309
7582
|
// rather than left for the PM to notice.
|
|
@@ -7343,6 +7616,9 @@ if ((HOME_ENABLED || workspaces.size) && [...sessions.values()].filter((s) => s.
|
|
|
7343
7616
|
}
|
|
7344
7617
|
|
|
7345
7618
|
if (require.main === module) {
|
|
7619
|
+
// pm-os-absorb Run 2 (R25, X11): one nonce per launch, held in memory only. Never in process.env (so no PTY inherits
|
|
7620
|
+
// it), never logged (D08). GET / templates it into the page; lib/pm-guard.js refuses a press without it.
|
|
7621
|
+
pmGuard.setLaunchNonce(crypto.randomBytes(24).toString('base64url'));
|
|
7346
7622
|
// Refuse-to-start is the D62 spine: if the requested remote bind isn't loopback or a tailnet
|
|
7347
7623
|
// address, resolveBinds returned {refused} and we exit rather than expose a PTY server publicly.
|
|
7348
7624
|
if (BIND.refused) { console.error('✗ ' + BIND.refused); process.exit(1); }
|