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
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* pm-guard.js — THE GUARDED WRITER'S FRONT DOOR: every act only the PM may take passes here first (pm-os-absorb, PRD
|
|
3
|
+
* R25, D86 point 2).
|
|
4
|
+
*
|
|
5
|
+
* requirePmPress(req, body, { act }) → { ok: true, actor } | { ok: false, status, error }
|
|
6
|
+
* requirePmCli({ act, expect, isTTY, env, ask }) → Promise of the same shape
|
|
7
|
+
* askAtTerminal(prompt, { input, output }) → Promise<string>, one line typed at the terminal
|
|
8
|
+
* requirePagePress(req, body) → { ok: true, actor } | { ok: false, status, error } (any other page write)
|
|
9
|
+
* requirePmUpgrade(req, { act }) → the same, for a WebSocket upgrade (the nonce in the query)
|
|
10
|
+
* setLaunchNonce(v) · launchNonce() → the per-launch nonce; server.js sets it once at start (2A)
|
|
11
|
+
* NONCE_HEADER = 'x-pmos-nonce' · NONCE_META = 'pmos-nonce'
|
|
12
|
+
* PM_ONLY_ACTS · WRITER_FIELDS
|
|
13
|
+
*
|
|
14
|
+
* THE NONCE WIRE CONTRACT (frozen in the base; 2A fills the mint, never the names):
|
|
15
|
+
* - server.js calls setLaunchNonce(<random>) once at start and replaces the base placeholder
|
|
16
|
+
* `<meta name="pmos-nonce" content="">` in public/index.html with the value when it serves `/`.
|
|
17
|
+
* - public/pm/http.js pmHeaders() reads that meta once and sends it as the `x-pmos-nonce` header on every request.
|
|
18
|
+
* - Check order in BOTH press functions: act → body shape → writer fields → Origin → nonce. So a 500 (unknown act)
|
|
19
|
+
* and a 400 (writer field) never depend on the nonce.
|
|
20
|
+
* - While launchNonce() is '' (the base, before 2A), the nonce step is skipped. Once it is set, a missing or wrong
|
|
21
|
+
* `x-pmos-nonce` header is 403.
|
|
22
|
+
* - Tests reach it through test-run.js: T.nonce(BASE) reads the meta from GET / ('' before 2A), and
|
|
23
|
+
* T.pressHeaders(BASE) → { 'Content-Type', Origin: BASE, 'x-pmos-nonce' }. Every route test sends T.pressHeaders.
|
|
24
|
+
*
|
|
25
|
+
* A route that writes a PM-only line calls requirePmPress before its writer and passes the returned `actor` on; a CLI
|
|
26
|
+
* verb calls requirePmCli. The writer itself never decides that a caller is the PM. `act` must be one of PM_ONLY_ACTS,
|
|
27
|
+
* so a call site names what it guards and the list below stays the whole R25 surface.
|
|
28
|
+
*
|
|
29
|
+
* FILLED BY RUN 2 (slice 2A). The page side refuses, in this order:
|
|
30
|
+
* - an unknown act: 500 (a programming error; an unguarded default is the defect this file exists to prevent);
|
|
31
|
+
* - a body that is not an object, or that carries a writer field: 400;
|
|
32
|
+
* - no Origin header, an Origin that is not http(s), or an Origin that is not this request's own Host (another site,
|
|
33
|
+
* another port, so another launch): 403. A socket request with no Host header is refused too, because there is
|
|
34
|
+
* nothing to match. Only a caller with no socket and no Host (a unit test's plain object) skips the Host match;
|
|
35
|
+
* - once server.js has called setLaunchNonce, a missing or wrong x-pmos-nonce header: 403.
|
|
36
|
+
* The terminal side refuses an agent session (403, without asking), no interactive terminal (403), and a typed text
|
|
37
|
+
* that does not match `expect` (403). With no `ask` function there is no way to confirm, so the act is refused (X12).
|
|
38
|
+
*
|
|
39
|
+
* RESIDUAL (named, not closed). A process that runs as the PM's own user can read the nonce from GET / and send it with
|
|
40
|
+
* an Origin that matches. A process that fakes a terminal (`script`) and types the expected id is the PM to this file.
|
|
41
|
+
* The guard closes the paths an agent's tools take by default; CI detection (run2-b) is the backstop for the rest.
|
|
42
|
+
*/
|
|
43
|
+
const readline = require('readline');
|
|
44
|
+
const actor = require('./actor.js');
|
|
45
|
+
|
|
46
|
+
/** Every act only the PM may take. One word each; the R25 list plus D86 point 2. */
|
|
47
|
+
const PM_ONLY_ACTS = Object.freeze([
|
|
48
|
+
'accept', // the Acceptance Gate, the review approve and return, a document approval (/api/gate)
|
|
49
|
+
'lifecycle', // hold, close, cancel, reopen (/api/lifecycle)
|
|
50
|
+
'keep', // Keep on an agent batch (/api/plan-items kept)
|
|
51
|
+
'now', // Move to Now (placements)
|
|
52
|
+
'adopt', // adopt an agent's placement proposal
|
|
53
|
+
'anchor', // change parent_kr (the record, /api/doc/save, /api/align/apply, /api/ingest/confirm)
|
|
54
|
+
'bind', // bind a session to an initiative (/api/session/initiative)
|
|
55
|
+
'issue', // Issue a report (issues.jsonl)
|
|
56
|
+
'release', // the D65 Release of a factory batch (one grant per batch)
|
|
57
|
+
]);
|
|
58
|
+
/** What each act is, as the refusal says it: "only the PM can <phrase>". */
|
|
59
|
+
const ACT_PHRASE = Object.freeze({
|
|
60
|
+
accept: 'accept or return work',
|
|
61
|
+
lifecycle: 'hold, close, cancel or reopen an initiative',
|
|
62
|
+
keep: 'keep an agent\'s items',
|
|
63
|
+
now: 'move an item to Now',
|
|
64
|
+
adopt: 'adopt a proposal',
|
|
65
|
+
anchor: 'change an anchor',
|
|
66
|
+
bind: 'bind a session to an initiative',
|
|
67
|
+
issue: 'issue a report',
|
|
68
|
+
release: 'release a batch',
|
|
69
|
+
});
|
|
70
|
+
/** Fields only a writer sets. A body that carries one is refused, even with the value the writer would choose. */
|
|
71
|
+
const WRITER_FIELDS = Object.freeze(['v', 'eid', 'by', 'via', 'at', 'override', 'pm', 'actor', 'nonce', 'grant']);
|
|
72
|
+
|
|
73
|
+
const NONCE_HEADER = 'x-pmos-nonce';
|
|
74
|
+
const NONCE_META = 'pmos-nonce';
|
|
75
|
+
let NONCE = '';
|
|
76
|
+
/** server.js calls this once at start (2A). The value never goes into a PTY environment or a log (D08). */
|
|
77
|
+
function setLaunchNonce(v) { NONCE = typeof v === 'string' ? v : ''; }
|
|
78
|
+
function launchNonce() { return NONCE; }
|
|
79
|
+
|
|
80
|
+
const refuse = (status, error) => ({ ok: false, status, error });
|
|
81
|
+
const knownAct = (act) => typeof act === 'string' && PM_ONLY_ACTS.includes(act);
|
|
82
|
+
|
|
83
|
+
function requirePmPress(req, body, { act } = {}) {
|
|
84
|
+
if (!knownAct(act)) return refuse(500, `pm-guard: ${JSON.stringify(act)} is not a PM-only act`);
|
|
85
|
+
const bad = bodyChecks(body);
|
|
86
|
+
if (bad) return bad;
|
|
87
|
+
return pageChecks(req, `only the PM can ${ACT_PHRASE[act]}, from the page`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Body shape and writer fields; shared by both press functions. Null when the body passes. */
|
|
91
|
+
function bodyChecks(body) {
|
|
92
|
+
const b = body == null ? {} : body;
|
|
93
|
+
if (typeof b !== 'object' || Array.isArray(b)) return refuse(400, 'the body must be a JSON object');
|
|
94
|
+
const sent = WRITER_FIELDS.find((k) => Object.prototype.hasOwnProperty.call(b, k));
|
|
95
|
+
if (sent) return refuse(400, `field ${sent} is set by the writer`);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Origin, then the nonce. ONE function for both press functions, so a page write and a PM-only press can never differ in
|
|
101
|
+
* what they accept. The Origin must name this request's own Host: the page this launch served sends exactly that.
|
|
102
|
+
*/
|
|
103
|
+
function pageChecks(req, who) {
|
|
104
|
+
const h = (req && req.headers) || {};
|
|
105
|
+
if (!h.origin) return refuse(403, `${who}; this request has no Origin header`);
|
|
106
|
+
if (!sameLaunchOrigin(h.origin, h.host, !!(req && req.socket))) return refuse(403, `${who}; this request comes from another origin`);
|
|
107
|
+
// A wrong value is almost always a page from an earlier start of the workspace; a missing one is not the page at all.
|
|
108
|
+
if (NONCE && !h[NONCE_HEADER]) return refuse(403, `${who}; this request does not come from the workspace page`);
|
|
109
|
+
if (NONCE && h[NONCE_HEADER] !== NONCE) return refuse(403, `${who}; the workspace restarted, so reload the page, then try again`);
|
|
110
|
+
return { ok: true, actor: actor.fromUi() };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** True when `origin` is an http(s) origin and names `host` (the request's Host header). See the header for no Host. */
|
|
114
|
+
function sameLaunchOrigin(origin, host, fromSocket) {
|
|
115
|
+
let u;
|
|
116
|
+
try { u = new URL(String(origin)); } catch { return false; }
|
|
117
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
|
|
118
|
+
if (u.origin !== String(origin).replace(/\/$/, '')) return false; // an origin has no path, query or user part
|
|
119
|
+
if (typeof host !== 'string' || !host) return !fromSocket;
|
|
120
|
+
return u.host === host.trim().toLowerCase();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Every page write that is NOT a PM-only act (capture, Next/Later placements, a new initiative, a measurement, a report
|
|
125
|
+
* note or draft): the same body, Origin and nonce checks, no act. Route modules call this and never hand-roll the checks.
|
|
126
|
+
*/
|
|
127
|
+
function requirePagePress(req, body) {
|
|
128
|
+
const bad = bodyChecks(body);
|
|
129
|
+
if (bad) return bad;
|
|
130
|
+
return pageChecks(req, 'this write comes from the page only');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The WebSocket form of requirePmPress. A browser WebSocket cannot set a header, so the page sends the nonce as the
|
|
135
|
+
* `pmos-nonce` query parameter of the upgrade URL. The Origin and nonce rules are pageChecks' own, so a socket and a
|
|
136
|
+
* press can never differ in what they accept. server.js calls it once per upgrade and keeps the answer on the socket.
|
|
137
|
+
*/
|
|
138
|
+
function requirePmUpgrade(req, { act } = {}) {
|
|
139
|
+
if (!knownAct(act)) return refuse(500, `pm-guard: ${JSON.stringify(act)} is not a PM-only act`);
|
|
140
|
+
const h = (req && req.headers) || {};
|
|
141
|
+
let q = '';
|
|
142
|
+
try { q = new URL(String((req && req.url) || '/'), 'http://upgrade.invalid').searchParams.get(NONCE_META) || ''; } catch { q = ''; }
|
|
143
|
+
return pageChecks({ headers: { ...h, [NONCE_HEADER]: q }, socket: req && req.socket }, `only the PM can ${ACT_PHRASE[act]}, from the page`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function requirePmCli({ act, expect, isTTY, env, ask } = {}) {
|
|
147
|
+
if (!knownAct(act)) return refuse(500, `pm-guard: ${JSON.stringify(act)} is not a PM-only act`);
|
|
148
|
+
if (typeof expect !== 'string' || !expect.trim()) return refuse(500, 'pm-guard: a PM-only verb needs the text the PM must type');
|
|
149
|
+
const who = actor.fromCli({ isTTY, env });
|
|
150
|
+
if (actor.isAgent(who) && who.by !== 'agent:unknown') return refuse(403, `an agent session cannot ${ACT_PHRASE[act]}; ask the PM to do it`);
|
|
151
|
+
if (!actor.isPm(who)) return refuse(403, `only the PM can ${ACT_PHRASE[act]}, at an interactive terminal`);
|
|
152
|
+
if (typeof ask !== 'function') return refuse(403, `to ${ACT_PHRASE[act]}, type a confirmation at the terminal`);
|
|
153
|
+
let typed;
|
|
154
|
+
try { typed = await ask(`Type ${expect} to ${ACT_PHRASE[act]}: `); } catch { typed = null; }
|
|
155
|
+
if (typeof typed !== 'string' || typed.trim() !== expect.trim()) return refuse(403, `the typed text did not match ${expect}; nothing was written`);
|
|
156
|
+
return { ok: true, actor: who };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** One line from the terminal. The prompt goes to stderr, so `--json` output on stdout stays parseable. */
|
|
160
|
+
function askAtTerminal(prompt, { input = process.stdin, output = process.stderr } = {}) {
|
|
161
|
+
return new Promise((resolve) => {
|
|
162
|
+
const rl = readline.createInterface({ input, output, terminal: false });
|
|
163
|
+
let done = false;
|
|
164
|
+
const finish = (v) => { if (done) return; done = true; rl.close(); resolve(v); };
|
|
165
|
+
rl.once('line', (line) => finish(String(line)));
|
|
166
|
+
rl.once('close', () => finish(null));
|
|
167
|
+
output.write(prompt);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = {
|
|
172
|
+
PM_ONLY_ACTS, WRITER_FIELDS, NONCE_HEADER, NONCE_META,
|
|
173
|
+
requirePmPress, requirePagePress, requirePmUpgrade, requirePmCli, askAtTerminal, setLaunchNonce, launchNonce,
|
|
174
|
+
};
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* pm-lines-needs.js — a PM-only line that is not attested: the one rule, and its needs-repair rows on Today
|
|
3
|
+
* (pm-os-absorb Run 2, slice 2B; PRD R25(4), R12 kind 8; D86 point 2 item 6; map-spec X10).
|
|
4
|
+
*
|
|
5
|
+
* scan(root, { runsDir, rules, rulesFile, now, arrival })
|
|
6
|
+
* → { cutover, cutoverCommit, flags: [flag…], older: [{ ledger, rel, count }], shallow }
|
|
7
|
+
* rowsFor(w, { lead }) → [{ run_id, kind: 'pm-line', initiative_id, question, doc, today }]
|
|
8
|
+
* sig(w) → string
|
|
9
|
+
* kinds = ['pm-line'] SOURCES · AGENT_COMMIT · isAgentCommit(message, email)
|
|
10
|
+
*
|
|
11
|
+
* A flag is { kind, ledger, rel, n, initiative, cause, message }. The kinds:
|
|
12
|
+
* no-via a PM-only line at or after `via_required_from` (planning/plan-rules.json) that has no `via`;
|
|
13
|
+
* unknown-grant a PM-only line that cites a grant (`grant`, or `via: 'grant'`) that grants.jsonl does not hold;
|
|
14
|
+
* grant-mismatch a PM-only line that cites a grant that does not cover it: a lifecycle line needs a lifecycle-bulk
|
|
15
|
+
* grant whose target names its state and its initiative, and every line's `at` falls inside the grant's
|
|
16
|
+
* window (at to expires_at). A copied id of a merge grant, or of another batch, is flagged;
|
|
17
|
+
* not-pm a PM-only line in a D86 ledger whose `by` is not the PM's;
|
|
18
|
+
* agent-commit a PM-only line that arrived in an agent-authored commit (a Co-Authored-By trailer that names an agent,
|
|
19
|
+
* a session trailer, or an agent's address). This catches a line written by a script and then committed
|
|
20
|
+
* (bypass e) and a patch applied with `git apply` (bypass f).
|
|
21
|
+
* agent-basis an agent's acceptance (D87) that the Review Gate does not back: no eval row for its run, a verdict that
|
|
22
|
+
* is not PASS or PASS-WITH-FINDINGS, a score under the row's pass_threshold, a `basis` that does not match
|
|
23
|
+
* the latest row, a pm_label, no via, or an action other than accept.
|
|
24
|
+
* A line older than `via_required_from` is "older": it is counted once per ledger in `older` and never flagged. A line
|
|
25
|
+
* counts as older only when BOTH its own time is older AND the commit that added it does NOT DESCEND from the cut-over
|
|
26
|
+
* commit: the first commit whose rules file sets `via_required_from` (git history, not dates). A commit on a parallel
|
|
27
|
+
* branch that was merged in after the cut-over does not descend from it, so a merge alone does not make its lines new. A line's `at` and a
|
|
28
|
+
* commit's dates are the writer's to choose (GIT_COMMITTER_DATE), so neither alone makes a line older; a commit that is
|
|
29
|
+
* that descends from the cut-over commit is checked in full, whatever its dates say. A line's time is its `at` or
|
|
30
|
+
* `date`, or the `accepted_at`, `recorded_at` or `created_at` of a line written before Run 2; a line with none is older
|
|
31
|
+
* only when its commit is (an uncommitted line with no time is checked). While no commit sets the rules
|
|
32
|
+
* file (the value is set but not committed yet), every committed line comes before the cut-over. While
|
|
33
|
+
* `via_required_from` is null (before Run 2 merges), every line is older, and the arrival check does not run.
|
|
34
|
+
*
|
|
35
|
+
* SHALLOW HISTORY. In a shallow clone, `git blame` gives every line older than the clone's edge to the edge commit, so
|
|
36
|
+
* the arrival check cannot see who added it. `shallow` is true when `git rev-parse --is-shallow-repository` says so;
|
|
37
|
+
* scripts/check-pm-lines.js then FAILS (the gate needs fetch-depth: 0) while via_required_from is set. A root commit
|
|
38
|
+
* of a full clone is not an edge: blame runs with --root.
|
|
39
|
+
*
|
|
40
|
+
* WHICH LINES ARE PM-ONLY. Each D86 ledger's writer answers through its own `pmOnly(rec)`; this file never reads a
|
|
41
|
+
* ledger schema. Keep (`kept` in plan-items.jsonl) is PM-only; plan-items.js keeps its own rule for it, so a kept line is
|
|
42
|
+
* checked here for its arrival only, and scripts/check-plan-items.js fails a kept line that is not from the page or not
|
|
43
|
+
* by the PM. Every line of acceptances.jsonl (acceptance, review approve and return, document approval) and of
|
|
44
|
+
* lifecycle.jsonl is PM-only (R25), except an agent's acceptance of a run (D87): a line whose `by` is `agent:<session>`
|
|
45
|
+
* and which carries a `basis`. That line is not the PM's, so its arrival is not checked; the Review Gate row it names is
|
|
46
|
+
* (agentBasis below). An agent line WITHOUT a basis stays PM-only and is flagged as before.
|
|
47
|
+
*
|
|
48
|
+
* TWO CALLERS: the needs queue (lib/needs-providers.js, so Today shows a kind-8 card for each flag) and the CI checker
|
|
49
|
+
* scripts/check-pm-lines.js, which passes the runs directory of the tree it checks. Each reads the same rule here.
|
|
50
|
+
* The arrival check runs `git blame` and `git log` in the repository of the runs directory: local reads, no network.
|
|
51
|
+
*
|
|
52
|
+
* HONEST LIMIT. This is detection after the fact. A same-user process can still write a line with `via` and `by` set as
|
|
53
|
+
* the PM's and commit it without an agent trailer; nothing here can tell that line from a real one (R25 residual). An
|
|
54
|
+
* uncommitted line is checked for `via`, its grant and its `by`, and for its arrival only once it is committed.
|
|
55
|
+
*/
|
|
56
|
+
const fs = require('fs');
|
|
57
|
+
const path = require('path');
|
|
58
|
+
const crypto = require('crypto');
|
|
59
|
+
const { execFileSync } = require('child_process');
|
|
60
|
+
const ledgers = require('./plan-ledgers.js');
|
|
61
|
+
const planTree = require('./plan-tree.js');
|
|
62
|
+
const planRules = require('./plan-rules.js');
|
|
63
|
+
const placements = require('./placements.js');
|
|
64
|
+
const measurements = require('./measurements.js');
|
|
65
|
+
const issues = require('./issues.js');
|
|
66
|
+
|
|
67
|
+
const KIND = 'pm-line';
|
|
68
|
+
const QUESTION = 'A PM-only line is not attested';
|
|
69
|
+
|
|
70
|
+
const what = {
|
|
71
|
+
'plan-items': () => 'A Keep line',
|
|
72
|
+
placements: (r) => `A placed line into ${r.horizon === 'now' ? 'Now' : String(r.horizon || 'a horizon')}`,
|
|
73
|
+
measurements: () => 'A measured line',
|
|
74
|
+
issues: () => 'An issued line',
|
|
75
|
+
acceptances: () => 'An acceptance line',
|
|
76
|
+
lifecycle: () => 'A lifecycle line',
|
|
77
|
+
};
|
|
78
|
+
/** The ledgers that hold PM-only lines, and the rule each one answers. */
|
|
79
|
+
const SOURCES = Object.freeze([
|
|
80
|
+
{ ledger: 'plan-items', file: ledgers.LEDGERS['plan-items'].file, pmOnly: (r) => r.ev === 'kept', viaChecked: false },
|
|
81
|
+
{ ledger: 'placements', file: ledgers.LEDGERS.placements.file, pmOnly: placements.pmOnly, viaChecked: true },
|
|
82
|
+
{ ledger: 'measurements', file: ledgers.LEDGERS.measurements.file, pmOnly: measurements.pmOnly, viaChecked: true },
|
|
83
|
+
{ ledger: 'issues', file: ledgers.LEDGERS.issues.file, pmOnly: issues.pmOnly, viaChecked: true },
|
|
84
|
+
{ ledger: 'acceptances', file: 'acceptances.jsonl', pmOnly: (r) => !isAgentAcceptance(r), viaChecked: true },
|
|
85
|
+
{ ledger: 'lifecycle', file: 'lifecycle.jsonl', pmOnly: () => true, viaChecked: true },
|
|
86
|
+
]);
|
|
87
|
+
const D86 = new Set(['placements', 'measurements', 'issues']);
|
|
88
|
+
|
|
89
|
+
// ── D87: an agent's acceptance of a run ─────────────────────────────────────────────────────────────────────────────
|
|
90
|
+
/** An agent's acceptance (D87): written by an agent session, and naming the Review Gate result it rests on. */
|
|
91
|
+
const isAgentAcceptance = (r) => !!r && typeof r.by === 'string' && r.by.startsWith('agent:') && !!r.basis && typeof r.basis === 'object';
|
|
92
|
+
const PASSING = new Set(['PASS', 'PASS-WITH-FINDINGS']);
|
|
93
|
+
/** Why the agent acceptance `rec` is not backed by `evalRows` (evals.jsonl, oldest first), or null when it is. */
|
|
94
|
+
function agentBasisProblem(rec, evalRows) {
|
|
95
|
+
if (rec.accepted !== true || (rec.action !== undefined && rec.action !== 'accept')) return 'an agent may only accept; a return or a refusal is the PM\'s';
|
|
96
|
+
if (rec.pm_label !== undefined && rec.pm_label !== null) return 'an agent acceptance carries no pm_label: only the PM labels the evaluator (D40)';
|
|
97
|
+
if (typeof rec.via !== 'string' || !rec.via.trim()) return 'it has no via';
|
|
98
|
+
const rows = evalRows.filter((e) => e.run_id === rec.run_id);
|
|
99
|
+
if (!rows.length) return `evals.jsonl has no Review Gate row for run ${JSON.stringify(rec.run_id)}`;
|
|
100
|
+
const e = rows[rows.length - 1]; // the latest verdict for the run is the one that counts
|
|
101
|
+
if (!PASSING.has(e.verdict)) return `the latest Review Gate verdict for the run is ${JSON.stringify(e.verdict)}`;
|
|
102
|
+
if (typeof e.score !== 'number' || typeof e.pass_threshold !== 'number') return 'the latest Review Gate row names no score or pass_threshold';
|
|
103
|
+
if (e.score < e.pass_threshold) return `the latest Review Gate score ${e.score} is under its pass_threshold ${e.pass_threshold}`;
|
|
104
|
+
const b = rec.basis;
|
|
105
|
+
if (b.verdict !== e.verdict || b.score !== e.score || b.pass_threshold !== e.pass_threshold) return 'its basis does not match the latest Review Gate row for the run';
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// An agent-authored commit: a trailer that names an agent, or an agent's address. One list, used by CI and by Today.
|
|
110
|
+
const AGENT_COMMIT = /^(?:co-authored-by:[^\n]*\b(?:claude|copilot|codex|cursor|devin|gemini|aider|agent)\b|claude-session:|pmos-session:)/im;
|
|
111
|
+
const AGENT_EMAIL = /noreply@anthropic\.com|\[bot\]@users\.noreply\.github\.com/i;
|
|
112
|
+
const isAgentCommit = (message, email) => AGENT_COMMIT.test(String(message || '')) || AGENT_EMAIL.test(String(email || ''));
|
|
113
|
+
|
|
114
|
+
function git(cwd, args) {
|
|
115
|
+
try { return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 }); }
|
|
116
|
+
catch { return null; }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readJsonl(file) {
|
|
120
|
+
let text;
|
|
121
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return null; }
|
|
122
|
+
const out = [];
|
|
123
|
+
text.split('\n').forEach((raw, i) => {
|
|
124
|
+
if (!raw.trim()) return;
|
|
125
|
+
let rec = null;
|
|
126
|
+
try { rec = JSON.parse(raw); } catch { return; } // a torn line is its own checker's defect
|
|
127
|
+
if (rec && typeof rec === 'object' && !Array.isArray(rec)) out.push({ n: i + 1, rec });
|
|
128
|
+
});
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* For each line number of `file`: the commit that added it, as { sha, time (ms), agent, boundary }, or null for a line
|
|
134
|
+
* that is not committed. One `git blame` and one `git log` per file. Null when the file is not in a git repository.
|
|
135
|
+
*/
|
|
136
|
+
function arrivals(file) {
|
|
137
|
+
const dir = path.dirname(file);
|
|
138
|
+
const out = git(dir, ['blame', '--root', '--line-porcelain', '--', path.basename(file)]);
|
|
139
|
+
if (out === null) return null;
|
|
140
|
+
const byLine = new Map();
|
|
141
|
+
const meta = new Map();
|
|
142
|
+
let cur = null;
|
|
143
|
+
for (const l of out.split('\n')) {
|
|
144
|
+
const h = /^([0-9a-f]{40}) \d+ (\d+)/.exec(l);
|
|
145
|
+
if (h) { cur = { sha: h[1], n: Number(h[2]) }; if (!meta.has(h[1])) meta.set(h[1], { boundary: false, time: null }); continue; }
|
|
146
|
+
if (!cur) continue;
|
|
147
|
+
if (l === 'boundary') meta.get(cur.sha).boundary = true;
|
|
148
|
+
else if (l.startsWith('committer-time ')) meta.get(cur.sha).time = Number(l.slice(15)) * 1000;
|
|
149
|
+
else if (l.startsWith('\t')) { byLine.set(cur.n, cur.sha); cur = null; }
|
|
150
|
+
}
|
|
151
|
+
const shas = [...meta.keys()].filter((s) => !/^0+$/.test(s));
|
|
152
|
+
if (shas.length) {
|
|
153
|
+
const log = git(dir, ['log', '--no-walk=unsorted', '--format=%H%x1f%ae%x1f%B%x1e', ...shas]) || '';
|
|
154
|
+
for (const rec of log.split('\x1e')) {
|
|
155
|
+
const [sha, email, body] = rec.replace(/^\n/, '').split('\x1f');
|
|
156
|
+
if (meta.has(sha)) meta.get(sha).agent = isAgentCommit(body, email);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const res = new Map();
|
|
160
|
+
for (const [n, sha] of byLine) res.set(n, /^0+$/.test(sha) ? null : { sha, ...meta.get(sha) });
|
|
161
|
+
return res;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Is the repository of `dir` a shallow clone? */
|
|
165
|
+
const isShallow = (dir) => (git(dir, ['rev-parse', '--is-shallow-repository']) || '').trim() === 'true';
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The cut-over commit: the oldest commit whose `rulesFile` sets via_required_from, and the set of its descendants (itself
|
|
169
|
+
* included). { sha: null, descendants: null } when no commit sets it (every committed line comes before the cut-over) or
|
|
170
|
+
* when there is no git history to read.
|
|
171
|
+
*/
|
|
172
|
+
function cutoverCommit(rulesFile) {
|
|
173
|
+
const none = { sha: null, descendants: null };
|
|
174
|
+
if (!rulesFile) return none;
|
|
175
|
+
const dir = path.dirname(rulesFile);
|
|
176
|
+
const top = (git(dir, ['rev-parse', '--show-toplevel']) || '').trim();
|
|
177
|
+
if (!top) return none;
|
|
178
|
+
let rel;
|
|
179
|
+
try { rel = path.relative(top, fs.realpathSync(rulesFile)).split(path.sep).join('/'); } catch { return none; }
|
|
180
|
+
const shas = (git(top, ['log', '--format=%H', '--reverse', '--', rel]) || '').split('\n').filter(Boolean);
|
|
181
|
+
for (const sha of shas) {
|
|
182
|
+
let v = null;
|
|
183
|
+
try { v = JSON.parse(git(top, ['show', `${sha}:${rel}`]) || 'null'); } catch { v = null; }
|
|
184
|
+
if (v && typeof v.via_required_from === 'string') {
|
|
185
|
+
// Descendants of the cut-over commit (itself included). A commit that is not one is before the cut-over: an
|
|
186
|
+
// ancestor, or a commit on a parallel branch made before it and merged in later (a merge does not make it new).
|
|
187
|
+
const desc = (git(top, ['rev-list', '--ancestry-path', `${sha}..HEAD`]) || '').split('\n').filter(Boolean);
|
|
188
|
+
return { sha, descendants: new Set([sha, ...desc]) };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return none;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const BULK_STATE = /^(active|closed|on-hold|cancelled):(.+)$/;
|
|
195
|
+
/** Why the grant `g` does not cover the PM-only line `rec` of `ledger`, or null when it does. */
|
|
196
|
+
function grantMismatch(ledger, rec, g) {
|
|
197
|
+
const t = timeOf(rec);
|
|
198
|
+
const from = Date.parse(g.at || '');
|
|
199
|
+
const to = Date.parse(g.expires_at || '');
|
|
200
|
+
if (t === null || Number.isNaN(from) || Number.isNaN(to) || t < from || t > to) return `its at is outside the window of grant ${g.id} (${g.at} to ${g.expires_at})`;
|
|
201
|
+
if (ledger !== 'lifecycle') return null;
|
|
202
|
+
if (g.scope !== 'lifecycle-bulk') return `grant ${g.id} has scope ${JSON.stringify(g.scope)}, and a lifecycle line needs lifecycle-bulk`;
|
|
203
|
+
const m = BULK_STATE.exec(String(g.target || ''));
|
|
204
|
+
if (!m || m[1] !== rec.state || !m[2].split(',').includes(initiativeOf(rec))) return `grant ${g.id} covers ${JSON.stringify(g.target)}, not ${rec.state}:${initiativeOf(rec)}`;
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// A line's own time. Lines written before Run 2 name it `created_at`, `recorded_at` or `accepted_at`.
|
|
209
|
+
const TIME_KEYS = ['at', 'date', 'accepted_at', 'recorded_at', 'created_at'];
|
|
210
|
+
const timeOf = (rec) => {
|
|
211
|
+
for (const k of TIME_KEYS) { const t = typeof rec[k] === 'string' ? Date.parse(rec[k]) : NaN; if (!Number.isNaN(t)) return t; }
|
|
212
|
+
return null;
|
|
213
|
+
};
|
|
214
|
+
const initiativeOf = (rec) => (typeof rec.initiative === 'string' ? rec.initiative : typeof rec.initiative_id === 'string' ? rec.initiative_id : null);
|
|
215
|
+
|
|
216
|
+
/** The one rule. `runsDir` defaults to the runs directory of the main tree of `root` (lib/plan-ledgers.js). */
|
|
217
|
+
function scan(root, { runsDir, rules, rulesFile, now = Date.now(), arrival = true } = {}) {
|
|
218
|
+
const dir = runsDir || ledgers.runsDir(root);
|
|
219
|
+
const r = rules || planRules.readRules(root);
|
|
220
|
+
const cutover = typeof r.via_required_from === 'string' ? Date.parse(r.via_required_from) : null;
|
|
221
|
+
const grantRows = readJsonl(path.join(dir, 'grants.jsonl')) || [];
|
|
222
|
+
const grants = new Map(grantRows.filter((g) => g.rec.type === 'grant' && typeof g.rec.id === 'string').map((g) => [g.rec.id, g.rec]));
|
|
223
|
+
const checkArrival = cutover !== null && arrival;
|
|
224
|
+
const shallow = checkArrival && isShallow(dir);
|
|
225
|
+
const cut = checkArrival ? cutoverCommit(rulesFile === undefined ? planRules.rulesPath(root) : rulesFile) : { sha: null, descendants: null };
|
|
226
|
+
const base = runsDir ? path.resolve(String(root)) : planTree.mainTree(path.resolve(String(root))); // the paths a row names
|
|
227
|
+
const flags = [];
|
|
228
|
+
const older = [];
|
|
229
|
+
for (const src of SOURCES) {
|
|
230
|
+
const file = path.join(dir, src.file);
|
|
231
|
+
const lines = readJsonl(file);
|
|
232
|
+
if (!lines) continue;
|
|
233
|
+
const rel = path.relative(base, file).split(path.sep).join('/') || src.file;
|
|
234
|
+
const pm = lines.filter((l) => { try { return !!src.pmOnly(l.rec); } catch { return false; } });
|
|
235
|
+
if (!pm.length) continue;
|
|
236
|
+
const arr = checkArrival ? arrivals(file) : null;
|
|
237
|
+
let olderCount = 0;
|
|
238
|
+
for (const { n, rec } of pm) {
|
|
239
|
+
const t = timeOf(rec);
|
|
240
|
+
const a = arr ? arr.get(n) : null;
|
|
241
|
+
// Ancestry, not dates: an uncommitted line, a line at a shallow edge, and every commit while no commit sets the
|
|
242
|
+
// rules file come before the cut-over; any other commit must not descend from the cut-over commit.
|
|
243
|
+
const commitOld = cutover === null || !a || a.boundary || cut.descendants === null || !cut.descendants.has(a.sha);
|
|
244
|
+
// A line with no time of its own is older only when a commit before the cut-over added it: an uncommitted line
|
|
245
|
+
// with no time is a hand write, and is checked.
|
|
246
|
+
const lineOld = cutover === null || (t !== null ? t < cutover : !!(a && !a.boundary && commitOld));
|
|
247
|
+
const isOlder = lineOld && (commitOld || !arr);
|
|
248
|
+
const causes = [];
|
|
249
|
+
const say = (kind, cause, message) => causes.push({ kind, cause, message });
|
|
250
|
+
const viaMissing = typeof rec.via !== 'string' || !rec.via.trim();
|
|
251
|
+
if (src.viaChecked && viaMissing && !isOlder) say('no-via', `${what[src.ledger](rec)} has no via`, `written after via_required_from with no via`);
|
|
252
|
+
const cites = typeof rec.grant === 'string' ? rec.grant : rec.via === 'grant' ? '' : null;
|
|
253
|
+
if (cites !== null && !grants.has(cites)) {
|
|
254
|
+
say('unknown-grant', `${what[src.ledger](rec)} cites a grant that grants.jsonl does not hold`, `cites grant ${JSON.stringify(cites)}, which is not in grants.jsonl`);
|
|
255
|
+
} else if (cites !== null) {
|
|
256
|
+
const why = grantMismatch(src.ledger, rec, grants.get(cites));
|
|
257
|
+
if (why) say('grant-mismatch', `${what[src.ledger](rec)} cites a grant that does not cover it`, why);
|
|
258
|
+
}
|
|
259
|
+
if (D86.has(src.ledger) && typeof rec.by === 'string' && !rec.by.startsWith('pm:')) {
|
|
260
|
+
say('not-pm', `${what[src.ledger](rec)} is written by an agent`, 'a PM-only line whose by is not the PM');
|
|
261
|
+
}
|
|
262
|
+
if (a && !a.boundary && a.agent && !isOlder) {
|
|
263
|
+
say('agent-commit', `${what[src.ledger](rec)} arrived in an agent-authored commit`, `arrived in the agent-authored commit ${a.sha.slice(0, 8)}`);
|
|
264
|
+
}
|
|
265
|
+
if (isOlder && viaMissing && src.viaChecked) olderCount++;
|
|
266
|
+
for (const c of causes) flags.push({ ...c, ledger: src.ledger, rel, n, initiative: initiativeOf(rec), eid: typeof rec.eid === 'string' ? rec.eid : null });
|
|
267
|
+
}
|
|
268
|
+
if (olderCount) older.push({ ledger: src.ledger, rel, count: olderCount });
|
|
269
|
+
}
|
|
270
|
+
// D87: each agent acceptance must rest on a passing Review Gate row for its run
|
|
271
|
+
const accFile = path.join(dir, 'acceptances.jsonl');
|
|
272
|
+
const agentLines = (readJsonl(accFile) || []).filter((l) => isAgentAcceptance(l.rec));
|
|
273
|
+
if (agentLines.length) {
|
|
274
|
+
const evalRows = (readJsonl(path.join(dir, 'evals.jsonl')) || []).map((l) => l.rec);
|
|
275
|
+
const rel = path.relative(base, accFile).split(path.sep).join('/') || 'acceptances.jsonl';
|
|
276
|
+
for (const { n, rec } of agentLines) {
|
|
277
|
+
const why = agentBasisProblem(rec, evalRows);
|
|
278
|
+
if (why) flags.push({ kind: 'agent-basis', cause: 'An agent acceptance is not backed by the Review Gate', message: why, ledger: 'acceptances', rel, n, initiative: initiativeOf(rec), eid: null });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return { cutover: cutover === null ? null : new Date(cutover).toISOString(), cutoverCommit: cut.sha, flags, older, shallow, runsDir: dir, now };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── the needs provider (lib/needs-providers.js) ─────────────────────────────────────────────────────────────────────
|
|
285
|
+
// A scan runs git (blame, rev-list) over the whole history. server.js asks for every registered tree on each needs
|
|
286
|
+
// poll, so the rows are kept per root until `sig` (every input: ledgers, grants, rules, HEAD's reflog) changes.
|
|
287
|
+
const rowsCache = new Map(); // root -> { sig, rows }
|
|
288
|
+
function rowsFor(w, { lead = true } = {}) {
|
|
289
|
+
if (!w || !w.root || !lead) return []; // repository-wide rows: only the lead tree speaks for the ledgers
|
|
290
|
+
let key = null;
|
|
291
|
+
try { key = sig(w); } catch { key = null; }
|
|
292
|
+
const hit = rowsCache.get(w.root);
|
|
293
|
+
if (key && hit && hit.sig === key) return hit.rows.map((r) => ({ ...r, today: { ...r.today } }));
|
|
294
|
+
const rows = rowsFresh(w);
|
|
295
|
+
if (key) rowsCache.set(w.root, { sig: key, rows });
|
|
296
|
+
return rows.map((r) => ({ ...r, today: { ...r.today } }));
|
|
297
|
+
}
|
|
298
|
+
function rowsFresh(w) {
|
|
299
|
+
const s = scan(w.root);
|
|
300
|
+
const byLine = new Map();
|
|
301
|
+
for (const f of s.flags) {
|
|
302
|
+
const key = `${f.rel}:${f.n}`;
|
|
303
|
+
if (!byLine.has(key)) byLine.set(key, { first: f, causes: [] });
|
|
304
|
+
byLine.get(key).causes.push(f.cause);
|
|
305
|
+
}
|
|
306
|
+
return [...byLine.values()].map(({ first, causes }) => ({
|
|
307
|
+
run_id: `pm-lines-needs:${first.eid || `${first.ledger}-${first.n}`}`,
|
|
308
|
+
kind: KIND,
|
|
309
|
+
initiative_id: first.initiative,
|
|
310
|
+
question: QUESTION,
|
|
311
|
+
doc: null,
|
|
312
|
+
today: { kind: 8, source: 'pm-lines-needs', cause: causes.join('; '), evidence: `${first.rel} line ${first.n}`, act: { label: 'Show line', cli: null } },
|
|
313
|
+
}));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Changes whenever a row may: every ledger read, grants.jsonl, the rules file and the git history (a commit). */
|
|
317
|
+
function sig(w) {
|
|
318
|
+
if (!w || !w.root) return '';
|
|
319
|
+
const parts = [];
|
|
320
|
+
const stat = (f) => { try { const st = fs.statSync(f); return `${st.size}:${st.mtimeMs}:${st.ino}`; } catch { return 'none'; } };
|
|
321
|
+
let dir = null;
|
|
322
|
+
try { dir = ledgers.runsDir(w.root); } catch { return 'engine-missing'; }
|
|
323
|
+
for (const src of SOURCES) parts.push(src.file + '=' + stat(path.join(dir, src.file)));
|
|
324
|
+
parts.push('evals=' + stat(path.join(dir, 'evals.jsonl')));
|
|
325
|
+
parts.push('grants=' + stat(path.join(dir, 'grants.jsonl')));
|
|
326
|
+
parts.push('rules=' + stat(planRules.rulesPath(w.root) || ''));
|
|
327
|
+
parts.push('head=' + stat(path.join(planTree.mainTree(path.resolve(String(w.root))), '.git', 'logs', 'HEAD')));
|
|
328
|
+
return crypto.createHash('sha1').update(parts.join('|')).digest('hex');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
module.exports = { kinds: [KIND], rowsFor, sig, scan, SOURCES, AGENT_COMMIT, isAgentCommit, isAgentAcceptance, agentBasisProblem };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* pmos-verbs.js — the planning layer's `pmos` verbs: which module runs each, its help line and its guide page
|
|
3
|
+
* (pm-os-absorb Runs 2 to 5, PRD R24).
|
|
4
|
+
*
|
|
5
|
+
* has(verb) · run(verb, argv, { say, call }) → Promise<exit code> · names() · helpLines()
|
|
6
|
+
* VERBS
|
|
7
|
+
*
|
|
8
|
+
* bin/pmos.js asks has() and hands the call to run(), so the runs that add a verb add a module and never edit the
|
|
9
|
+
* launcher. A VERB MODULE is workspace/lib/<verb>-cli.js, in the shape of lib/item-cli.js:
|
|
10
|
+
* module.exports = { run(argv, { say, call }) → exit code | Promise, COMMANDS: { <verb>: ['sub', …] }, USAGE };
|
|
11
|
+
* argv is process.argv.slice(2), so argv[0] is the verb. `say` prints a line. `call(method, path, body)` reaches the
|
|
12
|
+
* running workspace server, or answers null when none runs; it sends no Origin header, so a PM-only route refuses it.
|
|
13
|
+
* A PM-only act from the CLI goes through lib/pm-guard.js requirePmCli and the writer module directly, never over HTTP.
|
|
14
|
+
* Exit codes: 0 done, 1 refused, 2 usage.
|
|
15
|
+
*
|
|
16
|
+
* guide.test.js G24 reads COMMANDS of every module present and requires each `pmos <verb> <sub>` in the `guide` page
|
|
17
|
+
* named here, so a verb cannot ship without its guide text.
|
|
18
|
+
*
|
|
19
|
+
* A listed verb is a verb from now on, built or not: `pmos board` no longer opens a folder named board (R24 says a
|
|
20
|
+
* path with a verb's name needs `./`). A verb whose module is not on disk yet says which run brings it and exits 2.
|
|
21
|
+
*/
|
|
22
|
+
const path = require('path');
|
|
23
|
+
|
|
24
|
+
const VERBS = Object.freeze({
|
|
25
|
+
initiative: Object.freeze({ module: 'initiative-cli.js', run: 2, guide: 'initiatives-roadmap.md',
|
|
26
|
+
help: 'pmos initiative new|now|next|later|list|export … — initiatives and their place on the Roadmap (pmos initiative help)' }),
|
|
27
|
+
measure: Object.freeze({ module: 'measure-cli.js', run: 2, guide: 'initiatives-roadmap.md',
|
|
28
|
+
help: 'pmos measure … — record a measured value of a KR or a health budget, with its source (pmos measure help)' }),
|
|
29
|
+
today: Object.freeze({ module: 'today-cli.js', run: 3, guide: 'today.md',
|
|
30
|
+
help: 'pmos today — what needs you, ranked as Today shows it' }),
|
|
31
|
+
board: Object.freeze({ module: 'board-cli.js', run: 4, guide: 'board.md',
|
|
32
|
+
help: 'pmos board [--mine] [--init <id>] — the items of every initiative, by column' }),
|
|
33
|
+
'verify-work': Object.freeze({ module: 'verify-work-cli.js', run: 4, guide: 'integrated-work.md',
|
|
34
|
+
help: 'pmos verify-work — mark work items integrated from the default branch (local refs only)' }),
|
|
35
|
+
report: Object.freeze({ module: 'report-cli.js', run: 5, guide: 'reports.md',
|
|
36
|
+
help: 'pmos report draft|issue|list … — draft and issue reports (pmos report help)' }),
|
|
37
|
+
// Not a pm-os-absorb verb (run: null): the workspace-launcher initiative added it through the same table.
|
|
38
|
+
launcher: Object.freeze({ module: 'launcher-cli.js', run: null, guide: 'launcher.md',
|
|
39
|
+
help: 'pmos launcher on|off|status — a PMOS app and a login item, so the workspace starts without a terminal' }),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const has = (verb) => typeof verb === 'string' && Object.prototype.hasOwnProperty.call(VERBS, verb);
|
|
43
|
+
const names = () => Object.keys(VERBS);
|
|
44
|
+
const helpLines = () => names().map((v) => ' ' + VERBS[v].help);
|
|
45
|
+
|
|
46
|
+
/** The verb's module, or null when its run has not built it. A module that is present and fails to load throws. */
|
|
47
|
+
function load(verb) {
|
|
48
|
+
const full = path.join(__dirname, VERBS[verb].module);
|
|
49
|
+
try { return require(full); }
|
|
50
|
+
catch (e) {
|
|
51
|
+
if (e && e.code === 'MODULE_NOT_FOUND' && String(e.message).includes(full)) return null;
|
|
52
|
+
throw e;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function run(verb, argv, { say = console.log, call = async () => null } = {}) {
|
|
57
|
+
if (!has(verb)) { say(`✗ pmos ${verb} is not a verb`); return 2; }
|
|
58
|
+
let mod;
|
|
59
|
+
try { mod = load(verb); }
|
|
60
|
+
catch (e) { say(`✗ pmos ${verb} could not load: ${String((e && e.message) || e).slice(0, 200)}`); return 1; }
|
|
61
|
+
if (!mod || typeof mod.run !== 'function') {
|
|
62
|
+
say(`✗ pmos ${verb} arrives in Run ${VERBS[verb].run} of pm-os-absorb. To open a folder named ${verb}, type: pmos ./${verb}`);
|
|
63
|
+
return 2;
|
|
64
|
+
}
|
|
65
|
+
const code = await mod.run(argv, { say, call });
|
|
66
|
+
return Number.isInteger(code) ? code : 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { VERBS, has, names, helpLines, load, run };
|