create-pmos 0.52.0 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/mods/pmos-cockpit/README.md +13 -0
- package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +2 -1
- package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +41 -11
- package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +228 -8
- package/payload/mods/pmos-cockpit/hooks/register.tsx +11 -3
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/plan-items-stage.js +271 -0
- package/payload/scripts/pmos-log.sh +18 -7
- package/payload/scripts/workflow-state.js +28 -2
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/bin/pmos.js +15 -1
- package/workspace/cut-stub.js +40 -0
- package/workspace/first-paint-audit.js +145 -0
- package/workspace/fixtures/capture/requirements/capture-2026-10-04-1.md +10 -0
- package/workspace/fixtures/initiatives-api/list.json +176 -0
- package/workspace/fixtures/initiatives-api/post-403.json +5 -0
- package/workspace/fixtures/initiatives-api/post-ok.json +10 -0
- package/workspace/fixtures/initiatives-api/roadmap.json +208 -0
- package/workspace/fixtures/initiatives-api/sheet.json +112 -0
- package/workspace/fixtures/report-api/draft.json +177 -0
- package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +97 -0
- package/workspace/fixtures/today-api/provider-rows.json +204 -0
- package/workspace/fixtures/today-api/seam-probe-kinds.json +5 -0
- package/workspace/guide/board.md +81 -0
- package/workspace/guide/breakdown.md +154 -0
- package/workspace/guide/command-palette.md +75 -0
- package/workspace/guide/index.md +27 -17
- package/workspace/guide/initiatives-roadmap.md +130 -0
- package/workspace/guide/initiatives-screen.md +89 -0
- package/workspace/guide/initiatives-timeline.md +57 -0
- package/workspace/guide/integrated-work.md +94 -0
- package/workspace/guide/knowledge-graph.md +3 -68
- package/workspace/guide/layout.md +98 -32
- package/workspace/guide/live-notes.md +3 -3
- package/workspace/guide/pm-only-acts.md +89 -0
- package/workspace/guide/pm-only-lines.md +61 -0
- package/workspace/guide/reports-page.md +80 -0
- package/workspace/guide/reports.md +73 -0
- package/workspace/guide/timeline.md +70 -0
- package/workspace/guide/today.md +85 -0
- package/workspace/guide/validate-with-typesafe.md +1 -1
- package/workspace/lib/actor.js +91 -0
- package/workspace/lib/board-cache.js +32 -2
- package/workspace/lib/board-cli.js +108 -0
- package/workspace/lib/board-fold.js +232 -0
- package/workspace/lib/breakdown-export.js +80 -0
- package/workspace/lib/capture.js +98 -0
- package/workspace/lib/engine/plan-items-stage.js +271 -0
- package/workspace/lib/engine/workflow-state.js +28 -2
- package/workspace/lib/factory-join.js +158 -0
- package/workspace/lib/grant-mint.js +113 -0
- package/workspace/lib/health-rules-factory.js +92 -0
- package/workspace/lib/health-rules-plan.js +164 -0
- package/workspace/lib/health.js +79 -0
- package/workspace/lib/initiative-cli.js +222 -0
- package/workspace/lib/initiative-display.js +183 -0
- package/workspace/lib/initiative-needs.js +127 -0
- package/workspace/lib/initiative-rows.js +206 -0
- package/workspace/lib/initiative-writer.js +141 -0
- package/workspace/lib/issues.js +206 -0
- package/workspace/lib/item-cli.js +195 -0
- package/workspace/lib/item-spans.js +137 -0
- package/workspace/lib/measure-cli.js +100 -0
- package/workspace/lib/measurements.js +189 -0
- package/workspace/lib/mobile-nav.js +57 -53
- package/workspace/lib/needs-providers.js +120 -0
- package/workspace/lib/placements.js +440 -0
- package/workspace/lib/plan-items.js +922 -0
- package/workspace/lib/plan-ledgers.js +85 -0
- package/workspace/lib/plan-rules.js +72 -0
- package/workspace/lib/plan-tree.js +367 -0
- package/workspace/lib/pm-guard.js +174 -0
- package/workspace/lib/pm-lines-needs.js +331 -0
- package/workspace/lib/pmos-verbs.js +66 -0
- package/workspace/lib/proactive.js +7 -4
- package/workspace/lib/report-cli.js +152 -0
- package/workspace/lib/report-draft.js +579 -0
- package/workspace/lib/report-needs.js +213 -0
- package/workspace/lib/route-modules.js +128 -0
- package/workspace/lib/routes-board.js +91 -0
- package/workspace/lib/routes-capture.js +52 -0
- package/workspace/lib/routes-initiatives.js +371 -0
- package/workspace/lib/routes-reports.js +163 -0
- package/workspace/lib/routes-timeline.js +197 -0
- package/workspace/lib/routes-today.js +32 -0
- package/workspace/lib/today-cli.js +99 -0
- package/workspace/lib/today.js +333 -0
- package/workspace/lib/triage.js +163 -0
- package/workspace/lib/verify-work-cli.js +81 -0
- package/workspace/lib/work-check-needs.js +99 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +4 -5
- package/workspace/public/index.html +29 -19
- package/workspace/public/pm/app.js +299 -30
- package/workspace/public/pm/bd-board.js +113 -0
- package/workspace/public/pm/bd-drag.js +59 -0
- package/workspace/public/pm/bd-model.js +152 -0
- package/workspace/public/pm/bd-timeline.js +233 -0
- package/workspace/public/pm/bd-tree.js +244 -0
- package/workspace/public/pm/board-copy.js +57 -0
- package/workspace/public/pm/breakdown-api.js +58 -0
- package/workspace/public/pm/breakdown-copy.js +138 -0
- package/workspace/public/pm/components.css +3054 -0
- package/workspace/public/pm/cycle-review.js +125 -0
- package/workspace/public/pm/data.js +40 -187
- package/workspace/public/pm/dest-board.js +438 -0
- package/workspace/public/pm/dest-initiatives.js +134 -0
- package/workspace/public/pm/dest-reports.js +270 -0
- package/workspace/public/pm/dest-settings.js +217 -0
- package/workspace/public/pm/dest-today.js +176 -0
- package/workspace/public/pm/dialogs.js +88 -0
- package/workspace/public/pm/display-word.js +56 -0
- package/workspace/public/pm/dock.js +105 -0
- package/workspace/public/pm/fixture.js +8 -227
- package/workspace/public/pm/frame-copy.js +94 -0
- package/workspace/public/pm/handoff-box.js +82 -0
- package/workspace/public/pm/http.js +88 -0
- package/workspace/public/pm/icons.js +53 -0
- package/workspace/public/pm/initiative-list.js +218 -0
- package/workspace/public/pm/initiative-sheet.js +261 -0
- package/workspace/public/pm/initiatives-api.js +59 -0
- package/workspace/public/pm/initiatives-copy.js +215 -0
- package/workspace/public/pm/initiatives-timeline-copy.js +59 -0
- package/workspace/public/pm/initiatives-timeline.js +410 -0
- package/workspace/public/pm/initiatives-ui.js +123 -0
- package/workspace/public/pm/item-sheet.js +251 -0
- package/workspace/public/pm/keep-card.js +204 -0
- package/workspace/public/pm/lifecycle.js +7 -1
- package/workspace/public/pm/mobile.css +49 -20
- package/workspace/public/pm/mobile.js +157 -29
- package/workspace/public/pm/modes-live.js +7 -883
- package/workspace/public/pm/modes.js +32 -1024
- package/workspace/public/pm/new-initiative-sheet.js +74 -0
- package/workspace/public/pm/next-step-ui.js +3 -2
- package/workspace/public/pm/palette-copy.js +51 -0
- package/workspace/public/pm/palette.js +312 -0
- package/workspace/public/pm/panel-breakdown.js +702 -0
- package/workspace/public/pm/panel-cockpit.js +63 -295
- package/workspace/public/pm/panel-detail.js +25 -172
- package/workspace/public/pm/panel-initiatives.js +1 -19
- package/workspace/public/pm/panel-repos.js +31 -81
- package/workspace/public/pm/panel-tabs.js +197 -110
- package/workspace/public/pm/panel-trees.js +5 -2
- package/workspace/public/pm/portfolio-report.js +61 -0
- package/workspace/public/pm/primitives.js +656 -0
- package/workspace/public/pm/print.css +33 -0
- package/workspace/public/pm/provider-cards.js +136 -0
- package/workspace/public/pm/registry.js +194 -0
- package/workspace/public/pm/release-card.js +110 -0
- package/workspace/public/pm/report-view.js +309 -0
- package/workspace/public/pm/reports-copy.js +114 -0
- package/workspace/public/pm/roadmap.js +271 -0
- package/workspace/public/pm/sig-watch.js +41 -0
- package/workspace/public/pm/state.js +8 -4
- package/workspace/public/pm/status-banner.js +13 -5
- package/workspace/public/pm/tab-doc.js +2 -4
- package/workspace/public/pm/tab-term.js +12 -2
- package/workspace/public/pm/tab-web.js +3 -2
- package/workspace/public/pm/timeline-copy.js +48 -0
- package/workspace/public/pm/today-cards.js +244 -0
- package/workspace/public/pm/today-copy.js +97 -0
- package/workspace/public/pm/tokens.css +16 -18
- package/workspace/public/pm/topbar.js +102 -0
- package/workspace/public/pm/triage-card.js +60 -0
- package/workspace/public/pm/undo-hold.js +103 -0
- package/workspace/public/pm/view-addrepo.js +2 -1
- package/workspace/public/pm/view-livenotes.js +3 -1
- package/workspace/public/pm/view-today.js +7 -162
- package/workspace/public/vendor/Sortable.min.js +2 -0
- package/workspace/public/vendor/lucide-sprite.js +79 -0
- package/workspace/replaced-tests.json +2864 -0
- package/workspace/run-tests.js +56 -5
- package/workspace/server.js +312 -36
- package/workspace/sheet-answer.js +61 -0
- package/workspace/skills/measure.skill +41 -0
- package/workspace/skills/report-biweekly.skill +23 -25
- package/workspace/skills/tracker-refresh.skill +50 -0
- package/workspace/test-files.json +19 -3
- package/workspace/test-lists/base.json +6 -0
- package/workspace/test-lists/run2-a.json +6 -0
- package/workspace/test-lists/run2-b.json +13 -0
- package/workspace/test-lists/run2-c.json +19 -0
- package/workspace/test-lists/run2-d.json +22 -0
- package/workspace/test-lists/run3-0.json +5 -0
- package/workspace/test-lists/run3-a.json +5 -0
- package/workspace/test-lists/run3-b.json +6 -0
- package/workspace/test-lists/run3-c.json +15 -0
- package/workspace/test-lists/run3-d.json +6 -0
- package/workspace/test-lists/run4-a.json +8 -0
- package/workspace/test-lists/run4-b.json +6 -0
- package/workspace/test-lists/run4-c.json +13 -0
- package/workspace/test-lists/run4-d.json +9 -0
- package/workspace/test-lists/run5-a.json +6 -0
- package/workspace/test-lists/run5-b.json +6 -0
- package/workspace/test-lists/run5-c.json +15 -0
- package/workspace/test-run.js +27 -1
- package/workspace/third-party/README.md +44 -0
- package/workspace/third-party/lucide-static-1.52.0.LICENSE +43 -0
- package/workspace/third-party/sortablejs-1.15.7.LICENSE +21 -0
- package/workspace/design-parity.js +0 -673
- package/workspace/public/pm/tab-task.js +0 -86
- package/workspace/public/pm/view-guide.js +0 -141
- package/workspace/public/pm/view-knowledge.js +0 -225
- package/workspace/public/pm/view-memory.js +0 -122
- package/workspace/public/pm/view-portfolio.js +0 -153
- package/workspace/public/pm/view-reports.js +0 -190
- package/workspace/public/pm/view-scheduled.js +0 -161
- package/workspace/public/pm/view-skills.js +0 -216
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
kernel-source: pmos@
|
|
2
|
-
assembled: 2026-10-
|
|
1
|
+
kernel-source: pmos@aa14199
|
|
2
|
+
assembled: 2026-10-06
|
|
3
3
|
mode: vendor
|
|
@@ -32,6 +32,14 @@ A grant is one line in `grants.jsonl`. You mint it by typing `/pmos-grant merge:
|
|
|
32
32
|
|
|
33
33
|
`/pmos-accept <run_id> pass|fail <note>` records your Acceptance through the same writer, with `pm_label` set. When your own message reads as an approval, a band offers Pass, Fail and Not now for the newest run of the branch's initiative that has no acceptance. Your press writes the row; the note quotes your message. Nothing is written from inferred text.
|
|
34
34
|
|
|
35
|
+
**PM-only acts (pm-os-absorb Run 2, D86 point 2).** The guards deny a model call that does one of the PM's acts in the workspace: Move to Now, adopt a proposal, Issue, acceptance, lifecycle, the anchor change, Keep and Release. A call is denied when it:
|
|
36
|
+
|
|
37
|
+
- sends a write (a body, or a method other than GET) to a PM-only workspace route on a loopback address and a workspace port: `/api/gate`, `/api/lifecycle`, `/api/plan-items`, `/api/placements`, `/api/doc/save`, `/api/align/apply`, `/api/ingest/confirm`, `/api/session/initiative`, `/api/report/issue` or `/api/board/release`. The workspace ports are 7433 to 7445, the port in `PMOS_ROUTE_URL` and a `PMOS_WS_PORT` pin. The check reads each spelling of a loopback address (`127.1`, `0`, `[::1]`, …), removes `/./` and `/x/../` from the path, and reads a URL that the command keeps in a variable. A call to a product's own server on another port is allowed;
|
|
38
|
+
- runs a PM-only `pmos` verb (`pmos initiative now`, `adopt`, `anchor` or `lifecycle`; `pmos report issue`; `pmos board release`), also through `node $PMOS_BIN`, `env`, `script` or a launcher;
|
|
39
|
+
- loads the writer of a D86 ledger (`workspace/lib/plan-items.js`, `placements.js`, `measurements.js`, `issues.js`) from inline code (`-e`, `--eval=`, `-r`, `--require=`, `--import=`, a here-document, a here-string, or a program sent through a pipe), or writes and runs such a script in one command, or writes one outside the repository that calls the writer's PM path. The module path must name `workspace/lib/`. In PMOS's own repository, `lib/<name>` and `./<name>` also count. A product's own `src/github/issues.js` is not a writer, and a kit install loads it freely.
|
|
40
|
+
|
|
41
|
+
The four D86 ledgers are listed as ledgers, so a hand write to one is denied and the message names the `pmos` verb that writes it. Two grant scopes join `merge` and `publish`: `release:<initiative>:<batch-id>` (one factory batch) and `lifecycle-bulk:<state>:<id>,<id>,…` (one state for at most 20 initiatives). Both are single use and live 120 minutes unless you give a shorter time.
|
|
42
|
+
|
|
35
43
|
## Model routing (prompt-model-routing, Run 3)
|
|
36
44
|
|
|
37
45
|
- **Every prompt is routed before it runs.** When the PMOS workspace started this terminal, it put its own loopback address in `PMOS_ROUTE_URL`. The mod asks that address for the route of each prompt you type and each prompt handed to a sub-agent. The workspace asks Jev only when its three gates are open: a saved key, consent for the repository, and the "route prompts with Jev" switch (D84). All three are on the workspace's validation page. The key stays in the workspace and never reaches this terminal.
|
|
@@ -81,6 +89,11 @@ A shell can do what a matcher does not name. The guards are defence in depth. Th
|
|
|
81
89
|
- a script written first and run second (`bash /tmp/x.sh`);
|
|
82
90
|
- a path built from pieces no pattern can join (`'runs' + '.js' + 'onl'`);
|
|
83
91
|
- a patch that touches a ledger (`git apply`);
|
|
92
|
+
- a script written by one call inside the repository and run by a later call, or a writer's path built from pieces: the line it writes is flagged by CI (`scripts/check-pm-lines.js`) when it arrives in an agent-authored commit, which is detection after the fact;
|
|
93
|
+
- a call to a PM-only route on an address that is not loopback, or on a port that this session does not know as a workspace port;
|
|
94
|
+
- a program that `node` reads from a file on stdin (`node < /tmp/x.js`), as for any script run from a file;
|
|
95
|
+
- in a kit install, a writer loaded by a path that does not name `workspace/lib/` (`cd` into the workspace first);
|
|
96
|
+
- `pmos initiative up`, `top`, `down` and `check-by`. These are PM-only only for an initiative in Now, and the matcher cannot see the horizon. For a Now initiative, the verb asks the PM to type a confirmation at a terminal, so an agent's call fails there;
|
|
84
97
|
- a Bash write through a link that points at a ledger (Edit and Write are resolved to where the link leads; Bash is not);
|
|
85
98
|
- `cd npm-pkg && npm publish`: the mod reads the package name from the session root, so a publish from another folder finds no matching grant and is denied. Run it yourself, or run it from the folder.
|
|
86
99
|
|
|
@@ -68,7 +68,8 @@ export function planAcceptance({ layout, root, run, label, note, pm, atIso }) {
|
|
|
68
68
|
return { ok: true, argv: [...w.argv, 'accept', run.run_id, run.initiative_id, label, note], env: w.env };
|
|
69
69
|
}
|
|
70
70
|
if (layout.kind === 'self') {
|
|
71
|
-
|
|
71
|
+
// via 'cli': the PM typed /pmos-accept (a PM-only line carries via, R25(4); scripts/check-pm-lines.js reads it).
|
|
72
|
+
const row = { run_id: run.run_id, initiative_id: run.initiative_id, accepted: label === 'pass', pm_label: label, pm, at: atIso, note, via: 'cli' };
|
|
72
73
|
return { ok: true, argv: ['node', `${r}/scripts/record-acceptance.js`, JSON.stringify(row)], env: {} };
|
|
73
74
|
}
|
|
74
75
|
return { ok: false, text: 'There is no PMOS state here, so there is nowhere to record an acceptance.' };
|
|
@@ -11,38 +11,68 @@
|
|
|
11
11
|
// on a branch that names none. It is optional. A value that is not a plain slug makes the whole grant
|
|
12
12
|
// ignored: a grant that says something the code cannot read is not trusted for anything.
|
|
13
13
|
//
|
|
14
|
-
// This release has
|
|
15
|
-
// for a push), `publish` covers one artifact.
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// This release has four scopes and no standing grant. `merge` covers one PR number (or one branch name
|
|
15
|
+
// for a push), `publish` covers one artifact. Two more came with D86 point 2 (pm-os-absorb Run 2, map-spec
|
|
16
|
+
// X5): `release` covers ONE factory batch of one initiative (`release:<initiative>:<batch-id>`, D65), and
|
|
17
|
+
// `lifecycle-bulk` covers one lifecycle change of an explicit list of at most 20 initiatives
|
|
18
|
+
// (`lifecycle-bulk:<state>:<id>,<id>,…`, the state one of active, closed, on-hold, cancelled; an id listed
|
|
19
|
+
// twice is refused). Both are minted only by /pmos-grant or a person's press (workspace/lib/grant-mint.js).
|
|
20
|
+
// A scope the code does not know, including `docs-sync` and every `outward:` scope, is refused when
|
|
21
|
+
// minted and ignored when read. A grant for one target never covers another. A grant is live when it
|
|
22
|
+
// has not expired and has not been used.
|
|
23
|
+
//
|
|
24
|
+
// ONE RULE IN THREE PLACES. scripts/record-grant.js and kit/scripts/pmos-log.sh hold the same scopes and
|
|
25
|
+
// target patterns; scripts/tests/record-grant.test.js runs one table of cases through all three.
|
|
18
26
|
//
|
|
19
27
|
// Pure: it reads text and a clock value it is given.
|
|
20
28
|
|
|
21
|
-
export const SCOPES = ['merge', 'publish'];
|
|
29
|
+
export const SCOPES = ['merge', 'publish', 'release', 'lifecycle-bulk'];
|
|
22
30
|
export const DEFAULT_TTL_MINUTES = 120;
|
|
23
31
|
export const MAX_TTL_MINUTES = 24 * 60;
|
|
24
32
|
// The same slug rule as the initiative resolver and both writers.
|
|
25
33
|
export const INITIATIVE_ID = /^[a-z0-9][a-z0-9._-]{0,99}$/;
|
|
26
34
|
|
|
35
|
+
// The lifecycle states a bulk grant may name: STOPPED_STATES of scripts/workflow-state.js, and `active`.
|
|
36
|
+
export const LIFECYCLE_STATES = ['active', 'closed', 'on-hold', 'cancelled'];
|
|
37
|
+
export const BULK_MAX_IDS = 20;
|
|
38
|
+
const SLUG = '[a-z0-9][a-z0-9._-]{0,99}';
|
|
27
39
|
const TARGET = {
|
|
28
40
|
merge: /^(?:\d{1,9}|[A-Za-z0-9][A-Za-z0-9._\/-]{0,79})$/,
|
|
29
41
|
publish: /^[A-Za-z0-9@][A-Za-z0-9@._\/-]{0,99}$/,
|
|
42
|
+
release: new RegExp(`^${SLUG}:[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`),
|
|
43
|
+
'lifecycle-bulk': new RegExp(`^(?:active|closed|on-hold|cancelled):${SLUG}(?:,${SLUG}){0,${BULK_MAX_IDS - 1}}$`),
|
|
44
|
+
};
|
|
45
|
+
const WHAT = {
|
|
46
|
+
merge: 'PR number or branch name',
|
|
47
|
+
publish: 'artifact name',
|
|
48
|
+
release: '<initiative>:<batch-id>',
|
|
49
|
+
'lifecycle-bulk': `<state>:<id>,<id>,… (one of ${LIFECYCLE_STATES.join(', ')}, then 1 to ${BULK_MAX_IDS} initiative ids)`,
|
|
30
50
|
};
|
|
51
|
+
const USE = 'Use merge:<PR number or branch>, publish:<artifact>, release:<initiative>:<batch-id> or lifecycle-bulk:<state>:<id>,<id>,….';
|
|
52
|
+
|
|
53
|
+
/** Does `target` name exactly one thing a grant of `kind` may cover? The rule the two writers repeat. */
|
|
54
|
+
export function targetOk(kind, target) {
|
|
55
|
+
const t = String(target);
|
|
56
|
+
if (!Object.prototype.hasOwnProperty.call(TARGET, kind) || t.includes('*') || t.includes('..') || !TARGET[kind].test(t)) return false;
|
|
57
|
+
if (kind !== 'lifecycle-bulk') return true;
|
|
58
|
+
const ids = t.slice(t.indexOf(':') + 1).split(',');
|
|
59
|
+
return new Set(ids).size === ids.length;
|
|
60
|
+
}
|
|
31
61
|
|
|
32
62
|
/** Parse "merge:361" into its parts, or say why not. */
|
|
33
63
|
export function parseScope(text) {
|
|
34
64
|
const raw = String(text ?? '').trim();
|
|
35
|
-
if (raw === '') return { ok: false, reason: 'a scope is required, for example merge:361
|
|
65
|
+
if (raw === '') return { ok: false, reason: 'a scope is required, for example merge:361, publish:create-pmos or release:alpha:b-01' };
|
|
36
66
|
if (/^docs-sync(?::|$)/.test(raw) || /^outward(?::|$)/.test(raw)) {
|
|
37
67
|
return { ok: false, reason: `"${raw}" is a standing scope. Standing grants are not allowed in this release (D81). Mint one single-use grant per action.` };
|
|
38
68
|
}
|
|
39
69
|
const at = raw.indexOf(':');
|
|
40
|
-
if (at === -1) return { ok: false, reason: `unknown scope "${raw}".
|
|
70
|
+
if (at === -1) return { ok: false, reason: `unknown scope "${raw}". ${USE}` };
|
|
41
71
|
const kind = raw.slice(0, at);
|
|
42
72
|
const target = raw.slice(at + 1);
|
|
43
|
-
if (!SCOPES.includes(kind)) return { ok: false, reason: `unknown scope "${kind}".
|
|
44
|
-
if (target === '' ||
|
|
45
|
-
return { ok: false, reason: `"${target}" is not a single ${kind
|
|
73
|
+
if (!SCOPES.includes(kind)) return { ok: false, reason: `unknown scope "${kind}". ${USE}` };
|
|
74
|
+
if (target === '' || !targetOk(kind, target)) {
|
|
75
|
+
return { ok: false, reason: `"${target}" is not a single ${WHAT[kind]}. A grant names one target; a wildcard is refused.` };
|
|
46
76
|
}
|
|
47
77
|
return { ok: true, kind, target };
|
|
48
78
|
}
|
|
@@ -60,7 +90,7 @@ export function parseLedger(text) {
|
|
|
60
90
|
if (r === null || typeof r !== 'object' || Array.isArray(r)) { ignored += 1; continue; }
|
|
61
91
|
if (r.type === 'use' && typeof r.grant_id === 'string') { uses.set(r.grant_id, (uses.get(r.grant_id) ?? 0) + 1); continue; }
|
|
62
92
|
if (r.type !== 'grant' || typeof r.id !== 'string' || !SCOPES.includes(r.scope) || typeof r.target !== 'string') { ignored += 1; continue; }
|
|
63
|
-
if (!
|
|
93
|
+
if (!targetOk(r.scope, r.target)) { ignored += 1; continue; }
|
|
64
94
|
const at = Date.parse(r.at);
|
|
65
95
|
const exp = Date.parse(r.expires_at);
|
|
66
96
|
if (Number.isNaN(at) || Number.isNaN(exp) || exp <= at || exp - at > MAX_TTL_MINUTES * 60_000 || r.uses !== 1) { ignored += 1; continue; }
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
// default branch, a forced push, npm publish -> needs a live grant
|
|
12
12
|
// cloud a cloud-mutating command from AGENTS.md -> deny, the PM runs it
|
|
13
13
|
// outward a write tool for Confluence, Jira, Slack, Gmail … -> ask for each item
|
|
14
|
+
// pm-act a PM-only act (pm-os-absorb R25(3), D86 point 2): an
|
|
15
|
+
// HTTP write to a loopback PM-only route, a PM-only
|
|
16
|
+
// `pmos` verb, or inline code that requires a D86
|
|
17
|
+
// ledger's writer module -> deny, the PM presses it
|
|
14
18
|
//
|
|
15
19
|
// WHAT THIS IS NOT. A shell can do what no matcher names: a path built from pieces, a script written
|
|
16
20
|
// first and run second, a tool this file has never seen. The mod is defence in depth. The writers
|
|
@@ -23,9 +27,17 @@
|
|
|
23
27
|
// Pure: no I/O. The caller gives it the layout, the branch facts and, for settings.json, the text.
|
|
24
28
|
|
|
25
29
|
// ── paths ───────────────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
// The four D86 ledgers (pm-os-absorb, PRD R24) and the one module that writes each. An agent reaches each
|
|
31
|
+
// writer through a `pmos` verb, which records who wrote the line; the verb is named in a deny message.
|
|
32
|
+
export const D86_LEDGERS = Object.freeze({
|
|
33
|
+
'plan-items.jsonl': { writer: 'workspace/lib/plan-items.js', verb: 'pmos item' },
|
|
34
|
+
'placements.jsonl': { writer: 'workspace/lib/placements.js', verb: 'pmos initiative' },
|
|
35
|
+
'measurements.jsonl': { writer: 'workspace/lib/measurements.js', verb: 'pmos measure' },
|
|
36
|
+
'issues.jsonl': { writer: 'workspace/lib/issues.js', verb: 'pmos report' },
|
|
37
|
+
});
|
|
26
38
|
export const LEDGER_NAMES = new Set([
|
|
27
39
|
'runs.jsonl', 'evals.jsonl', 'acceptances.jsonl', 'outcome_checks.jsonl', 'grants.jsonl',
|
|
28
|
-
'agent-runs.jsonl', 'lifecycle.jsonl', 'verify-queue.jsonl',
|
|
40
|
+
'agent-runs.jsonl', 'lifecycle.jsonl', 'verify-queue.jsonl', ...Object.keys(D86_LEDGERS),
|
|
29
41
|
]);
|
|
30
42
|
const ACCEPT_GRANTS = new Set(['acceptances.jsonl', 'grants.jsonl']);
|
|
31
43
|
|
|
@@ -51,7 +63,8 @@ export function classifyPath(path, ctx) {
|
|
|
51
63
|
const state = ctx.layout?.stateRoot ?? null;
|
|
52
64
|
const inState = state !== null && under(abs, state);
|
|
53
65
|
if (name.endsWith('.jsonl') && (inState || LEDGER_NAMES.has(name))) {
|
|
54
|
-
|
|
66
|
+
const d86 = D86_LEDGERS[name];
|
|
67
|
+
return { kind: ACCEPT_GRANTS.has(name) ? 'accept-grants' : 'ledger', path: abs, ...(d86 ? { writer: d86.verb } : {}) };
|
|
55
68
|
}
|
|
56
69
|
if (abs === `${root}/.claude/pmos-cockpit.json`) return { kind: 'governing', path: abs, what: '.claude/pmos-cockpit.json' };
|
|
57
70
|
if (ctx.layout?.kind === 'kit' && under(abs, `${root}/pmos/mods`)) return { kind: 'governing', path: abs, what: 'the installed mod (pmos/mods)' };
|
|
@@ -115,7 +128,8 @@ function readSubstitutions(cmd) {
|
|
|
115
128
|
|
|
116
129
|
/**
|
|
117
130
|
* Split a command into segments of words. A word is { t: text, dyn: has an unresolved $ or glob,
|
|
118
|
-
* op: a redirect operator }. A segment also carries the bodies of its here-documents
|
|
131
|
+
* op: a redirect operator }. A segment also carries the bodies of its here-documents, and pipedIn when a
|
|
132
|
+
* single `|` feeds it.
|
|
119
133
|
*/
|
|
120
134
|
export function parseShell(cmd) {
|
|
121
135
|
const segs = [];
|
|
@@ -163,7 +177,12 @@ export function parseShell(cmd) {
|
|
|
163
177
|
seg.words.push({ t: '&>', dyn: false, op: true }); continue;
|
|
164
178
|
}
|
|
165
179
|
if (c === ';' || c === '|' || c === '&' || c === '(' || c === ')' || ((c === '{' || c === '}') && standalone(i))) {
|
|
166
|
-
|
|
180
|
+
// A single `|` (or `|&`) feeds the next segment: it is marked pipedIn, so an interpreter that reads its
|
|
181
|
+
// program from the pipe is judged on the text that feeds it.
|
|
182
|
+
const pipe = c === '|' && cmd[i + 1] !== '|';
|
|
183
|
+
flushSeg(); i += 1; if ((c === '&' || c === '|') && cmd[i] === c) i += 1;
|
|
184
|
+
if (pipe) { if (cmd[i] === '&') i += 1; seg.pipedIn = true; }
|
|
185
|
+
continue;
|
|
167
186
|
}
|
|
168
187
|
if (c === '>' || c === '<') {
|
|
169
188
|
if (cur !== null && /^\d+$/.test(cur.t)) cur = null; else flushWord();
|
|
@@ -300,6 +319,157 @@ function ghFindings(args, findings, raw) {
|
|
|
300
319
|
|
|
301
320
|
const CLOUD = /^(db\s+(push|reset)|functions\s+deploy|projects\s+create|secrets\s+set|branches\s+create|link)\b/;
|
|
302
321
|
|
|
322
|
+
// ── PM-only acts (pm-os-absorb R25(3), D86 point 2) ──────────────────────────────────────────────
|
|
323
|
+
// The workspace routes that write a PM-only act. The server refuses a call with no nonce or no Origin (R25(2));
|
|
324
|
+
// this refuses the model's call before it is made, so a nonce read from the page is not used either.
|
|
325
|
+
export const PM_ROUTES = Object.freeze(['gate', 'lifecycle', 'plan-items', 'placements', 'doc/save', 'align/apply', 'ingest/confirm',
|
|
326
|
+
'session/initiative', 'report/issue', 'board/release']);
|
|
327
|
+
// The `pmos` subverbs that do a PM-only act. The verb asks the PM to type a confirmation at a terminal; an agent's
|
|
328
|
+
// Bash tool has none, and `script` makes one (bypass b), so the model's call is refused here as well.
|
|
329
|
+
export const PM_VERBS = Object.freeze({ initiative: ['now', 'adopt', 'anchor', 'lifecycle'], report: ['issue'], board: ['release'] });
|
|
330
|
+
const reEsc = (t) => t.replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&');
|
|
331
|
+
// The workspace's ports (workspace/lib/server-discovery.js): DEFAULT_PORT 7433 and the FALLBACK_SPAN of 12 the launcher
|
|
332
|
+
// rolls forward over. A port pinned with PMOS_WS_PORT is added from the session's environment (wsPortsOf; register.tsx
|
|
333
|
+
// puts it in ctx.wsPorts). A product's own server on another port is not the workspace, so a call to it passes.
|
|
334
|
+
export const WS_PORTS = Object.freeze(Array.from({ length: 13 }, (_, k) => 7433 + k));
|
|
335
|
+
/** The workspace ports for one session: WS_PORTS, the port of PMOS_ROUTE_URL and a pinned PMOS_WS_PORT. */
|
|
336
|
+
export function wsPortsOf(env = {}) {
|
|
337
|
+
const ports = new Set(WS_PORTS);
|
|
338
|
+
const add = (v) => { const n = Number(v); if (Number.isInteger(n) && n > 0 && n < 65536) ports.add(n); };
|
|
339
|
+
if (typeof env.PMOS_WS_PORT === 'string' && /^\d{1,5}$/.test(env.PMOS_WS_PORT.trim())) add(env.PMOS_WS_PORT.trim());
|
|
340
|
+
const m = typeof env.PMOS_ROUTE_URL === 'string' ? /^https?:\/\/[^/:]+:(\d{1,5})\//.exec(env.PMOS_ROUTE_URL) : null;
|
|
341
|
+
if (m) add(m[1]);
|
|
342
|
+
return [...ports];
|
|
343
|
+
}
|
|
344
|
+
// A loopback host in every spelling a client resolves (127.1, 127.0.1, 0, 0x7f000001, 2130706433, [::1], …), or a
|
|
345
|
+
// host in a variable, then an optional port, then a PM-only route. Text is normalized first (normalizeUrlText).
|
|
346
|
+
const LOOP_HOST = '(?:localhost|127(?:\\.\\d{1,3}){0,3}|0x7f[0-9a-fx.]*|0177(?:\\.\\d{1,4}){0,3}|2130706433|0(?:\\.0){0,3}'
|
|
347
|
+
+ '|\\[(?:::1|0:0:0:0:0:0:0:1|::ffff:127(?:\\.\\d{1,3}){3}|::ffff:7f00:1)\\])';
|
|
348
|
+
const VAR = '\\$\\{?([A-Za-z_][A-Za-z0-9_]*)\\}?';
|
|
349
|
+
const PM_ROUTE_URL = new RegExp(`(?<![\\w.-])(${LOOP_HOST}|${VAR})(?::(\\d{1,5}|${VAR}))?/api/(${PM_ROUTES.map(reEsc).join('|')})(?![\\w/-])`, 'gi');
|
|
350
|
+
const HTTP_CLIENTS = new Set(['curl', 'wget', 'http', 'https', 'xh', 'xhs', 'httpie']);
|
|
351
|
+
|
|
352
|
+
/** Expand the variables a command assigns ($U, ${U}), then collapse the dot segments a client collapses (/./, /x/../). */
|
|
353
|
+
function normalizeUrlText(text, vars) {
|
|
354
|
+
let t = String(text);
|
|
355
|
+
for (let k = 0; k < 3 && vars && vars.size && /\$/.test(t); k += 1) {
|
|
356
|
+
t = t.replace(new RegExp(VAR, 'g'), (m, name) => (vars.has(name) ? vars.get(name) : m));
|
|
357
|
+
}
|
|
358
|
+
let prev;
|
|
359
|
+
do { prev = t; t = t.replace(/\/\.(?=\/)/g, '').replace(/\/(?!\.\.\/)[^/\s'"?#]+\/\.\.(?=\/)/g, '').replace(/([^:/\s])\/{2,}/g, '$1/'); } while (t !== prev);
|
|
360
|
+
return t;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The PM-only route a text sends to, or null. The port must be one of the workspace's (ctx.wsPorts, else WS_PORTS). A
|
|
365
|
+
* port or a host in a variable the command does not assign cannot be read: it counts in PMOS-self, and in a kit
|
|
366
|
+
* install only when the variable's name starts with PMOS.
|
|
367
|
+
*/
|
|
368
|
+
function pmRouteIn(text, ctx) {
|
|
369
|
+
const ports = ctx?.wsPorts ?? WS_PORTS;
|
|
370
|
+
const self = ctx?.layout?.kind === 'self';
|
|
371
|
+
const t = normalizeUrlText(text, ctx?.vars);
|
|
372
|
+
for (const m of t.matchAll(PM_ROUTE_URL)) {
|
|
373
|
+
const hostVar = m[2];
|
|
374
|
+
const portVar = m[4];
|
|
375
|
+
const port = m[3] !== undefined && portVar === undefined ? Number(m[3]) : null;
|
|
376
|
+
const varOk = (name) => self || /^PMOS/.test(name);
|
|
377
|
+
if (portVar !== undefined) { if (varOk(portVar)) return m[5]; continue; }
|
|
378
|
+
if (port !== null) { if (ports.includes(port)) return m[5]; continue; }
|
|
379
|
+
if (hostVar !== undefined && varOk(hostVar)) return m[5];
|
|
380
|
+
}
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Inline code that loads a D86 writer module: require(…), import(…), import … from …, node -r/--require/--import. The
|
|
385
|
+
// module path must name the writer where it lives, workspace/lib/<name> (D86_LEDGERS). In PMOS-self, where those are
|
|
386
|
+
// the only modules of those names, lib/<name> and ./<name> count as well. A product's own src/github/issues.js is not
|
|
387
|
+
// a writer, so a kit install loads it freely.
|
|
388
|
+
const D86_NAME = /(?:^|\/)(plan-items|placements|measurements|issues)(?:\.[cm]?js)?$/;
|
|
389
|
+
const REQUIRE_SPEC = /(?:\b(?:require|import)\s*\(?\s*|\bfrom\s+)['"`]([^'"`]+)['"`]/g;
|
|
390
|
+
const LOOSE_REQUIRE = /(?:\b(?:require|import)\s*\(?\s*|\bfrom\s+)['"`][^'"`]*\bworkspace\/lib\/(?:plan-items|placements|measurements|issues)(?:\.[cm]?js)?['"`]/;
|
|
391
|
+
/** The D86 module a module path names (plan-items, placements, measurements or issues), or null. */
|
|
392
|
+
function d86Module(spec, ctx) {
|
|
393
|
+
const m = D86_NAME.exec(String(spec).trim());
|
|
394
|
+
if (!m) return null;
|
|
395
|
+
if (/(?:^|\/)workspace\/lib\/[^/]+$/.test(spec)) return m[1];
|
|
396
|
+
if (ctx?.layout?.kind === 'self' && (/(?:^|\/)lib\/[^/]+$/.test(spec) || /^\.\/[^/]+$/.test(spec))) return m[1];
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
/** The D86 module that inline code requires or imports, or null. */
|
|
400
|
+
function requiredModule(text, ctx) {
|
|
401
|
+
for (const m of String(text).matchAll(REQUIRE_SPEC)) { const w = d86Module(m[1], ctx); if (w) return w; }
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
// The node options that carry inline code, and the ones that load a module. `--opt=value` is split first.
|
|
405
|
+
const INLINE_FLAGS = new Set(['-e', '-p', '--eval', '--print', '-c', '-pe', '-ne']);
|
|
406
|
+
const LOAD_FLAGS = new Set(['-r', '--require', '--import', '--loader', '--experimental-loader']);
|
|
407
|
+
const splitEq = (args) => args.flatMap((a) => { const m = /^(--[A-Za-z][\w-]*)=([\s\S]*)$/.exec(a); return m ? [m[1], m[2]] : [a]; });
|
|
408
|
+
const PM_WRITE_CALL = /\bappendFromUi\s*\(/;
|
|
409
|
+
const D86_USE = 'Write through the pmos verb that owns the ledger (pmos item, pmos initiative, pmos measure, pmos report): it records who wrote the line, and a PM-only line needs the PM\'s own press';
|
|
410
|
+
const writerOfModule = (m) => Object.values(D86_LEDGERS).find((d) => d.writer.endsWith(`/${m}.js`));
|
|
411
|
+
|
|
412
|
+
/** Does this HTTP-client call send a body or a writing method? A plain GET is a read and passes. */
|
|
413
|
+
function httpWrites(verb, args) {
|
|
414
|
+
const text = args.join(' ');
|
|
415
|
+
if (verb === 'curl') {
|
|
416
|
+
if (args.some((a) => /^(-d|--data(-\w+)?|--json|-F|--form(-string)?|-T|--upload-file)$/.test(a) || (/^-[A-Za-z]*[dFT]/.test(a) && !a.startsWith('--')))) return true;
|
|
417
|
+
const x = args.findIndex((a) => a === '-X' || a === '--request');
|
|
418
|
+
const m = x !== -1 ? args[x + 1] : (args.find((a) => /^-X\w+$/.test(a)) ?? '').slice(2);
|
|
419
|
+
return !!m && !/^(GET|HEAD|OPTIONS)$/i.test(m);
|
|
420
|
+
}
|
|
421
|
+
if (verb === 'wget') return /--(post-data|post-file|method|body-data|body-file)\b/.test(text);
|
|
422
|
+
return args.some((a) => /^(POST|PUT|PATCH|DELETE)$/i.test(a) || /^[^-][^=:\s]*:?=/.test(a));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The PM-only findings of one segment (R25(3)). `words` are the segment's words without their wrappers. ctx.vars holds
|
|
427
|
+
* the variables the command assigns, ctx.wsPorts the workspace's ports. `--opt=value` is read as `--opt value`.
|
|
428
|
+
*/
|
|
429
|
+
function pmActFindings(seg, words, verb, rawArgs, findings, raw, ctx) {
|
|
430
|
+
const args = splitEq(rawArgs).map((a) => normalizeUrlText(a, ctx.vars));
|
|
431
|
+
const texts = [];
|
|
432
|
+
if (INTERPRETERS.has(verb)) {
|
|
433
|
+
for (let k = 0; k < args.length; k += 1) {
|
|
434
|
+
if ((INLINE_FLAGS.has(args[k]) || args[k] === '<<<') && args[k + 1] !== undefined) texts.push(args[k + 1]);
|
|
435
|
+
}
|
|
436
|
+
texts.push(...seg.heredocs);
|
|
437
|
+
// A program fed through a pipe (`echo "…" | node`, `… | node -`): the text that feeds it is its code.
|
|
438
|
+
if (typeof seg.stdinText === 'string') texts.push(seg.stdinText);
|
|
439
|
+
}
|
|
440
|
+
// 1 · an HTTP write to a loopback PM-only route, by a client or by inline code
|
|
441
|
+
if (HTTP_CLIENTS.has(verb)) {
|
|
442
|
+
const route = args.map((a) => pmRouteIn(a, ctx)).find(Boolean);
|
|
443
|
+
if (route && httpWrites(verb, args)) findings.push({ kind: 'pm-act', why: `${verb} sends a write to the PM-only route /api/${route}`, act: 'route', raw });
|
|
444
|
+
}
|
|
445
|
+
for (const t of texts) {
|
|
446
|
+
const route = pmRouteIn(t, ctx);
|
|
447
|
+
if (route && /\b(POST|PUT|PATCH|DELETE)\b|\bmethod\b|\bbody\b|\bdata\s*=/i.test(t)) {
|
|
448
|
+
findings.push({ kind: 'pm-act', why: `inline ${verb} code sends a write to the PM-only route /api/${route}`, act: 'route', raw });
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// 2 · a PM-only `pmos` verb: pmos …, node $PMOS_BIN …, node …/bin/pmos.js …, inside env, script, xargs or a launcher
|
|
453
|
+
if (!READ_VERBS.has(verb) && verb !== 'git') {
|
|
454
|
+
const ws = words.filter((w) => !w.op).map((w) => w.t);
|
|
455
|
+
for (let k = 0; k < ws.length; k += 1) {
|
|
456
|
+
const b = base(ws[k]);
|
|
457
|
+
if (!(b === 'pmos' || b === 'pmos.js' || /^\$\{?PMOS_BIN\}?$/.test(ws[k]))) continue;
|
|
458
|
+
const rest = ws.slice(k + 1).filter((a) => !a.startsWith('-'));
|
|
459
|
+
if ((PM_VERBS[rest[0]] ?? []).includes(rest[1])) {
|
|
460
|
+
findings.push({ kind: 'pm-act', why: `pmos ${rest[0]} ${rest[1]} is a PM-only act`, act: 'verb', verb: `pmos ${rest[0]} ${rest[1]}`, raw });
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// 3 · inline code that requires a D86 writer module (bypass e): node -e "require('…/placements.js')…"
|
|
466
|
+
if (INTERPRETERS.has(verb)) {
|
|
467
|
+
const mod = texts.map((t) => requiredModule(t, ctx)).find(Boolean)
|
|
468
|
+
?? args.map((a, k) => (LOAD_FLAGS.has(args[k - 1]) ? d86Module(a, ctx) : null)).find(Boolean);
|
|
469
|
+
if (mod) findings.push({ kind: 'pm-act', why: `inline ${verb} code loads ${writerOfModule(mod)?.writer ?? mod}, a D86 ledger writer`, act: 'require', use: D86_USE, raw });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
303
473
|
function segmentFindings(seg, ctx, findings, depth) {
|
|
304
474
|
const raw = seg.words.map((w) => w.t).join(' ');
|
|
305
475
|
const words = stripWrappers(seg.words);
|
|
@@ -316,6 +486,11 @@ function segmentFindings(seg, ctx, findings, depth) {
|
|
|
316
486
|
for (const h of seg.heredocs) analyzeInto(h, ctx, findings, depth + 1);
|
|
317
487
|
}
|
|
318
488
|
if (verb === 'eval') analyzeInto(args.join(' '), ctx, findings, depth + 1);
|
|
489
|
+
// `script -c "…" /dev/null` (util-linux) runs its command under a new terminal: bypass (b) of R25
|
|
490
|
+
if (verb === 'script') {
|
|
491
|
+
const ci = args.findIndex((a) => /^-[A-Za-z]*c[A-Za-z]*$/.test(a) || a === '--command');
|
|
492
|
+
if (ci !== -1 && args[ci + 1] !== undefined) analyzeInto(args[ci + 1], ctx, findings, depth + 1);
|
|
493
|
+
}
|
|
319
494
|
if (verb === 'source' || verb === '.') { /* a file: not read here */ }
|
|
320
495
|
}
|
|
321
496
|
|
|
@@ -335,6 +510,9 @@ function segmentFindings(seg, ctx, findings, depth) {
|
|
|
335
510
|
}
|
|
336
511
|
if (verb === 'supabase' && CLOUD.test(nonFlags(words.slice(1)).join(' '))) findings.push({ kind: 'cloud', action: `supabase ${nonFlags(words.slice(1)).slice(0, 2).join(' ')}`, raw });
|
|
337
512
|
|
|
513
|
+
// ── PM-only acts ───────────────────────────────────────────────────────────────────────
|
|
514
|
+
pmActFindings(seg, words, verb, args, findings, raw, ctx);
|
|
515
|
+
|
|
338
516
|
// ── the ledgers ────────────────────────────────────────────────────────────────────────
|
|
339
517
|
const scriptName = [words[0], ...words.slice(1, 3)].map((w) => (w ? base(w.t) : '')).find((n) => WRITERS.has(n) || READ_SCRIPTS.has(n)) ?? '';
|
|
340
518
|
if (scriptName === 'record-acceptance.js') findings.push({ kind: 'accept-grants', why: 'record-acceptance.js writes the acceptance ledger', raw });
|
|
@@ -394,7 +572,8 @@ function segmentFindings(seg, ctx, findings, depth) {
|
|
|
394
572
|
else if (WRITERS.has(scriptName) || READ_SCRIPTS.has(scriptName)) readOnly = READ_SCRIPTS.has(scriptName);
|
|
395
573
|
if (!readOnly || redirectsToLedger) {
|
|
396
574
|
const acc = mentions.some((m) => ACCEPT_GRANTS.has(m.name));
|
|
397
|
-
|
|
575
|
+
const d86 = mentions.map((m) => D86_LEDGERS[m.name]).find(Boolean);
|
|
576
|
+
findings.push({ kind: acc ? 'accept-grants' : 'ledger', why: `${verb || 'a redirect'} writes ${mentions[0].path}`, ...(d86 ? { writer: d86.verb } : {}), raw });
|
|
398
577
|
}
|
|
399
578
|
}
|
|
400
579
|
|
|
@@ -441,11 +620,41 @@ function taintedNames(segs, ctx) {
|
|
|
441
620
|
return names;
|
|
442
621
|
}
|
|
443
622
|
|
|
623
|
+
// The variables a command assigns (`U=…; curl $U`, `export U=…`), so a URL or a module path kept in a variable is read
|
|
624
|
+
// as its value. The assignments that start a segment count; the last one wins. Nested commands inherit the outer set.
|
|
625
|
+
const DECLARE = new Set(['export', 'local', 'declare', 'readonly', 'typeset']);
|
|
626
|
+
function assignedVars(segs, outer) {
|
|
627
|
+
const vars = new Map(outer ?? []);
|
|
628
|
+
for (const seg of segs) {
|
|
629
|
+
for (const w of seg.words) {
|
|
630
|
+
if (w.op) break;
|
|
631
|
+
if (DECLARE.has(w.t) || (w.t.startsWith('-') && !w.t.includes('='))) continue;
|
|
632
|
+
const m = /^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/.exec(w.t);
|
|
633
|
+
if (!m) break;
|
|
634
|
+
vars.set(m[1], normalizeUrlText(m[2], vars));
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return vars;
|
|
638
|
+
}
|
|
639
|
+
|
|
444
640
|
function analyzeInto(cmd, ctx, findings, depth) {
|
|
445
|
-
for (const sub of readSubstitutions(cmd)) if (depth < 4) analyzeInto(sub, ctx, findings, depth + 1);
|
|
446
641
|
const segs = parseShell(cmd);
|
|
642
|
+
const vars = assignedVars(segs, ctx.vars);
|
|
643
|
+
for (const sub of readSubstitutions(cmd)) if (depth < 4) analyzeInto(sub, { ...ctx, vars }, findings, depth + 1);
|
|
447
644
|
const tainted = taintedNames(segs, ctx);
|
|
448
|
-
|
|
645
|
+
segs.forEach((seg, k) => {
|
|
646
|
+
if (seg.pipedIn && k > 0) seg.stdinText = [segs[k - 1].words.map((w) => w.t).join(' '), ...segs[k - 1].heredocs].join('\n');
|
|
647
|
+
});
|
|
648
|
+
for (const seg of segs) segmentFindings(seg, { ...ctx, tainted, vars }, findings, depth);
|
|
649
|
+
// A script written and run in one command (bypass e): a here-document that loads a D86 writer, written to a
|
|
650
|
+
// file by any verb, while another segment runs node. A script written by one call and run by a later one is
|
|
651
|
+
// seen only when it is written outside the repository (findingsFor); CI flags the line it writes either way.
|
|
652
|
+
const writtenRequire = segs.find((g) => g.heredocs.some((h) => requiredModule(h, ctx)));
|
|
653
|
+
const runsNode = segs.some((g) => { const w = stripWrappers(g.words); return w.length > 0 && ['node', 'nodejs', 'deno', 'bun'].includes(base0(w[0])); });
|
|
654
|
+
if (writtenRequire && runsNode && !findings.some((f) => f.kind === 'pm-act' && f.act === 'require')) {
|
|
655
|
+
const mod = writtenRequire.heredocs.map((h) => requiredModule(h, ctx)).find(Boolean);
|
|
656
|
+
findings.push({ kind: 'pm-act', why: `a script written and run in one command loads ${writerOfModule(mod)?.writer ?? mod}, a D86 ledger writer`, act: 'require', use: D86_USE, raw: cmd });
|
|
657
|
+
}
|
|
449
658
|
}
|
|
450
659
|
|
|
451
660
|
/**
|
|
@@ -505,6 +714,14 @@ export function findingsFor(e, ctx, settingsText = null) {
|
|
|
505
714
|
if (c.kind === 'settings') { if (touchesPluginEntries(e, settingsText)) out.push({ kind: 'governing', what: c.what, path: c.path }); continue; }
|
|
506
715
|
out.push({ ...c });
|
|
507
716
|
}
|
|
717
|
+
// A script written outside the repository that loads a D86 writer and calls its PM path (bypass e, written
|
|
718
|
+
// first and run later). Inside the repository the writers' own callers and tests live, so it passes there,
|
|
719
|
+
// and CI flags a PM line that such a script writes when it arrives in an agent-authored commit.
|
|
720
|
+
const root = String(ctx.root).replace(/\/+$/, '');
|
|
721
|
+
const outside = pathsOf(e).some((p) => !under(resolvePath(p, root), root));
|
|
722
|
+
const body = [e.content, e.new_string, e.new_source, ...(Array.isArray(e.edits) ? e.edits.map((x) => x?.new_string) : [])].filter((t) => typeof t === 'string').join('\n');
|
|
723
|
+
const mod = requiredModule(body, ctx);
|
|
724
|
+
if (outside && mod && PM_WRITE_CALL.test(body)) out.push({ kind: 'pm-act', why: `a script outside the repository loads ${writerOfModule(mod)?.writer ?? mod} and calls its PM path`, act: 'require', use: D86_USE });
|
|
508
725
|
}
|
|
509
726
|
const other = classifyTool(e, ctx);
|
|
510
727
|
if (other) out.push(other);
|
|
@@ -526,7 +743,9 @@ export function denyMessage(f, ctx) {
|
|
|
526
743
|
const head = 'Blocked by pmos-cockpit:';
|
|
527
744
|
switch (f.kind) {
|
|
528
745
|
case 'ledger':
|
|
529
|
-
return `${head} a hand-written change to a ledger (${f.why ?? f.path ?? 'a *.jsonl file in the state root'}). Write ledger rows with ${writerHint(ctx)}, which validates the row. Reading a ledger is not blocked.`;
|
|
746
|
+
return `${head} a hand-written change to a ledger (${f.why ?? f.path ?? 'a *.jsonl file in the state root'}). Write ledger rows with ${f.writer ?? writerHint(ctx)}, which validates the row. Reading a ledger is not blocked.`;
|
|
747
|
+
case 'pm-act':
|
|
748
|
+
return `${head} ${f.why ?? 'a PM-only act'}. Only the PM does it (R25, D86 point 2). ${f.use ? `${f.use}.` : f.verb ? `Ask the PM to run ${f.verb} at their own terminal, or to press it in the workspace.` : 'Ask the PM to press it in the workspace.'}`;
|
|
530
749
|
case 'accept-grants':
|
|
531
750
|
return `${head} acceptances and grants are the PM's (D12: Acceptance is non-delegable). ${f.why ? `${f.why}. ` : ''}The PM records one by typing /pmos-accept <run_id> pass|fail <note> or /pmos-grant <scope>, or by pressing the button. Ask the PM to do it.`;
|
|
532
751
|
case 'governing':
|
|
@@ -556,6 +775,7 @@ export function looseDeny(e) {
|
|
|
556
775
|
const c = e.command;
|
|
557
776
|
if (/\bgh\b[^\n;|&]*\b(pr\s+merge|api)\b|\bnpm\b[^\n;|&]*\bpublish\b|\b(pnpm|yarn)\b[^\n;|&]*\bpublish\b|\bgit\b[^\n;|&]*\bpush\b|\bsupabase\b[^\n;|&]*\b(db push|functions deploy|projects create|secrets set)\b/.test(c)) return say('a merge, push or publish');
|
|
558
777
|
if (/\.jsonl\b|record-acceptance|record-grant|pmos-log\.sh\s+(accept|grant)|pmos\/mods|pmos-cockpit\.json|settings\.json/.test(c)) return say('a write that names a ledger or governing file');
|
|
778
|
+
if (pmRouteIn(c, null) || LOOSE_REQUIRE.test(c) || /\b(initiative\s+(now|adopt|anchor|lifecycle)|report\s+issue|board\s+release)\b/.test(c)) return say('a PM-only act');
|
|
559
779
|
return null;
|
|
560
780
|
}
|
|
561
781
|
if (isFileWriteTool(tool)) {
|
|
@@ -4,7 +4,7 @@ import type { Register } from 'claude-code'
|
|
|
4
4
|
import type { CockpitStatus, SkillsReport } from '../types'
|
|
5
5
|
import { checkGrant } from './lib/grants.mjs'
|
|
6
6
|
import { parseAccept, pressNote, planAcceptance, planGrant, writerOf } from './lib/commands.mjs'
|
|
7
|
-
import { PATH_KEYS, classifyTool, denyMerge, denyMessage, findingsFor, isFileWriteTool, looseDeny, readsAsApproval } from './lib/guard.mjs'
|
|
7
|
+
import { PATH_KEYS, classifyTool, denyMerge, denyMessage, findingsFor, isFileWriteTool, looseDeny, readsAsApproval, wsPortsOf } from './lib/guard.mjs'
|
|
8
8
|
import { recordPath, resolveInitiative } from './lib/initiative.mjs'
|
|
9
9
|
import { detectLayout } from './lib/layout.mjs'
|
|
10
10
|
import { parseProfile } from './lib/profile.mjs'
|
|
@@ -54,6 +54,14 @@ async function sh($: any, argv: string[], init?: any): Promise<{ exitCode: numbe
|
|
|
54
54
|
try { return await $.process.run(argv, init) } catch (e: any) { return { exitCode: 1, stdout: '', stderr: String(e?.message ?? e) } }
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// The workspace's ports for the PM-only route check: the defaults, plus the port of PMOS_ROUTE_URL and a pinned
|
|
58
|
+
// PMOS_WS_PORT from this session's environment. A product's own server on another port is not the workspace.
|
|
59
|
+
async function wsPorts($: any): Promise<number[]> {
|
|
60
|
+
const env: Record<string, string | undefined> = {}
|
|
61
|
+
for (const k of ['PMOS_ROUTE_URL', 'PMOS_WS_PORT']) { try { env[k] = await $.env.get(k) } catch { env[k] = undefined } }
|
|
62
|
+
return wsPortsOf(env)
|
|
63
|
+
}
|
|
64
|
+
|
|
57
65
|
async function rootAndLayout($: any) {
|
|
58
66
|
const root: string = await $.session.root()
|
|
59
67
|
const layout = await detectLayout({ exists: (p: string) => $.fs.exists(p) }, root)
|
|
@@ -156,7 +164,7 @@ async function judge($: any, e: any): Promise<string | null> {
|
|
|
156
164
|
const { root, layout } = await rootAndLayout($)
|
|
157
165
|
const needsBranch = (tool === 'Bash' && /\bpush\b/.test(String(e.command ?? ''))) || tool.startsWith('mcp__github')
|
|
158
166
|
let facts = needsBranch ? await gitFacts($, root) : { branch: null, defaultBranch: null, commitsAhead: null }
|
|
159
|
-
const ctx = { root, layout, branch: facts.branch, defaultBranch: facts.defaultBranch }
|
|
167
|
+
const ctx = { root, layout, branch: facts.branch, defaultBranch: facts.defaultBranch, wsPorts: await wsPorts($) }
|
|
160
168
|
const settingsPath = `${root}/.claude/settings.json`
|
|
161
169
|
const settingsText = isFileWriteTool(tool) ? await readText($, settingsPath) : null
|
|
162
170
|
const findings = findingsFor(e, ctx, settingsText)
|
|
@@ -175,7 +183,7 @@ async function judge($: any, e: any): Promise<string | null> {
|
|
|
175
183
|
}
|
|
176
184
|
if (findings.length === 0) return null
|
|
177
185
|
|
|
178
|
-
for (const kind of ['accept-grants', 'ledger', 'governing', 'cloud']) {
|
|
186
|
+
for (const kind of ['accept-grants', 'pm-act', 'ledger', 'governing', 'cloud']) {
|
|
179
187
|
const f = findings.find((x) => x.kind === kind)
|
|
180
188
|
if (f) return denyMessage(f, ctx)
|
|
181
189
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T00:08:33Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T00:08:33Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|