create-pmos 0.52.0 → 0.53.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/README.md +2 -0
- package/workspace/REMOTE.md +24 -63
- package/workspace/bin/pmos.js +19 -3
- 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 +28 -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/launcher.md +51 -0
- package/workspace/guide/layout.md +102 -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/proactive-engine.md +21 -5
- 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 +75 -0
- package/workspace/lib/brief.js +20 -2
- 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/goals.js +165 -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 +210 -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/launcher-cli.js +207 -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 +380 -0
- package/workspace/lib/pm-guard.js +174 -0
- package/workspace/lib/pm-lines-needs.js +331 -0
- package/workspace/lib/pmos-verbs.js +69 -0
- package/workspace/lib/proactive-cli.js +58 -2
- package/workspace/lib/proactive.js +9 -6
- package/workspace/lib/qr.js +293 -0
- package/workspace/lib/remote-access.js +93 -1
- package/workspace/lib/report-cli.js +152 -0
- package/workspace/lib/report-draft.js +578 -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/icons/apple-touch-icon.png +0 -0
- package/workspace/public/icons/icon-192.png +0 -0
- package/workspace/public/icons/icon-512.png +0 -0
- package/workspace/public/index.html +40 -19
- package/workspace/public/pm/app.js +330 -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 +3135 -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 +182 -0
- package/workspace/public/pm/dialogs.js +88 -0
- package/workspace/public/pm/display-word.js +56 -0
- package/workspace/public/pm/dock.js +175 -0
- package/workspace/public/pm/fixture.js +8 -227
- package/workspace/public/pm/frame-copy.js +99 -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 +200 -111
- 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 +126 -0
- package/workspace/public/pm/today-goals.js +172 -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 +2877 -0
- package/workspace/run-tests.js +56 -5
- package/workspace/server.js +446 -40
- package/workspace/sheet-answer.js +61 -0
- package/workspace/skills/measure.skill +41 -0
- package/workspace/skills/morning-brief.skill +15 -9
- package/workspace/skills/report-biweekly.skill +23 -25
- package/workspace/skills/tracker-refresh.skill +50 -0
- package/workspace/test-files.json +21 -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-lists/workspace-launcher.json +5 -0
- package/workspace/test-lists/workspace-phone-app.json +5 -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
|
@@ -94,6 +94,8 @@ const judgmentLib = require('./lib/judgment');
|
|
|
94
94
|
const proactiveLib = require('./lib/proactive');
|
|
95
95
|
const learnLib = require('./lib/learn');
|
|
96
96
|
const briefLib = require('./lib/brief');
|
|
97
|
+
const goalsLib = require('./lib/goals');
|
|
98
|
+
const todayLib = require('./lib/today');
|
|
97
99
|
const prepareQueue = require('./lib/prepare-queue');
|
|
98
100
|
const harness = require('./lib/harness-parse');
|
|
99
101
|
const transcript = require('./lib/transcript-read');
|
|
@@ -124,6 +126,13 @@ const profilesLib = require('./lib/profiles'); // readProfiles(homeDir)
|
|
|
124
126
|
const focus = require('./lib/focus'); // buildFocus(input) → FocusView (cross-product)
|
|
125
127
|
const handoff = require('./lib/handoff'); // readLinks(parsedFrontmatterObject) — never a path
|
|
126
128
|
const fourPanel = require('./lib/four-panel'); // GET /api/initiative — the one-initiative join + the declaration stores
|
|
129
|
+
const planItems = require('./lib/plan-items.js'); // pm-os-absorb Run 1 — the ONE writer and fold of plan-items.jsonl (control graph)
|
|
130
|
+
const planTree = require('./lib/plan-tree.js'); // …and the Breakdown layout: derived stages, owed rows, the Board columns
|
|
131
|
+
const routeModules = require('./lib/route-modules.js'); // pm-os-absorb Runs 2–5 — each run's routes in its own lib/routes-<name>.js
|
|
132
|
+
const needsProviders = require('./lib/needs-providers.js'); // …and each run's needs-you rows, folded into deriveNeeds
|
|
133
|
+
const pmGuard = require('./lib/pm-guard.js'); // pm-os-absorb Run 2 (R25) — Origin, nonce and writer fields on every PM press
|
|
134
|
+
const actorLib = require('./lib/actor.js'); // …the session-token hash persisted with each agent session
|
|
135
|
+
const grantMint = require('./lib/grant-mint.js'); // …one D81 grant for a bulk lifecycle press (501 until 2B fills it)
|
|
127
136
|
// The "home" repo this server manages. In PMOS-self the app ships INSIDE its repo
|
|
128
137
|
// (pmos/workspace) so the parent dir IS the repo. But a kit/tenant copy is installed standalone
|
|
129
138
|
// — `npm i -g` puts it under node_modules/pmos-workspace, or it runs from an npx bundle — and
|
|
@@ -279,9 +288,12 @@ const SHELL = defaultShell();
|
|
|
279
288
|
// "child session" marker and disables its own transcript saving. Strip those markers here: the
|
|
280
289
|
// harness inside the PTY must run as though the PM launched it directly. Login/subscription do
|
|
281
290
|
// NOT live in these vars (they're in the CLI's own config/keychain), so this never affects auth.
|
|
291
|
+
// PMOS_SESSION and PMOS_BIN are the same kind of marker (pm-os-absorb Run 1): a server started from inside an agent
|
|
292
|
+
// session holds that session's token, and a shell that inherited it would record the PM's `pmos item` as the agent's.
|
|
293
|
+
// spawnSession gives them to an agent session only, as its own values.
|
|
282
294
|
const INHERITED_SESSION_MARKERS = [
|
|
283
295
|
'CLAUDECODE', 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_ENTRYPOINT',
|
|
284
|
-
'CLAUDE_CODE_EXECPATH', 'CLAUDE_PID', 'AI_AGENT', 'CLAUDE_EFFORT',
|
|
296
|
+
'CLAUDE_CODE_EXECPATH', 'CLAUDE_PID', 'AI_AGENT', 'CLAUDE_EFFORT', 'PMOS_SESSION', 'PMOS_BIN',
|
|
285
297
|
];
|
|
286
298
|
function sessionEnv() {
|
|
287
299
|
const env = { ...process.env };
|
|
@@ -1847,7 +1859,7 @@ function surfaceInitiatives(root, w) {
|
|
|
1847
1859
|
const acc = readAcceptances(w);
|
|
1848
1860
|
const life = readLifecycle(w);
|
|
1849
1861
|
const runs = readRunCounts(w);
|
|
1850
|
-
const needsRaw = readNeeds(w) || [];
|
|
1862
|
+
const needsRaw = (readNeeds(w) || []).filter(notPlanNeed);
|
|
1851
1863
|
const needsBy = new Map();
|
|
1852
1864
|
for (const n of needsRaw) if (n && n.initiative_id) needsBy.set(n.initiative_id, (needsBy.get(n.initiative_id) || 0) + 1);
|
|
1853
1865
|
|
|
@@ -2189,6 +2201,11 @@ const SEED_SETS = {
|
|
|
2189
2201
|
{ name: 'Bi-weekly report draft', skill: 'report-biweekly', cadence: 'bi-weekly', day: 'Fri', time: '16:00' },
|
|
2190
2202
|
{ name: 'Monthly report draft', skill: 'report-monthly', cadence: 'monthly', dayOfMonth: 1, time: '08:00' },
|
|
2191
2203
|
],
|
|
2204
|
+
// 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
|
|
2205
|
+
// fifth line of `reports`, so pressing `reports` still makes the four report schedules. It is weekly, Friday 15:30: the
|
|
2206
|
+
// bi-weekly draft (Friday 16:00) fires every other week from whichever Friday it was set up, and a weekly refresh is
|
|
2207
|
+
// never out of step with it. A refresh that finds no change writes no line.
|
|
2208
|
+
tracker: [{ name: 'Tracker refresh', skill: 'tracker-refresh', cadence: 'weekly', day: 'Fri', time: '15:30' }],
|
|
2192
2209
|
};
|
|
2193
2210
|
function readSchedules() {
|
|
2194
2211
|
try { const a = JSON.parse(fs.readFileSync(schedFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; }
|
|
@@ -2336,6 +2353,12 @@ function persist(s) {
|
|
|
2336
2353
|
// so a session survives even if the workspace registry is lost (it re-registers its root).
|
|
2337
2354
|
const meta = { id: s.id, ws: s.ws, root: s.root, name: s.name, agent: s.agent, cwd: s.cwd,
|
|
2338
2355
|
state: s.state, note: s.note || '', created: s.created, tmux: s.tmux || null, origin: s.origin || null };
|
|
2356
|
+
// The breakdown item a Hand to agent session is bound to, so a restart keeps the binding (pm-os-absorb Run 1).
|
|
2357
|
+
if (s.plan_item) meta.plan_item = s.plan_item;
|
|
2358
|
+
// pm-os-absorb Run 2 (R25): the SHA-256 of the agent's PMOS_SESSION token, never the token (D08). A restored session
|
|
2359
|
+
// has no token in memory, so its stored hash is written back; lib/actor.js sessionForToken reads the map after a restart.
|
|
2360
|
+
const th = s.token ? actorLib.tokenHash(s.token) : (typeof s.tokenHash === 'string' ? s.tokenHash : null);
|
|
2361
|
+
if (th) meta.tokenHash = th;
|
|
2339
2362
|
// The profile ID ONLY — never the descriptor and never its env, which is where a credential
|
|
2340
2363
|
// would live (D08). The key is written only for a session PMOS created, so its ABSENCE stays a
|
|
2341
2364
|
// fact: a record persisted before profiles existed restores as "not recorded", and inventing a
|
|
@@ -2604,6 +2627,8 @@ function sessionList() {
|
|
|
2604
2627
|
id: s.id, ws: s.ws, name: s.name, agent: s.agent, state: s.state, created: s.created,
|
|
2605
2628
|
model: 'model' in s ? s.model : null, model_line: modelLib.line('model' in s ? s.model : null),
|
|
2606
2629
|
cwd: path.relative(s.root, s.cwd) || '.', note: s.note || '', origin: s.origin || null,
|
|
2630
|
+
plan_item: s.plan_item || null, // Hand to agent: the breakdown item this session is bound to (pm-os-absorb Run 1)
|
|
2631
|
+
// No `token` and no `tokenHash` here, ever: the page never sees a session token (F5; r25-guards.test.js checks it).
|
|
2607
2632
|
// Derived presentation state, JOINED from both sources: the cadence clock above and the
|
|
2608
2633
|
// transcript's answer to whose turn it is (lib/session-state). working | quiet | exited |
|
|
2609
2634
|
// needs | blocked. `shell`-ness stays on `agent`, because it is orthogonal to state;
|
|
@@ -2836,10 +2861,12 @@ function tmuxKillSession(name) { if (TMUX && name) tmuxCtl(['kill-session', '-t'
|
|
|
2836
2861
|
// not is the honest-state contract inverted, so this is a correctness fix and not a tidy-up.
|
|
2837
2862
|
// `new-session -e K=V` (tmux ≥3.2) sets the environment of THAT session only; a session that
|
|
2838
2863
|
// declares no profile is left genuinely unset rather than inheriting a neighbour's.
|
|
2839
|
-
|
|
2864
|
+
// `extra` carries what PMOS itself gives one session (PMOS_SESSION, its token for `pmos item`); the profile
|
|
2865
|
+
// overlay stays LAST so nothing in `extra` can unset the workspace flag the overlay carries.
|
|
2866
|
+
function createTmuxSession(name, cmd, workdir, cols, rows, extra, overlay) {
|
|
2840
2867
|
if (tmuxHasSession(name)) return false;
|
|
2841
2868
|
const eArgs = [];
|
|
2842
|
-
for (const [k, v] of Object.entries(overlay || {})) eArgs.push('-e', `${k}=${v}`);
|
|
2869
|
+
for (const [k, v] of Object.entries({ ...(extra || {}), ...(overlay || {}) })) eArgs.push('-e', `${k}=${v}`);
|
|
2843
2870
|
const r = tmuxCtl(['-f', TMUX_CONF, 'new-session', '-d', '-s', name,
|
|
2844
2871
|
...eArgs, '-x', String(cols || 80), '-y', String(rows || 24), '-c', workdir, '--', cmd]);
|
|
2845
2872
|
return r.status === 0;
|
|
@@ -3049,8 +3076,16 @@ function resolveProfileForSpawn(id) {
|
|
|
3049
3076
|
: `profile "${id}" is not among the descriptors in ${read.dir} — PMOS will not substitute another` };
|
|
3050
3077
|
}
|
|
3051
3078
|
|
|
3052
|
-
function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, initiative, modelAsk }) {
|
|
3079
|
+
function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, initiative, modelAsk, planItem }) {
|
|
3053
3080
|
const id = nextSessionId();
|
|
3081
|
+
/* pm-os-absorb Run 1 — every AGENT session gets a token, `<id>.<8 hex>`, in its environment as PMOS_SESSION, and
|
|
3082
|
+
* PMOS_BIN, this workspace's own bin/pmos.js. `pmos item` run inside it is then attributed `agent:<token>` (the token
|
|
3083
|
+
* wins over a terminal, lib/plan-items.js actorFromCli). A plain shell session is the PM's own terminal, so it gets
|
|
3084
|
+
* neither (both set empty, never the server's own), and its `pmos item` stays the PM's. The token is an attribution,
|
|
3085
|
+
* not a secret, and Run 2's nonce is the guard. The ledger keeps it whole (it is committed with the repository); the
|
|
3086
|
+
* routes the page reads send `agent:sess-<n>` without it (lib/plan-items.js layoutForPage and needsFor), and the
|
|
3087
|
+
* session list does not carry it. */
|
|
3088
|
+
const sessionToken = `${id}.${crypto.randomBytes(4).toString('hex')}`;
|
|
3054
3089
|
const w = getWs(ws);
|
|
3055
3090
|
const root = w ? w.root : SELF_ROOT;
|
|
3056
3091
|
const isShell = agent === 'shell';
|
|
@@ -3071,8 +3106,17 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
3071
3106
|
// `profileRefusal` is server-internal: callers that can refuse read it and say why.
|
|
3072
3107
|
const pres = resolveProfileForSpawn(profile);
|
|
3073
3108
|
const penv = sessionEnvWithProfile(pres.profile);
|
|
3109
|
+
// sessionEnvWithProfile returns a fresh object (sessionEnv copies process.env), so these touch this session only.
|
|
3110
|
+
// sessionEnv() already strips both from the server's own environment. A shell still gets them SET EMPTY: on tmux they
|
|
3111
|
+
// travel as `-e`, which overrides a daemon an older server seeded with its token, and an empty PMOS_SESSION fails
|
|
3112
|
+
// SESSION_RE, so a `pmos item` typed in the shell is the PM's.
|
|
3113
|
+
const itemEnv = isShell ? { PMOS_SESSION: '', PMOS_BIN: '' } : { PMOS_SESSION: sessionToken, PMOS_BIN: path.join(__dirname, 'bin', 'pmos.js') };
|
|
3114
|
+
Object.assign(penv, itemEnv);
|
|
3074
3115
|
const base = { id, ws: w ? w.id : wsId(SELF_ROOT), root, name: (name && String(name).trim()) || agent, agent, origin: tag,
|
|
3075
3116
|
profile: pres.id, profileRefusal: pres.ok ? null : pres.error };
|
|
3117
|
+
// Hand to agent: the breakdown item this session was started for, or null. Set only by /api/plan-items/handoff/go.
|
|
3118
|
+
base.plan_item = planItem || null;
|
|
3119
|
+
base.token = sessionToken;
|
|
3076
3120
|
|
|
3077
3121
|
/* WHICH MODEL (D75). Resolved once, here, with the profile, so the record can say only what was applied.
|
|
3078
3122
|
* Three states are kept apart — not asked, applied, and "this binary has no model control" — and a choice that
|
|
@@ -3127,7 +3171,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
3127
3171
|
// the OVERLAY, not the merged env — the merged env is for the direct-PTY path below, which
|
|
3128
3172
|
// really does spawn the process itself. See createTmuxSession for why the two differ.
|
|
3129
3173
|
// 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)))) {
|
|
3174
|
+
if (!createTmuxSession(tname, launchArgs.length ? [cmd, ...launchArgs].join(' ') : cmd, workdir, 80, 24, itemEnv, sessionFlag.tmuxOverlay(profileOverlay(pres.profile)))) {
|
|
3131
3175
|
const why = `could not start a tmux session for '${cmd}'. Run \`npm run doctor\` in workspace/.`;
|
|
3132
3176
|
s.state = 'exited'; s.tmux = null; s.note = why;
|
|
3133
3177
|
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 +3235,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
3191
3235
|
// A FAILED git operation in a real repo must never spawn a session on the wrong branch (rubric
|
|
3192
3236
|
// hard floor): we return {ok:false} and spawn nothing. Kept as its own function so the
|
|
3193
3237
|
// 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 }) {
|
|
3238
|
+
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative, modelAsk, origin, initialInput, planItem }) {
|
|
3195
3239
|
const w = getWs(ws);
|
|
3196
3240
|
if (!w) return { ok: false, error: 'no workspace' };
|
|
3197
3241
|
// The profile is checked BEFORE any git operation. A composer that picked a profile PMOS cannot
|
|
@@ -3238,7 +3282,7 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
|
|
|
3238
3282
|
broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
|
|
3239
3283
|
}
|
|
3240
3284
|
}
|
|
3241
|
-
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative, modelAsk, origin, initialInput });
|
|
3285
|
+
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative, modelAsk, origin, initialInput, planItem });
|
|
3242
3286
|
// The co-tenancy fact, on the ordinary path too. Note where this sits: OUTSIDE the `if (branch)`
|
|
3243
3287
|
// block above. The isolate default already computes that other sessions would be disturbed — and
|
|
3244
3288
|
// then the whole git block is skipped when no branch was picked, which is the common path and the
|
|
@@ -3341,7 +3385,7 @@ function startPreview({ initiative, repoKey, ws }) {
|
|
|
3341
3385
|
// One click. Resolves the policy, then hands the SAME seam spawnWithBranch already exposes the
|
|
3342
3386
|
// branch/isolate decision it made — so the inherited hard floor (a failed git operation spawns
|
|
3343
3387
|
// nothing) is enforced in exactly one place and is not re-implemented here.
|
|
3344
|
-
function startWork({ initiative, repoKey, ws, agent, name, profile, origin, initialInput }) {
|
|
3388
|
+
function startWork({ initiative, repoKey, ws, agent, name, profile, origin, initialInput, planItem }) {
|
|
3345
3389
|
const key = repoKeyFor({ repoKey, ws });
|
|
3346
3390
|
if (!key) return { ok: false, error: 'no repository selected' };
|
|
3347
3391
|
const trees = treesOfRepo(key);
|
|
@@ -3367,6 +3411,8 @@ function startWork({ initiative, repoKey, ws, agent, name, profile, origin, init
|
|
|
3367
3411
|
// next-step-actions: a session started from a next step carries its origin (so the budget can count it) and its
|
|
3368
3412
|
// prompt. Only the /api/next-step/go route passes either; the socket's `start` message never does.
|
|
3369
3413
|
origin, initialInput,
|
|
3414
|
+
// Hand to agent (pm-os-absorb Run 1): only /api/plan-items/handoff/go passes it.
|
|
3415
|
+
planItem,
|
|
3370
3416
|
});
|
|
3371
3417
|
if (!r.ok) return r;
|
|
3372
3418
|
return { ok: true, session: r.session, notice: r.notice || plan.why, plan: plan.plan };
|
|
@@ -3434,7 +3480,7 @@ function nextStepsFor(w, id) {
|
|
|
3434
3480
|
const acc = readAcceptances(w).get(id) || {};
|
|
3435
3481
|
const lc = readLifecycle(w).get(id) || null;
|
|
3436
3482
|
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),
|
|
3483
|
+
lifecycle: lc && lc.state !== 'active' ? lc : null, ladder, needs: (readNeeds(w) || []).filter((n) => n && n.initiative_id === id && notPlanNeed(n)),
|
|
3438
3484
|
sessions: sessRows.filter((x) => x.initiative === id || liveIds.has(x.id)) });
|
|
3439
3485
|
nextStepActions.annotate(ns, (it) => nextStepPathOf(root, it));
|
|
3440
3486
|
attachRuns(ns, id);
|
|
@@ -3469,6 +3515,55 @@ function prepareNextStep(body) {
|
|
|
3469
3515
|
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
3516
|
}
|
|
3471
3517
|
|
|
3518
|
+
// ── Hand to agent (pm-os-absorb Run 1, PRD R14) ────────────────────────────────────────────────────────────────
|
|
3519
|
+
// A breakdown item is PREPARED (read-only: the first prompt line, where the session will run, the full prompt folded
|
|
3520
|
+
// away) and then STARTED by the PM's press with exactly that prompt — the next-step pattern. The session is bound to
|
|
3521
|
+
// the item (`plan_item`) and reports with `pmos item`. Neither step writes plan-items.jsonl or planning/work-items.jsonl:
|
|
3522
|
+
// materialization arrives in Run 4. Nothing else starts a bound session — no drag or drop reaches this code.
|
|
3523
|
+
const HANDOFF_WHERE = { 'new-worktree': 'new branch', 'reuse-worktree': 'its branch', 'main-tree-on-branch': 'its branch', 'main-tree': 'this tree' };
|
|
3524
|
+
// The next-step budget, with Hand to agent sessions counted beside next-step ones: both are sessions the workspace
|
|
3525
|
+
// starts on the PM's behalf. nsBudget() itself is unchanged, so the next-step limit reads exactly as before.
|
|
3526
|
+
function handoffBudget() {
|
|
3527
|
+
const today = new Date().toDateString();
|
|
3528
|
+
const all = [...sessions.values()];
|
|
3529
|
+
const running = all.filter((x) => (x.origin === 'next-step' || x.origin === 'plan-item') && x.state !== 'exited').length;
|
|
3530
|
+
const startedToday = nsRead(NS_RUNS()).filter((r) => r && r.at && new Date(r.at).toDateString() === today).length
|
|
3531
|
+
+ all.filter((x) => x.origin === 'plan-item' && x.created && new Date(x.created).toDateString() === today).length;
|
|
3532
|
+
return sessionBudget.check({ running, startedToday, limits: nsLimits() });
|
|
3533
|
+
}
|
|
3534
|
+
function prepareHandoff(body) {
|
|
3535
|
+
const w = body && body.ws ? workspaces.get(String(body.ws)) : null;
|
|
3536
|
+
if (!w) return { ok: false, status: 404, error: 'no such workspace' };
|
|
3537
|
+
const itemId = String((body && body.item) || '');
|
|
3538
|
+
if (!planItems.ID_RE.test(itemId)) return { ok: false, status: 400, error: 'item must be an item id, <initiative>.<10 characters>' };
|
|
3539
|
+
let fold;
|
|
3540
|
+
try { fold = planItems.readFold(w.root); }
|
|
3541
|
+
catch (e) { return { ok: false, status: 500, error: 'could not read the plan-items ledger: ' + String((e && e.message) || 'error').slice(0, 140) }; }
|
|
3542
|
+
const it = fold.items[itemId];
|
|
3543
|
+
if (!it) return { ok: false, status: 404, error: `no item ${itemId} in this repository` };
|
|
3544
|
+
const live = [...sessions.values()].find((x) => x.plan_item === itemId && x.state !== 'exited');
|
|
3545
|
+
if (live) return { ok: false, status: 409, error: 'a session is already working on this item', session: live.id };
|
|
3546
|
+
/* Hand to agent starts an AGENT, never a shell: a prompt typed into a shell would run as commands, and an item's note
|
|
3547
|
+
* is text an agent can write. The page never sends `agent`; anything but claude is refused rather than swapped. */
|
|
3548
|
+
if (body.agent != null && body.agent !== 'claude') return { ok: false, status: 400, error: 'Hand to agent starts claude' };
|
|
3549
|
+
const agent = 'claude';
|
|
3550
|
+
let stageLabel = it.stage;
|
|
3551
|
+
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 */ }
|
|
3552
|
+
const gone = new Set(fold.withdrawnIds || []);
|
|
3553
|
+
const { first, prompt } = planItems.handoffPrompt({ ...it, after: (it.after || []).filter((x) => !gone.has(x)) }, it.initiative, stageLabel);
|
|
3554
|
+
const preview = startPreview({ initiative: it.initiative, ws: w.id });
|
|
3555
|
+
if (!preview.ok) return { ok: false, status: 409, error: preview.error || 'no branch and tree could be chosen for this item' };
|
|
3556
|
+
const where = HANDOFF_WHERE[preview.plan] || preview.plan;
|
|
3557
|
+
return {
|
|
3558
|
+
ok: true, item: itemId, initiative: it.initiative, agent, plan: preview.plan, where, first,
|
|
3559
|
+
line: `Agent will: ${first} · ${where}`, prompt,
|
|
3560
|
+
// Go starts exactly what was shown: the prompt, the plan, the agent, the branch and the tree it was shown for.
|
|
3561
|
+
prompt_sha: crypto.createHash('sha256').update([prompt, preview.plan, agent, preview.branch || '', preview.wsId || preview.dir || '', w.id].join('\n')).digest('hex'),
|
|
3562
|
+
details: { branch: preview.branch || null, tree: preview.dir || null, session_name: 'item: ' + it.title.slice(0, 40) },
|
|
3563
|
+
ws: w.id,
|
|
3564
|
+
};
|
|
3565
|
+
}
|
|
3566
|
+
|
|
3472
3567
|
// ── Background prepare, slice D3 (initiative proactive-engine) ──────────────────────────────────────────────────
|
|
3473
3568
|
// The PM approves ONE plan hash from Today. The 60-second tick later starts that session through the SAME path a next-step
|
|
3474
3569
|
// `go` uses (startWork with origin next-step and the prompt that was shown), only while background prepare is on, outside quiet
|
|
@@ -3528,6 +3623,21 @@ function prepareTick({ manual = false } = {}) {
|
|
|
3528
3623
|
const PRO_SIGNALS = () => path.join(STATE, 'signals.jsonl');
|
|
3529
3624
|
const PRO_FEEDBACK = () => path.join(STATE, 'proactive-feedback.jsonl');
|
|
3530
3625
|
const PRO_SETTINGS = () => path.join(STATE, 'proactive-settings.json');
|
|
3626
|
+
// today-goals: one goals file per local day, and the newest stored brief (the same read GET /api/proactive/brief makes).
|
|
3627
|
+
const GOALS_DIR = () => path.join(STATE, 'goals');
|
|
3628
|
+
const goalsCardsMemo = new Map(); // ws -> { at, cards, error }: GET /api/proactive/goals reads Today's cards at most every 30 s
|
|
3629
|
+
function readGoalsDay(date) {
|
|
3630
|
+
let raw = null;
|
|
3631
|
+
try { raw = JSON.parse(fs.readFileSync(path.join(GOALS_DIR(), date + '.json'), 'utf8')); } catch { /* none yet */ }
|
|
3632
|
+
return goalsLib.readDay(raw, date);
|
|
3633
|
+
}
|
|
3634
|
+
function latestBrief() {
|
|
3635
|
+
try {
|
|
3636
|
+
const dir = path.join(STATE, 'briefs');
|
|
3637
|
+
const f = fs.readdirSync(dir).filter((n) => /^\d{4}-\d{2}-\d{2}\.json$/.test(n)).sort().pop();
|
|
3638
|
+
return f ? JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) : null;
|
|
3639
|
+
} catch { return null; }
|
|
3640
|
+
}
|
|
3531
3641
|
let proLastScan = 0;
|
|
3532
3642
|
const PRO_SCAN_EVERY_MS = 10 * 60 * 1000;
|
|
3533
3643
|
function proSettings() {
|
|
@@ -3560,7 +3670,7 @@ function proactiveCtx(now, settings) {
|
|
|
3560
3670
|
const lc = life.get(b.id); const stopped = !!(lc && lc.state !== 'active');
|
|
3561
3671
|
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
3672
|
}
|
|
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 }));
|
|
3673
|
+
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
3674
|
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
3675
|
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
3676
|
} catch (e) { entry.error = 'the repository could not be read — ' + String((e && e.message) || 'error').slice(0, 120); }
|
|
@@ -3875,10 +3985,33 @@ function leadingFrontmatter(file) { return fmParse.leadingFrontmatter(file); }
|
|
|
3875
3985
|
// The outcome rule mirrors kit/scripts/metrics.py EXACTLY, including "a missing horizon counts
|
|
3876
3986
|
// as due" — a bet with no judgment date can never come due by itself (horizon-gaming-by-omission).
|
|
3877
3987
|
// Derivation only ever reports; it never disposes anything (D57).
|
|
3988
|
+
/* The plan-items ledger belongs to the REPOSITORY (lib/plan-items.js ledgerRoot), not to a working tree, so its needs
|
|
3989
|
+
* rows are derived for ONE registered tree of it: the tree at the ledger's root, else the first registered. Otherwise a
|
|
3990
|
+
* repository with a linked worktree registered would count every Keep card twice. */
|
|
3991
|
+
function isPlanNeedsTree(w) {
|
|
3992
|
+
let mine; try { mine = planItems.ledgerRoot(w.root); } catch { return true; }
|
|
3993
|
+
const same = [...workspaces.values()].filter((t) => { try { return planItems.ledgerRoot(t.root) === mine; } catch { return false; } });
|
|
3994
|
+
if (!same.some((t) => t.id === w.id)) return true;
|
|
3995
|
+
const lead = same.find((t) => path.resolve(t.root) === mine) || same[0];
|
|
3996
|
+
return lead.id === w.id;
|
|
3997
|
+
}
|
|
3878
3998
|
function deriveNeeds(w) {
|
|
3999
|
+
if (!w) return [];
|
|
4000
|
+
/* pm-os-absorb Run 1 — the breakdown's needs come FIRST and do not depend on a state directory: one Keep card
|
|
4001
|
+
* per agent batch, an agent Done with no evidence, and a Keep line that was not written from the page. A ledger
|
|
4002
|
+
* that cannot be read is said here, as a row, rather than turned into an empty list or a 500 for every caller. */
|
|
4003
|
+
let needs = [];
|
|
4004
|
+
if (isPlanNeedsTree(w)) {
|
|
4005
|
+
try { needs = planItems.needsFor(w.root).map((n) => ({ ...n, ws: w.id })); }
|
|
4006
|
+
catch (e) {
|
|
4007
|
+
needs = [{ run_id: 'plan-items:unreadable', initiative_id: null, kind: 'plan-flag', derived: true, doc: null, ws: w.id,
|
|
4008
|
+
question: 'The plan-items ledger could not be read: ' + String((e && e.message) || 'error').slice(0, 160) }];
|
|
4009
|
+
}
|
|
4010
|
+
}
|
|
4011
|
+
// pm-os-absorb Runs 2–5 — the planning layer's other needs rows, one provider file per run (lib/needs-providers.js).
|
|
4012
|
+
needs = needs.concat(needsProviders.rowsFor(w, { lead: isPlanNeedsTree(w) }));
|
|
3879
4013
|
const dir = stateDirFor(w);
|
|
3880
|
-
if (!dir) return
|
|
3881
|
-
const needs = [];
|
|
4014
|
+
if (!dir) return needs;
|
|
3882
4015
|
const settled = new Set();
|
|
3883
4016
|
for (const a of readLedger(w, 'acceptances.jsonl')) if (a.run_id) settled.add(a.run_id);
|
|
3884
4017
|
for (const e of readLedger(w, 'evals.jsonl')) {
|
|
@@ -3923,6 +4056,13 @@ function readNeeds(w) {
|
|
|
3923
4056
|
const seen = new Set(queued.map((n) => n && n.run_id).filter(Boolean));
|
|
3924
4057
|
return queued.concat(deriveNeeds(w).filter((n) => !seen.has(n.run_id)));
|
|
3925
4058
|
}
|
|
4059
|
+
/* pm-os-absorb Run 1 — the breakdown's needs rows (a Keep card, an agent Done with no evidence, a forged Keep) are the
|
|
4060
|
+
* 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
|
|
4061
|
+
* an initiative's derived state, its next steps or the portfolio's needs list: those read the rows they read before
|
|
4062
|
+
* Run 1, so a pending Keep does not turn an initiative `blocked` with a sentence naming needs.json, and Today does not
|
|
4063
|
+
* show the same batch twice (once as a Keep card, once as an "Unblock" initiative card). */
|
|
4064
|
+
const PLAN_NEED_KINDS = new Set(['keep', 'evidence', 'plan-flag']);
|
|
4065
|
+
const notPlanNeed = (n) => !(n && n.derived === true && PLAN_NEED_KINDS.has(n.kind)) && !needsProviders.isProviderRow(n);
|
|
3926
4066
|
|
|
3927
4067
|
// ── Read-only console surfaces over file-mode state (D64 Track B). ──────────────────────
|
|
3928
4068
|
// The committed hosted snapshot is the seed/demo source when no LIVE file-mode state exists; the
|
|
@@ -3959,6 +4099,10 @@ function needsResult(w) {
|
|
|
3959
4099
|
run_id: n.run_id || null, kind: n.kind || 'question', initiative_id: n.initiative_id || null,
|
|
3960
4100
|
question: n.question || n.t || '(unstated)', derived: !!n.derived, doc: n.doc || null,
|
|
3961
4101
|
deliverable: n.deliverable || null,
|
|
4102
|
+
// pm-os-absorb Run 1 — a breakdown row keeps what its card needs; other rows are unchanged (no new keys).
|
|
4103
|
+
...(n.ws ? { ws: n.ws } : {}), ...(n.batch ? { batch: n.batch } : {}), ...(n.item ? { item: n.item } : {}), ...(n.flag ? { flag: n.flag } : {}),
|
|
4104
|
+
// pm-os-absorb Runs 2–5 — a provider row's Today card (lib/needs-providers.js); absent on every other row.
|
|
4105
|
+
...(n.today && typeof n.today === 'object' ? { today: n.today } : {}),
|
|
3962
4106
|
}));
|
|
3963
4107
|
// `all` is the RAW combined queue — what readNeeds(w) computes — carried so /api/needs can serve
|
|
3964
4108
|
// both the strict state and the queue from one derivation instead of deriving twice per request.
|
|
@@ -3996,10 +4140,14 @@ const needsAgg = new Map(); // ws id -> { at, sig, value }
|
|
|
3996
4140
|
// requirement is about. `none` and `unreadable` are distinct signatures on purpose; a file that
|
|
3997
4141
|
// appears, vanishes or becomes unstattable each invalidate.
|
|
3998
4142
|
function needsSig(w) {
|
|
4143
|
+
// The plan-items ledger feeds needs too (Keep, no evidence, forged Keep), so its signature is part of this one.
|
|
4144
|
+
let plan = 'none';
|
|
4145
|
+
try { plan = planItems.ledgerSigs([{ id: w.id, root: w.root }], null)[w.id].items; } catch { plan = 'unreadable'; }
|
|
4146
|
+
plan += '|' + needsProviders.sigFor(w); // pm-os-absorb Runs 2–5 — and every needs provider's
|
|
3999
4147
|
const f = needsFileFor(w);
|
|
4000
|
-
if (!f) return
|
|
4001
|
-
try { const st = fs.statSync(f); return `${st.mtimeMs}:${st.size}`; }
|
|
4002
|
-
catch { return
|
|
4148
|
+
if (!f) return `none|${plan}`;
|
|
4149
|
+
try { const st = fs.statSync(f); return `${st.mtimeMs}:${st.size}|${plan}`; }
|
|
4150
|
+
catch { return `unreadable|${plan}`; }
|
|
4003
4151
|
}
|
|
4004
4152
|
function needsCountCached(w) {
|
|
4005
4153
|
const hit = needsAgg.get(w.id);
|
|
@@ -4793,7 +4941,18 @@ async function handleRequest(req, res) {
|
|
|
4793
4941
|
const url = new URL(req.url, 'http://localhost');
|
|
4794
4942
|
const p = url.pathname;
|
|
4795
4943
|
|
|
4796
|
-
if (p === '/' || p === '/index.html')
|
|
4944
|
+
if (p === '/' || p === '/index.html') {
|
|
4945
|
+
// pm-os-absorb Run 2 (R25, X11): the per-launch nonce goes into the base meta line, nowhere else. no-store, so a
|
|
4946
|
+
// restarted server never answers a press from a page that holds the previous launch's value.
|
|
4947
|
+
const n = pmGuard.launchNonce();
|
|
4948
|
+
if (!n) return serveFile(res, path.join(PUB, 'index.html'), null, true);
|
|
4949
|
+
return fs.readFile(path.join(PUB, 'index.html'), 'utf8', (err, html) => {
|
|
4950
|
+
if (err) { res.writeHead(404); res.end('not found'); return; }
|
|
4951
|
+
const out = html.replace('<meta name="' + pmGuard.NONCE_META + '" content="" />', '<meta name="' + pmGuard.NONCE_META + '" content="' + n + '" />');
|
|
4952
|
+
res.writeHead(200, { 'Content-Type': TYPES['.html'], 'Cache-Control': 'no-store' });
|
|
4953
|
+
res.end(out);
|
|
4954
|
+
});
|
|
4955
|
+
}
|
|
4797
4956
|
// NB: there is no `/app.js` route. The single-file front end that owned it (public/app.js +
|
|
4798
4957
|
// public/styles.css) was RETIRED with the four-panel design — the shell is index.html plus the
|
|
4799
4958
|
// /pm/ module graph below, and one front end is the point. A stray <script src="/app.js"> now
|
|
@@ -4818,6 +4977,10 @@ async function handleRequest(req, res) {
|
|
|
4818
4977
|
// client feature that depends on it fails in a way no server-side test can see.
|
|
4819
4978
|
if (p === '/lib/apply-change.js') return serveFile(res, path.join(__dirname, 'lib', 'apply-change.js'));
|
|
4820
4979
|
if (p === '/lib/related-docs.js') return serveFile(res, path.join(__dirname, 'lib', 'related-docs.js'));
|
|
4980
|
+
// pm-os-absorb — the words an initiative shows (R3), one mapping for the page and for node over every record.
|
|
4981
|
+
if (p === '/lib/initiative-display.js') return serveFile(res, path.join(__dirname, 'lib', 'initiative-display.js'));
|
|
4982
|
+
// pm-os-absorb — the dated bars of a breakdown (R20): Breakdown › Timeline, Initiatives › Timeline and the report strip.
|
|
4983
|
+
if (p === '/lib/item-spans.js') return serveFile(res, path.join(__dirname, 'lib', 'item-spans.js'));
|
|
4821
4984
|
// …and no `/styles.css` route either: the token layer and every component rule now ship as
|
|
4822
4985
|
// /pm/tokens.css + /pm/mobile.css through the /pm/ directory route below.
|
|
4823
4986
|
// Every browser asks for /favicon.ico unprompted. Without a route it 404s on every load, which
|
|
@@ -4831,6 +4994,29 @@ async function handleRequest(req, res) {
|
|
|
4831
4994
|
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'max-age=86400' });
|
|
4832
4995
|
return res.end(svg);
|
|
4833
4996
|
}
|
|
4997
|
+
// workspace-phone-app: what Add to Home Screen reads, so the phone opens the workspace full screen
|
|
4998
|
+
// with its own icon. The manifest is built here rather than shipped as a file because its colours
|
|
4999
|
+
// are the dark theme's tokens and its start_url must be the bare page: never a ?token= URL, which
|
|
5000
|
+
// would store the secret in the phone's home-screen entry. No service worker, on purpose: this is
|
|
5001
|
+
// live state, and an offline cache would show a stale board as if it were current.
|
|
5002
|
+
if (p === '/manifest.webmanifest') {
|
|
5003
|
+
res.writeHead(200, { 'Content-Type': 'application/manifest+json', 'Cache-Control': 'no-cache' });
|
|
5004
|
+
return res.end(JSON.stringify({
|
|
5005
|
+
name: 'PMOS workspace', short_name: 'PMOS', id: '/', start_url: '/', scope: '/',
|
|
5006
|
+
display: 'standalone', orientation: 'any', background_color: '#090b0c', theme_color: '#090b0c',
|
|
5007
|
+
icons: [
|
|
5008
|
+
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
|
5009
|
+
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
|
5010
|
+
// The mark sits inside the central 62%, so the same image survives an Android maskable crop.
|
|
5011
|
+
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
|
5012
|
+
],
|
|
5013
|
+
}));
|
|
5014
|
+
}
|
|
5015
|
+
// The three icons, by name (an allowlist, like /lib/ above). iOS also asks for /apple-touch-icon.png
|
|
5016
|
+
// at the root unprompted, so that path answers too.
|
|
5017
|
+
if (p === '/icons/icon-192.png' || p === '/icons/icon-512.png' || p === '/icons/apple-touch-icon.png' || p === '/apple-touch-icon.png') {
|
|
5018
|
+
return serveFile(res, path.join(PUB, 'icons', path.basename(p)), 'image/png');
|
|
5019
|
+
}
|
|
4834
5020
|
// xterm assets straight from node_modules (offline; no CDN)
|
|
4835
5021
|
if (p === '/vendor/xterm.js') return serveFile(res, path.join(XROOT, 'xterm', 'lib', 'xterm.js'));
|
|
4836
5022
|
if (p === '/vendor/xterm.css') return serveFile(res, path.join(XROOT, 'xterm', 'css', 'xterm.css'));
|
|
@@ -4846,6 +5032,10 @@ async function handleRequest(req, res) {
|
|
|
4846
5032
|
// of difference the design-parity comparator reports and a human eye does not.
|
|
4847
5033
|
if (p === '/vendor/ibm-plex-mono-500.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-500.woff2'));
|
|
4848
5034
|
if (p === '/vendor/ibm-plex-mono-700.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-700.woff2'));
|
|
5035
|
+
// D86 — the two vendored browser libraries of pm-os-absorb Run 1, committed under public/vendor/ (no CDN, no
|
|
5036
|
+
// package.json entry). SortableJS does every drag; the sprite holds the lucide icons the Breakdown uses.
|
|
5037
|
+
if (p === '/vendor/Sortable.min.js') return serveFile(res, path.join(PUB, 'vendor', 'Sortable.min.js'));
|
|
5038
|
+
if (p === '/vendor/lucide-sprite.js') return serveFile(res, path.join(PUB, 'vendor', 'lucide-sprite.js'));
|
|
4849
5039
|
// ── the four-panel front end (public/pm/) ──────────────────────────────────────────────────────
|
|
4850
5040
|
// A DIRECTORY route, deliberately, where /lib/ above is an allowlist. The allowlist's own comment
|
|
4851
5041
|
// records why it is a trap: a file missing from it 404s silently and the feature that needed it
|
|
@@ -5060,6 +5250,9 @@ async function handleRequest(req, res) {
|
|
|
5060
5250
|
let body = {};
|
|
5061
5251
|
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
5062
5252
|
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5253
|
+
// R25: writing parent_kr is the anchor change, a PM-only act.
|
|
5254
|
+
const g = pmGuard.requirePmPress(req, body, { act: 'anchor' });
|
|
5255
|
+
if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
|
|
5063
5256
|
// ONE item. There is no array form, no `items`, no `all` — the tedium IS the gate (R4). A
|
|
5064
5257
|
// bulk confirm is how a dozen guessed anchors land on the board with every check green.
|
|
5065
5258
|
if (Array.isArray(body.item) || body.items != null || body.all != null) {
|
|
@@ -5623,7 +5816,11 @@ async function handleRequest(req, res) {
|
|
|
5623
5816
|
}
|
|
5624
5817
|
// `version` lets the launcher notice a server still running OLD code after an upgrade: files on disk
|
|
5625
5818
|
// advance under a live process, so the UI would otherwise call routes that process never learned.
|
|
5626
|
-
|
|
5819
|
+
// `ledgers` (pm-os-absorb Run 1) — the plan-items signature of every registered workspace, so an open Breakdown
|
|
5820
|
+
// knows to refetch: `items` is one stat per workspace; `evidence` is computed only for the ids in ?ledgers=
|
|
5821
|
+
// (at most 4). Computed on this request and never stored (D33).
|
|
5822
|
+
if (p === '/api/sessions') return json(res, { sessions: sessionList(), version: APP_VERSION,
|
|
5823
|
+
ledgers: planItems.ledgerSigs([...workspaces.values()].map((w) => ({ id: w.id, root: w.root })), url.searchParams.get('ledgers')) });
|
|
5627
5824
|
if (p === '/api/git/branches') {
|
|
5628
5825
|
// Read-only branch awareness for a workspace. A non-repo returns {ok:false} (never a fake
|
|
5629
5826
|
// branch). Fixed git args, no caller input in the read path (R1).
|
|
@@ -5788,13 +5985,16 @@ async function handleRequest(req, res) {
|
|
|
5788
5985
|
// per-tree counts a COLLAPSED sidebar row shows, which is the whole reason a row can be collapsed.
|
|
5789
5986
|
if (p === '/api/needs') {
|
|
5790
5987
|
const w = getWs(url.searchParams.get('ws'));
|
|
5988
|
+
// The signature is taken BEFORE the read, as needsCountCached takes it: a write between the two then leaves an
|
|
5989
|
+
// older signature on the cached count (read again on the next call), never a newer one on an older count.
|
|
5990
|
+
let sig = null;
|
|
5991
|
+
if (w) { try { sig = needsSig(w); } catch { sig = 'unreadable'; } }
|
|
5791
5992
|
const strict = needsResult(w);
|
|
5792
5993
|
// One derivation per request, not three: the strict read already holds the combined queue
|
|
5793
5994
|
// (`all` — identical to readNeeds when the read succeeds; on a corrupt file readNeeds keeps its
|
|
5794
5995
|
// lenient derived-only fallback), and the by_ws cache is seeded from the same read so the
|
|
5795
5996
|
// selected tree is not derived again — fresher than any cache hit, so R10 still holds.
|
|
5796
5997
|
if (w) {
|
|
5797
|
-
let sig; try { sig = needsSig(w); } catch { sig = 'unreadable'; }
|
|
5798
5998
|
needsAgg.set(w.id, { at: Date.now(), sig, value: strict.ok
|
|
5799
5999
|
? { count: strict.empty ? 0 : strict.data.items.length, error: null }
|
|
5800
6000
|
: { count: null, error: strict.error } });
|
|
@@ -5904,7 +6104,7 @@ async function handleRequest(req, res) {
|
|
|
5904
6104
|
nextSteps = deriveNextSteps({
|
|
5905
6105
|
id, accepted: acc.action === 'accept', returned: acc.action === 'return',
|
|
5906
6106
|
lifecycle: lc && lc.state !== 'active' ? lc : null, ladder,
|
|
5907
|
-
needs: (readNeeds(w) || []).filter((n) => n && n.initiative_id === id),
|
|
6107
|
+
needs: (readNeeds(w) || []).filter((n) => n && n.initiative_id === id && notPlanNeed(n)),
|
|
5908
6108
|
sessions: sessRows.filter((x) => (x.initiative === id) || conversations.some((c) => c.id === x.id && c.live)),
|
|
5909
6109
|
});
|
|
5910
6110
|
} catch (e) { nextSteps = { state: 'unreadable', summary: 'The next steps could not be derived — ' + String((e && e.message) || 'error').slice(0, 120), items: [] }; }
|
|
@@ -5984,6 +6184,9 @@ async function handleRequest(req, res) {
|
|
|
5984
6184
|
let body = {};
|
|
5985
6185
|
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
5986
6186
|
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6187
|
+
// R25 / R23: binding decides which initiatives an agent session works on, so an agent cannot bind itself.
|
|
6188
|
+
const g = pmGuard.requirePmPress(req, body, { act: 'bind' });
|
|
6189
|
+
if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
|
|
5987
6190
|
const s = sessions.get(String(body.id || ''));
|
|
5988
6191
|
if (!s) return json(res, { ok: false, error: 'no such session' }, 404);
|
|
5989
6192
|
const clearing = body.initiative === null || body.initiative === '';
|
|
@@ -6141,6 +6344,10 @@ async function handleRequest(req, res) {
|
|
|
6141
6344
|
let body = {};
|
|
6142
6345
|
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
6143
6346
|
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6347
|
+
// R25: every action this route records is the PM's — acceptance, review approve and return, document approval,
|
|
6348
|
+
// contract approval and a D60 disposition. One guard before the action is read, so no action escapes it.
|
|
6349
|
+
const g = pmGuard.requirePmPress(req, body, { act: 'accept' });
|
|
6350
|
+
if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
|
|
6144
6351
|
if (!body.action || !body.run_id) return json(res, { ok: false, error: 'action and run_id are required' }, 400);
|
|
6145
6352
|
const w = getWs(body.ws);
|
|
6146
6353
|
const rec = { at: nowISO(), date: nowISO().slice(0, 10),
|
|
@@ -6158,7 +6365,7 @@ async function handleRequest(req, res) {
|
|
|
6158
6365
|
// it, validated against the same closed set, and simply absent when they do not.
|
|
6159
6366
|
accepted: body.action === 'accept' ? true : body.action === 'return' ? false : undefined,
|
|
6160
6367
|
...(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 };
|
|
6368
|
+
note: body.note || '', workspace: w ? w.name : null, root: w ? w.root : null, pm: true, by: g.actor.by, via: g.actor.via };
|
|
6162
6369
|
// A malformed label is refused rather than dropped. Silently discarding it would put the row in
|
|
6163
6370
|
// the unlabeled pile while the PM believed they had labelled it — the same shape as inferring.
|
|
6164
6371
|
if (body.pm_label !== undefined && body.pm_label !== 'pass' && body.pm_label !== 'fail') {
|
|
@@ -6321,6 +6528,69 @@ async function handleRequest(req, res) {
|
|
|
6321
6528
|
const wrote = nsAppend(NS_RUNS(), rec, 'next-step run');
|
|
6322
6529
|
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
6530
|
}
|
|
6531
|
+
/* ── pm-os-absorb Run 1 — an initiative's breakdown (plan-items.jsonl) ──────────────────────────────────────────
|
|
6532
|
+
* GET is the Breakdown layout: derived stages with their owed rows, the items, the Board columns, the Keep batches.
|
|
6533
|
+
* POST writes through the ONE writer as the PM (`pm:wawan`, `via: ui`). Every POST needs an Origin header: the
|
|
6534
|
+
* global guard already refuses a foreign one, and a request with none did not come from the page. Header values
|
|
6535
|
+
* are never logged (D08). `ladder=0` skips the ladder (about 800 ms here) when only the items changed. */
|
|
6536
|
+
if (p === '/api/plan-items' && req.method === 'GET') {
|
|
6537
|
+
const wsParam = url.searchParams.get('ws');
|
|
6538
|
+
const w = wsParam ? workspaces.get(wsParam) : homeWorkspace();
|
|
6539
|
+
if (!w) return json(res, { ok: false, error: 'no such workspace' }, 404);
|
|
6540
|
+
const id = url.searchParams.get('id') || '';
|
|
6541
|
+
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);
|
|
6542
|
+
try {
|
|
6543
|
+
if (!planTree.recordPath(w.root, id)) return json(res, { ok: false, error: `no initiative ${id} in this repository` }, 404);
|
|
6544
|
+
const now = Date.now();
|
|
6545
|
+
// The signature is taken before the ledger and the ladder are read (lib/plan-items.js readLayout says why).
|
|
6546
|
+
const { sig, layout: lay } = planItems.readLayout({ id: w.id, root: w.root }, id, { ladder: url.searchParams.get('ladder') !== '0', now });
|
|
6547
|
+
const out = { ok: true, ws: w.id, initiative: id, profile: lay.profile, lane: lay.lane, ladderIncluded: lay.ladderIncluded };
|
|
6548
|
+
if (lay.ladderIncluded) out.ladder = lay.ladder;
|
|
6549
|
+
Object.assign(out, { sig, now: new Date(now).toISOString(), progress: lay.progress, stages: lay.stages, other: lay.other,
|
|
6550
|
+
items: lay.items, board: lay.board, batches: lay.batches, flags: lay.flags, withdrawnCount: lay.withdrawnCount });
|
|
6551
|
+
return json(res, out);
|
|
6552
|
+
} catch (e) {
|
|
6553
|
+
return json(res, { ok: false, error: 'the breakdown could not be read: ' + String((e && e.message) || 'error').slice(0, 160) }, 500);
|
|
6554
|
+
}
|
|
6555
|
+
}
|
|
6556
|
+
if ((p === '/api/plan-items' || p === '/api/plan-items/handoff/prepare' || p === '/api/plan-items/handoff/go') && req.method === 'POST') {
|
|
6557
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6558
|
+
// R25: pm-guard's body, writer-field, Origin and nonce checks. A call that carries a Keep is the PM-only act `keep`.
|
|
6559
|
+
const keeps = p === '/api/plan-items' && body && Array.isArray(body.events) && body.events.some((e) => e && e.ev === 'kept');
|
|
6560
|
+
const g = keeps ? pmGuard.requirePmPress(req, body, { act: 'keep' }) : pmGuard.requirePagePress(req, body);
|
|
6561
|
+
if (!g.ok) return json(res, { ok: false, error: g.error, ...(p === '/api/plan-items' ? { index: null } : {}) }, g.status);
|
|
6562
|
+
if (p === '/api/plan-items') {
|
|
6563
|
+
const w = body.ws ? workspaces.get(String(body.ws)) : null;
|
|
6564
|
+
if (!w) return json(res, { ok: false, error: 'no such workspace', index: null }, 404);
|
|
6565
|
+
let r;
|
|
6566
|
+
try { r = planItems.appendFromUi(w.root, body.events, { actor: g.actor }); }
|
|
6567
|
+
catch (e) { warnWriteFail('plan-items.jsonl', e); return json(res, { ok: false, error: 'could not record — nothing was saved', index: null }, 500); }
|
|
6568
|
+
if (!r.ok) return json(res, { ok: false, error: r.error, index: r.index }, r.status || 400);
|
|
6569
|
+
invalidateNeedsAgg(); // the ledger is per repository: every tree of it may show a new Keep card
|
|
6570
|
+
const sig = planItems.ledgerSigs([{ id: w.id, root: w.root }], null)[w.id];
|
|
6571
|
+
return json(res, { ok: true, written: r.written, sig });
|
|
6572
|
+
}
|
|
6573
|
+
if (p === '/api/plan-items/handoff/prepare') {
|
|
6574
|
+
const r = prepareHandoff(body);
|
|
6575
|
+
if (!r.ok) { const { status, ...rest } = r; return json(res, rest, status || 400); }
|
|
6576
|
+
const { ws: _ws, ...shown } = r;
|
|
6577
|
+
return json(res, shown);
|
|
6578
|
+
}
|
|
6579
|
+
// go: only the PM's press reaches it. It prepares again and starts one session only when the PM confirmed AND the
|
|
6580
|
+
// prompt and destination are the ones they were shown (the sha). It writes no ledger line.
|
|
6581
|
+
if (body.confirm !== true) return json(res, { ok: false, error: 'confirm is required: this starts a session' }, 400);
|
|
6582
|
+
const r = prepareHandoff(body);
|
|
6583
|
+
if (!r.ok) { const { status, ...rest } = r; return json(res, rest, status || 400); }
|
|
6584
|
+
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);
|
|
6585
|
+
const budget = handoffBudget();
|
|
6586
|
+
if (!budget.allowed) return json(res, { ok: false, error: budget.reason, budget }, 409);
|
|
6587
|
+
const started = startWork({ initiative: r.initiative, ws: r.ws, agent: r.agent, name: r.details.session_name,
|
|
6588
|
+
origin: 'plan-item', initialInput: r.prompt + '\r', planItem: r.item });
|
|
6589
|
+
if (!started.ok) return json(res, { ok: false, error: started.error }, 409);
|
|
6590
|
+
// The session's token is never projected; the page gets where it runs and the item it is bound to.
|
|
6591
|
+
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 },
|
|
6592
|
+
notice: started.notice || null, plan: started.plan });
|
|
6593
|
+
}
|
|
6324
6594
|
if (p === '/api/next-step/summary' && req.method === 'GET') {
|
|
6325
6595
|
// What an ended (or running) next-step session did: the files git shows on its branch against trunk, the end of the
|
|
6326
6596
|
// conversation, and every JUDGMENT line checked against its shape. Read-only.
|
|
@@ -6352,6 +6622,8 @@ async function handleRequest(req, res) {
|
|
|
6352
6622
|
// The PM's verdict on one step's run. It accepts or sends back THE STEP. It never records an acceptance of the
|
|
6353
6623
|
// initiative, never changes a status and never touches a lifecycle line.
|
|
6354
6624
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6625
|
+
const g = pmGuard.requirePmPress(req, body, { act: 'accept' }); // R25: accepting or returning a step is the PM's
|
|
6626
|
+
if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
|
|
6355
6627
|
const verdict = body.verdict === 'accept' ? 'accept' : body.verdict === 'send-back' ? 'send-back' : null;
|
|
6356
6628
|
if (!verdict) return json(res, { ok: false, error: 'verdict must be accept or send-back' }, 400);
|
|
6357
6629
|
const note = String(body.note || '').trim().slice(0, 500);
|
|
@@ -6361,7 +6633,7 @@ async function handleRequest(req, res) {
|
|
|
6361
6633
|
if (!run) return json(res, { ok: false, error: 'that session was not started from a next step' }, 404);
|
|
6362
6634
|
const live = sessions.get(sid);
|
|
6363
6635
|
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' };
|
|
6636
|
+
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
6637
|
const wrote = nsAppend(NS_REVIEWS(), rec, 'step review');
|
|
6366
6638
|
if (!wrote.ok) return json(res, { ok: false, error: wrote.error }, 500);
|
|
6367
6639
|
return json(res, { ok: true, recorded: rec });
|
|
@@ -6679,6 +6951,66 @@ async function handleRequest(req, res) {
|
|
|
6679
6951
|
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
6680
6952
|
return json(res, { ok: true, ws: w.id, name: w.name, ...validateLib.readFindings(validationFindingsFile(w)) });
|
|
6681
6953
|
}
|
|
6954
|
+
/* ── today-goals (G1, G4, G5): the PM's goals for the day, the plan that joins them to the brief and Today, and "plan my
|
|
6955
|
+
* day". The goals routes write only goals/<date>.json in the state folder. "Plan my day" seeds the morning-brief
|
|
6956
|
+
* schedule when the repository has none and runs it through fireSchedule, the run-now path. Nothing else starts. ── */
|
|
6957
|
+
if (p === '/api/proactive/goals' && req.method === 'GET') {
|
|
6958
|
+
const now = Date.now();
|
|
6959
|
+
const date = briefLib.localDate(now);
|
|
6960
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
6961
|
+
const day = readGoalsDay(date);
|
|
6962
|
+
const brief = latestBrief();
|
|
6963
|
+
let cards = []; let cardsError = null;
|
|
6964
|
+
// Today's cards are read only when an open goal can take them, and at most once per 30 seconds per repository: the
|
|
6965
|
+
// read is synchronous and heavy on a large repository, and the Today feed itself must not wait behind it.
|
|
6966
|
+
if (w && day.goals.some((x) => x.status === 'open')) {
|
|
6967
|
+
const memo = goalsCardsMemo.get(w.id);
|
|
6968
|
+
if (memo && now - memo.at < 30000) ({ cards, error: cardsError } = memo);
|
|
6969
|
+
else {
|
|
6970
|
+
let sess = []; try { sess = sessionList(); } catch { /* no session cards */ }
|
|
6971
|
+
const t = todayLib.todayFor(w, { stateDir: stateDirFor(w), sessions: sess, registered: (root) => { const x = workspaces.get(wsId(root)); return !!x && path.resolve(x.root) === path.resolve(root); } });
|
|
6972
|
+
if (t.ok) cards = t.cards; else cardsError = t.error;
|
|
6973
|
+
goalsCardsMemo.set(w.id, { at: now, cards, error: cardsError });
|
|
6974
|
+
}
|
|
6975
|
+
}
|
|
6976
|
+
const plan = goalsLib.planDay({ day, brief, cards, now });
|
|
6977
|
+
const sched = readSchedules().find((x) => x.skill === 'morning-brief' && (!w || x.ws === w.id)) || null;
|
|
6978
|
+
return json(res, { ok: true, ws: w ? w.id : null, today: date, plan, cards_error: cardsError, max_goals: goalsLib.MAX_GOALS,
|
|
6979
|
+
schedule: sched ? { id: sched.id, time: sched.time, lastRun: sched.lastRun, lastStatus: sched.lastStatus } : null,
|
|
6980
|
+
note: 'Goals are kept in the workspace state folder on this computer. The plan joins them to the morning brief and Today by goal id or initiative, never by guessing.' });
|
|
6981
|
+
}
|
|
6982
|
+
if (p === '/api/proactive/goals' && req.method === 'POST') {
|
|
6983
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6984
|
+
const now = Date.now();
|
|
6985
|
+
const date = briefLib.localDate(now);
|
|
6986
|
+
const r = goalsLib.edit(readGoalsDay(date), body, { now });
|
|
6987
|
+
if (!r.ok) return json(res, { ok: false, error: r.error }, /no goal with that id/.test(r.error) ? 404 : 400);
|
|
6988
|
+
try { fs.mkdirSync(GOALS_DIR(), { recursive: true }); fs.writeFileSync(path.join(GOALS_DIR(), date + '.json'), JSON.stringify(r.day) + '\n'); }
|
|
6989
|
+
catch (e) { warnWriteFail('goals', e); return json(res, { ok: false, error: 'could not save — nothing changed' }, 500); }
|
|
6990
|
+
return json(res, { ok: true, day: r.day, goal: r.goal });
|
|
6991
|
+
}
|
|
6992
|
+
if (p === '/api/proactive/goals/plan' && req.method === 'POST') {
|
|
6993
|
+
// A PM press: make sure the morning brief is scheduled for this repository, then run it now. The session reads the
|
|
6994
|
+
// goals with `pmos proactive goals` and submits a brief whose items name them.
|
|
6995
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6996
|
+
const w = getWs(body.ws);
|
|
6997
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
6998
|
+
const open = readGoalsDay(briefLib.localDate(Date.now())).goals.filter((g) => g.status === 'open');
|
|
6999
|
+
if (!open.length) return json(res, { ok: false, error: 'add a goal for today first: the plan is made for your goals' }, 409);
|
|
7000
|
+
let sched = readSchedules().find((x) => x.ws === w.id && x.skill === 'morning-brief');
|
|
7001
|
+
let seeded = false;
|
|
7002
|
+
if (!sched) {
|
|
7003
|
+
if (!taskById('morning-brief', w.root)) return json(res, { ok: false, error: 'the morning-brief skill is not installed for this repository' }, 409);
|
|
7004
|
+
const norm = scheduleLogic.normalizeSchedule({ ...SEED_SETS.brief[0], task: 'morning-brief' });
|
|
7005
|
+
if (!norm.ok) return json(res, { ok: false, error: norm.error }, 500);
|
|
7006
|
+
sched = { id: 'sc-' + Date.now().toString(36) + 'g', ws: w.id, ...norm.schedule, profile: null, lastRun: null, lastStatus: null };
|
|
7007
|
+
sched.nextRun = scheduleLogic.nextRun(sched);
|
|
7008
|
+
const list = readSchedules(); list.unshift(sched); writeSchedules(list); seeded = true;
|
|
7009
|
+
}
|
|
7010
|
+
const result = fireSchedule(sched);
|
|
7011
|
+
return json(res, { ok: !!result.ok, seeded, schedule: { id: sched.id, time: sched.time }, result, goals: open.length,
|
|
7012
|
+
...(result.ok ? {} : { error: result.reason || 'the morning-brief session did not start' }) }, result.ok ? 200 : 500);
|
|
7013
|
+
}
|
|
6682
7014
|
/* ── background prepare (D3): plan is read-only; queue records an approval; the runner starts it later ── */
|
|
6683
7015
|
if (p === '/api/proactive/prepare/plan' && req.method === 'POST') {
|
|
6684
7016
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
@@ -6752,7 +7084,8 @@ async function handleRequest(req, res) {
|
|
|
6752
7084
|
const dir = path.join(STATE, 'briefs');
|
|
6753
7085
|
if (req.method === 'POST') {
|
|
6754
7086
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6755
|
-
const
|
|
7087
|
+
const bday = body && typeof body.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(body.date) ? body.date : briefLib.localDate(Date.now());
|
|
7088
|
+
const v = briefLib.validateBrief(body, { now: Date.now(), settings: proSettings(), goalIds: readGoalsDay(bday).goals.map((g) => g.id) });
|
|
6756
7089
|
if (!v.ok) return json(res, { ok: false, error: v.error }, 400);
|
|
6757
7090
|
try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, v.brief.date + '.json'), JSON.stringify(v.brief) + '\n'); }
|
|
6758
7091
|
catch (e) { warnWriteFail('briefs', e); return json(res, { ok: false, error: 'could not save — nothing changed' }, 500); }
|
|
@@ -6828,17 +7161,37 @@ async function handleRequest(req, res) {
|
|
|
6828
7161
|
let body = {};
|
|
6829
7162
|
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
6830
7163
|
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6831
|
-
const
|
|
6832
|
-
if (
|
|
7164
|
+
const g = pmGuard.requirePmPress(req, body, { act: 'lifecycle' }); // R25: a lifecycle change is the PM's
|
|
7165
|
+
if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
|
|
7166
|
+
const ID_OK = (v) => typeof v === 'string' && /^[a-z0-9][a-z0-9._-]*$/i.test(v);
|
|
7167
|
+
// The bulk body `{ ws, ids, state }` (D86 point 2, X6): at most 20 ids and one state, under ONE lifecycle-bulk grant.
|
|
7168
|
+
const bulk = body.ids !== undefined;
|
|
7169
|
+
if (bulk && body.initiative_id !== undefined) return json(res, { ok: false, error: 'send initiative_id or ids, not both' }, 400);
|
|
7170
|
+
const ids = bulk ? body.ids : [String(body.initiative_id || '')];
|
|
7171
|
+
if (bulk && (!Array.isArray(ids) || !ids.length || ids.length > 20 || !ids.every(ID_OK) || new Set(ids).size !== ids.length)) {
|
|
7172
|
+
return json(res, { ok: false, error: 'ids must name 1 to 20 different initiatives' }, 400);
|
|
7173
|
+
}
|
|
7174
|
+
const id = ids[0];
|
|
7175
|
+
if (!ID_OK(id)) return json(res, { ok: false, error: 'initiative_id is required' }, 400);
|
|
6833
7176
|
if (body.state !== 'active' && !STOPPED_STATES.includes(body.state)) {
|
|
6834
7177
|
return json(res, { ok: false, error: `state must be one of ${STOPPED_STATES.concat('active').join(', ')}` }, 400);
|
|
6835
7178
|
}
|
|
6836
7179
|
const w = getWs(body.ws);
|
|
6837
7180
|
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
6838
|
-
|
|
6839
|
-
|
|
7181
|
+
const known = new Set(initiativeIndex(w.root).initiatives.map((x) => x.id));
|
|
7182
|
+
const missing = ids.find((x) => !known.has(x));
|
|
7183
|
+
if (missing) return json(res, { ok: false, error: `no initiative '${missing}' in ${w.name}` }, 404);
|
|
7184
|
+
const reason = String(body.reason || '').slice(0, 500);
|
|
7185
|
+
let grant = null;
|
|
7186
|
+
if (bulk) {
|
|
7187
|
+
const m = grantMint.mintFromPress(w.root, { scope: 'lifecycle-bulk', ids, state: body.state,
|
|
7188
|
+
instruction: `set ${ids.length} initiatives to ${body.state}`, initiative: null }, { actor: g.actor });
|
|
7189
|
+
if (!m.ok) return json(res, { ok: false, error: m.error }, m.status || 500); // the writer refused or is missing: nothing is written
|
|
7190
|
+
grant = m.grant_id;
|
|
6840
7191
|
}
|
|
6841
|
-
const
|
|
7192
|
+
const at = nowISO();
|
|
7193
|
+
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 } : {}) }));
|
|
7194
|
+
const rec = recs[0];
|
|
6842
7195
|
// The ledger the ENGINE reads for this repository's layout (scripts/workflow-state.js#layout): a repository
|
|
6843
7196
|
// with planning/initiatives/ keeps its ledgers in planning/runs/, a kit in pmos/state/. Writing anywhere else
|
|
6844
7197
|
// (R6 wrote the workspace's runtime state for PMOS-self) shows on the board but never clears the gate.
|
|
@@ -6849,13 +7202,18 @@ async function handleRequest(req, res) {
|
|
|
6849
7202
|
if (!d) return json(res, { ok: false, error: 'this repository has no state directory to record in — nothing was written' }, 500);
|
|
6850
7203
|
try { fs.mkdirSync(d, { recursive: true }); } catch { /* the guard reports it */ }
|
|
6851
7204
|
const sink = path.join(d, 'lifecycle.jsonl');
|
|
6852
|
-
|
|
7205
|
+
let wrote = 0;
|
|
7206
|
+
try { const sg = createSinkGuard({ sink, what: 'lifecycle' }); for (const r of recs) { sg.appendLine(sink, r); wrote++; } }
|
|
6853
7207
|
catch (e) {
|
|
6854
7208
|
warnWriteFail('lifecycle.jsonl', e);
|
|
6855
|
-
|
|
7209
|
+
// A partial bulk write names the grant that was used and the ids still to set, so the PM can press again for them.
|
|
7210
|
+
const rest = ids.slice(wrote);
|
|
7211
|
+
return json(res, { ok: false, error: wrote
|
|
7212
|
+
? `could not record the change — ${wrote} of ${recs.length} lines were saved${grant ? ` under grant ${grant}` : ''}; not saved: ${rest.join(', ')}. Set those again.`
|
|
7213
|
+
: 'could not record the change — nothing was saved', sink, ...(wrote ? { grant, unsaved: rest } : {}) }, 500);
|
|
6856
7214
|
}
|
|
6857
7215
|
boardCache.invalidate(w.root);
|
|
6858
|
-
return json(res, { ok: true, recorded: rec, sink });
|
|
7216
|
+
return json(res, bulk ? { ok: true, recorded: recs, grant, sink } : { ok: true, recorded: rec, sink });
|
|
6859
7217
|
}
|
|
6860
7218
|
if (p === '/api/doc/save' && req.method === 'POST') {
|
|
6861
7219
|
// The editor's save (workspace-doc-editor R3/R4). The ONLY write path on this surface, and it is
|
|
@@ -6865,6 +7223,9 @@ async function handleRequest(req, res) {
|
|
|
6865
7223
|
let body = {};
|
|
6866
7224
|
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
6867
7225
|
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
7226
|
+
// R25: a save can rewrite a record's parent_kr, so every save passes the anchor guard. `pm` is a writer field now.
|
|
7227
|
+
const g = pmGuard.requirePmPress(req, body, { act: 'anchor' });
|
|
7228
|
+
if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
|
|
6868
7229
|
|
|
6869
7230
|
const w = getWs(body.ws);
|
|
6870
7231
|
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
@@ -6902,7 +7263,7 @@ async function handleRequest(req, res) {
|
|
|
6902
7263
|
// needs.json and acceptances.jsonl use — a tenant product repo has no workspace/ folder, and
|
|
6903
7264
|
// assuming one is exactly the defect shipped in 0.7.1/0.7.2.
|
|
6904
7265
|
const region = applyChange.changedRegion(body.base, body.text);
|
|
6905
|
-
const rec = { file: String(body.path), at: nowISO(), pm:
|
|
7266
|
+
const rec = { file: String(body.path), at: nowISO(), pm: g.actor.by.slice('pm:'.length), via: g.actor.via,
|
|
6906
7267
|
note: typeof body.note === 'string' && body.note.trim() ? body.note.trim().slice(0, 400) : null,
|
|
6907
7268
|
at_line: region.at_line, added: region.added, removed: region.removed,
|
|
6908
7269
|
before: region.before, after: region.after };
|
|
@@ -7067,6 +7428,8 @@ async function handleRequest(req, res) {
|
|
|
7067
7428
|
let body = {};
|
|
7068
7429
|
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
7069
7430
|
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
7431
|
+
const g = pmGuard.requirePmPress(req, body, { act: 'anchor' }); // R25: an aligning edit can rewrite parent_kr too
|
|
7432
|
+
if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
|
|
7070
7433
|
const w = getWs(body.ws);
|
|
7071
7434
|
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
7072
7435
|
const rel = String(body.file || '');
|
|
@@ -7087,7 +7450,7 @@ async function handleRequest(req, res) {
|
|
|
7087
7450
|
const region = applyChange.changedRegion(body.before, body.after);
|
|
7088
7451
|
const dir = stateDirFor(w, { create: true });
|
|
7089
7452
|
if (dir) {
|
|
7090
|
-
const rec = { file: rel, at: nowISO(), pm:
|
|
7453
|
+
const rec = { file: rel, at: nowISO(), pm: g.actor.by.slice('pm:'.length), via: g.actor.via,
|
|
7091
7454
|
note: 'aligned with ' + (body.source || 'an earlier change') + (body.why ? ' — ' + String(body.why).slice(0, 200) : ''),
|
|
7092
7455
|
at_line: region.at_line, added: region.added, removed: region.removed,
|
|
7093
7456
|
before: region.before, after: region.after, aligned_from: body.source || null };
|
|
@@ -7114,6 +7477,8 @@ async function handleRequest(req, res) {
|
|
|
7114
7477
|
if (only) rows = rows.filter((r) => r.file === only);
|
|
7115
7478
|
return json(res, { ok: true, changes: rows.slice(-200).reverse(), total: rows.length });
|
|
7116
7479
|
}
|
|
7480
|
+
// 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.
|
|
7481
|
+
if (await routeModules.dispatch(req, res, url, { json, readBody, getWs, invalidateNeedsAgg, boardCache, consoleState, stateDirFor, sessionList })) return;
|
|
7117
7482
|
res.writeHead(404); res.end('not found');
|
|
7118
7483
|
}
|
|
7119
7484
|
|
|
@@ -7196,8 +7561,31 @@ function httpGuard(req, res) {
|
|
|
7196
7561
|
// sub-resource loads (CSS/JS) or the WS upgrade — so the documented "open http://…/?token=<t>"
|
|
7197
7562
|
// phone flow (REMOTE.md + startup banner) must reach THIS page-load guard, and every later request
|
|
7198
7563
|
// rides the cookie we seed just below. presentedToken() resolves all channels. (local hotfix)
|
|
7564
|
+
// workspace-phone-app: the sign-in form posts here. It is the one route an unauthorized peer may
|
|
7565
|
+
// reach, and all it can do is compare a token (constant-time) and, on a match, seed the same
|
|
7566
|
+
// cookie a valid ?token= navigation seeds. The body is capped; nothing about it is logged (D08).
|
|
7567
|
+
const navigation = remoteAccess.isPageNavigation({ method: req.method, accept: req.headers.accept });
|
|
7568
|
+
if (req.method === 'POST' && (req.url || '').split('?')[0] === remoteAccess.AUTH_PATH) {
|
|
7569
|
+
let body = '';
|
|
7570
|
+
req.on('data', (c) => { body += c; if (body.length > 4096) req.destroy(); });
|
|
7571
|
+
req.on('end', () => {
|
|
7572
|
+
if (remoteAccess.tokenMatches(remoteAccess.parseAuthForm(body), TOKEN)) {
|
|
7573
|
+
res.writeHead(303, { Location: '/', 'Set-Cookie': remoteAccess.sessionCookie(TOKEN), 'Cache-Control': 'no-store' });
|
|
7574
|
+
return res.end();
|
|
7575
|
+
}
|
|
7576
|
+
res.writeHead(401, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
7577
|
+
res.end(remoteAccess.signInPage({ failed: true }));
|
|
7578
|
+
});
|
|
7579
|
+
return;
|
|
7580
|
+
}
|
|
7199
7581
|
if (!remoteAccess.authorize({ remoteAddress: req.socket.remoteAddress,
|
|
7200
7582
|
presentedToken: remoteAccess.presentedToken(req), expectedToken: TOKEN })) {
|
|
7583
|
+
// A phone's browser gets a page it can act on, not JSON. API calls, scripts and anything else
|
|
7584
|
+
// that is not a page load keep the JSON 401 they had.
|
|
7585
|
+
if (navigation) {
|
|
7586
|
+
res.writeHead(401, { 'Content-Type': 'text/html; charset=utf-8', 'WWW-Authenticate': 'Bearer', 'Cache-Control': 'no-store' });
|
|
7587
|
+
return res.end(req.method === 'HEAD' ? undefined : remoteAccess.signInPage());
|
|
7588
|
+
}
|
|
7201
7589
|
res.writeHead(401, { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer' });
|
|
7202
7590
|
res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
|
|
7203
7591
|
return;
|
|
@@ -7210,8 +7598,14 @@ function httpGuard(req, res) {
|
|
|
7210
7598
|
let qTok = null;
|
|
7211
7599
|
try { qTok = new URL(req.url || '', 'http://localhost').searchParams.get('token'); } catch { /* ignore */ }
|
|
7212
7600
|
if (qTok && remoteAccess.tokenMatches(qTok.trim(), TOKEN)) {
|
|
7213
|
-
res.setHeader('Set-Cookie',
|
|
7214
|
-
|
|
7601
|
+
res.setHeader('Set-Cookie', remoteAccess.sessionCookie(TOKEN));
|
|
7602
|
+
// workspace-phone-app: on a page load, redirect to the same URL without the token, so it does
|
|
7603
|
+
// not stay in the address bar, the history, or the start_url of a home-screen install.
|
|
7604
|
+
const clean = navigation ? remoteAccess.stripTokenParam(req.url) : null;
|
|
7605
|
+
if (clean) {
|
|
7606
|
+
res.writeHead(303, { Location: clean, 'Cache-Control': 'no-store' });
|
|
7607
|
+
return res.end();
|
|
7608
|
+
}
|
|
7215
7609
|
}
|
|
7216
7610
|
}
|
|
7217
7611
|
return handleRequest(req, res);
|
|
@@ -7239,7 +7633,16 @@ function handleUpgrade(req, socket, head) {
|
|
|
7239
7633
|
socket.destroy(); // reject the upgrade; nothing about the request is logged (D08)
|
|
7240
7634
|
return;
|
|
7241
7635
|
}
|
|
7242
|
-
|
|
7636
|
+
// R25 (bind): a session started with an initiative is bound to it, a PM-only act. Only a socket the page opened with
|
|
7637
|
+
// this launch's nonce (`?pmos-nonce=`) may bind; any other socket still spawns, unbound, and is told why.
|
|
7638
|
+
const bind = pmGuard.requirePmUpgrade(req, { act: 'bind' });
|
|
7639
|
+
wss.handleUpgrade(req, socket, head, (ws) => { ws._bind = bind; wss.emit('connection', ws, req); });
|
|
7640
|
+
}
|
|
7641
|
+
/** The initiative a WS spawn may bind, or null with a notice sent when this socket may not bind (R25). */
|
|
7642
|
+
function wsBindable(ws, initiative) {
|
|
7643
|
+
if (initiative == null || initiative === '' || (ws._bind && ws._bind.ok)) return initiative;
|
|
7644
|
+
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'}` }));
|
|
7645
|
+
return null;
|
|
7243
7646
|
}
|
|
7244
7647
|
server.on('upgrade', handleUpgrade);
|
|
7245
7648
|
wss.on('connection', (ws) => {
|
|
@@ -7278,7 +7681,7 @@ wss.on('connection', (ws) => {
|
|
|
7278
7681
|
// `initiative` is what the client has always held and never sent: `+ session` is clicked on
|
|
7279
7682
|
// an initiative row, the tab carries its `slug`, and the spawn message named three other
|
|
7280
7683
|
// 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,
|
|
7684
|
+
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
7685
|
// D75 — what the composer chose (a tier, or a model), carried like the profile. Absent = not asked.
|
|
7283
7686
|
modelAsk: (m.tier || m.model) ? { explicit: { tier: m.tier, model: m.model, effort: m.effort } } : null });
|
|
7284
7687
|
// A refused profile is its own sentence, not "branch: …" — the composer's error line names
|
|
@@ -7292,7 +7695,7 @@ wss.on('connection', (ws) => {
|
|
|
7292
7695
|
// repo-first R1 — the one-click path. It carries an INITIATIVE (or none) and a repository,
|
|
7293
7696
|
// never a branch and never an isolate flag: those are the policy's to decide, and a client
|
|
7294
7697
|
// 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,
|
|
7698
|
+
const r = startWork({ initiative: wsBindable(ws, m.initiative), repoKey: m.repoKey, ws: m.ws,
|
|
7296
7699
|
agent: m.agent, name: m.name, profile: m.profile });
|
|
7297
7700
|
if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^profile /.test(r.error || '') ? '' : 'start: ') + r.error })); return; }
|
|
7298
7701
|
if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
|
|
@@ -7303,7 +7706,7 @@ wss.on('connection', (ws) => {
|
|
|
7303
7706
|
// server already holds, not re-sent by the client — a client that forgot it would otherwise
|
|
7304
7707
|
// silently restart the pane on a different environment, and the pane would look identical.
|
|
7305
7708
|
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 });
|
|
7709
|
+
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
7710
|
// A descriptor deleted since the original spawn does NOT get substituted. The respawn goes
|
|
7308
7711
|
// ahead with no profile — the pane line already says so — and the reason is said out loud
|
|
7309
7712
|
// rather than left for the PM to notice.
|
|
@@ -7343,6 +7746,9 @@ if ((HOME_ENABLED || workspaces.size) && [...sessions.values()].filter((s) => s.
|
|
|
7343
7746
|
}
|
|
7344
7747
|
|
|
7345
7748
|
if (require.main === module) {
|
|
7749
|
+
// pm-os-absorb Run 2 (R25, X11): one nonce per launch, held in memory only. Never in process.env (so no PTY inherits
|
|
7750
|
+
// it), never logged (D08). GET / templates it into the page; lib/pm-guard.js refuses a press without it.
|
|
7751
|
+
pmGuard.setLaunchNonce(crypto.randomBytes(24).toString('base64url'));
|
|
7346
7752
|
// Refuse-to-start is the D62 spine: if the requested remote bind isn't loopback or a tailnet
|
|
7347
7753
|
// address, resolveBinds returned {refused} and we exit rather than expose a PTY server publicly.
|
|
7348
7754
|
if (BIND.refused) { console.error('✗ ' + BIND.refused); process.exit(1); }
|
|
@@ -7391,7 +7797,7 @@ if (require.main === module) {
|
|
|
7391
7797
|
// old EADDRINUSE advice told the PM to do, on another port) has to leave the same trace as
|
|
7392
7798
|
// one started by `pmos` — otherwise the next `pmos` cannot see it and starts a second server.
|
|
7393
7799
|
try { fs.mkdirSync(STATE, { recursive: true }); fs.writeFileSync(PID_FILE, String(process.pid)); } catch {}
|
|
7394
|
-
for (const line of remoteAccess.startupLines(BIND, PORT)) console.log(line);
|
|
7800
|
+
for (const line of remoteAccess.startupLines(BIND, PORT, { ansi: !!process.stdout.isTTY && !process.env.NO_COLOR })) console.log(line);
|
|
7395
7801
|
});
|
|
7396
7802
|
});
|
|
7397
7803
|
}
|