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,271 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* plan-items-stage.js — the stage of each plan item, and the Build rule, for the workflow engine (pm-os-absorb Run 4,
|
|
3
|
+
* PRD R6, map-spec X29).
|
|
4
|
+
*
|
|
5
|
+
* foldStages(text) → { items: { [id]: { initiative, stage } }, withdrawn: [id…] }
|
|
6
|
+
* readFactory(text) → { items: { [workItemId]: { initiative, plan_item, integrated, withdrawn } } }
|
|
7
|
+
* mainTree(root) → the main working tree of root's repository (root itself when it is not linked)
|
|
8
|
+
* buildRule({ planText, workText, initiative })
|
|
9
|
+
* → { applies, materialized: [id…], integrated: [id…], pending: [id…] }
|
|
10
|
+
* buildRuleFor(root, id) → the same, read from the repository's two ledgers; { applies: false } when neither
|
|
11
|
+
* file is there
|
|
12
|
+
* planItemsPath(root) · workItemsPath(root)
|
|
13
|
+
*
|
|
14
|
+
* THE BUILD RULE (R6). Items never complete a stage, with one exception: a code initiative's Build stage is done when
|
|
15
|
+
* every MATERIALIZED item under it is integrated. A materialized item is a work item whose `declared` line names a
|
|
16
|
+
* plan item (`plan_item`, D86 point 1) that sits in the Build stage of the same initiative and is not withdrawn. A
|
|
17
|
+
* work item that is withdrawn in the factory ledger is not counted. When an initiative has no materialized item under
|
|
18
|
+
* Build, the rule does not apply and the engine decides the Build step from the commits, as before.
|
|
19
|
+
*
|
|
20
|
+
* WHY THIS FILE EXISTS (X29). The engine (scripts/workflow-state.js) is vendored into the workspace bundle and into
|
|
21
|
+
* the kit, so it cannot require workspace/lib/plan-items.js. This module reads the same ledger with a small pure fold
|
|
22
|
+
* of its own. workspace/plan-items-stage-parity.test.js holds it to plan-items.js's fold (stage, withdrawn) and to
|
|
23
|
+
* scripts/work-item.js's fold (integrated, withdrawn) on every fixture, so a rule added to one fold and not the
|
|
24
|
+
* other fails a test instead of making CI and the workspace disagree.
|
|
25
|
+
*
|
|
26
|
+
* DELIBERATELY FLAT in scripts/, not scripts/lib/ (read-jsonl.js has the same note): the kit and the workspace bundle
|
|
27
|
+
* vendor the engine's require closure by BASENAME into one directory, so a `./lib/` require from the engine would
|
|
28
|
+
* load in this checkout and break in every vendored copy.
|
|
29
|
+
*
|
|
30
|
+
* The factory half is D65's ORDER, restated small: lines sorted by instant (ledger order breaks a tie), the type's
|
|
31
|
+
* chain (scripts/work-item.js CHAIN, chainFor), and a two-outcome node satisfied by its LATEST outcome (failed revokes
|
|
32
|
+
* verified, released revokes claimed). A line that skips its predecessor is not counted, the same as deriveItems
|
|
33
|
+
* discards it, so a hand-appended `integrated` with no `verified` before it never makes the Build step done. It does
|
|
34
|
+
* not restate the per-field schema: scripts/check-work-items.js fails CI on a schema-invalid line. Restated, not
|
|
35
|
+
* required, because work-item.js cannot be vendored (its sink guard requires ./lib/sink.js); the parity test holds the
|
|
36
|
+
* two folds to one answer, on valid and on invalid ledgers.
|
|
37
|
+
*
|
|
38
|
+
* BOTH LEDGERS ARE READ FROM THE MAIN WORKING TREE, as lib/plan-items.js, lib/plan-ledgers.js and lib/factory-join.js
|
|
39
|
+
* read them. From a linked worktree (where every factory agent works) the engine, check-work-items and the workspace
|
|
40
|
+
* then read the same two files. mainTree is the filesystem-only walk of lib/plan-tree.js mainTree, restated here.
|
|
41
|
+
*
|
|
42
|
+
* Pure apart from the two file reads in buildRuleFor. No network, no git, no clock. Zero dependencies, stock Node.
|
|
43
|
+
*/
|
|
44
|
+
const fs = require('fs');
|
|
45
|
+
const path = require('path');
|
|
46
|
+
|
|
47
|
+
// ── the plan-items schema, as workspace/lib/plan-items.js lineErr states it ──────────────────────────────────────
|
|
48
|
+
// Every line that lib/plan-items.js ignores must be ignored here too, or the two folds disagree about which line of
|
|
49
|
+
// a repeated event id wins. So the whole stored-line schema is restated, not only the stage fields.
|
|
50
|
+
const EVENTS = ['planned', 'set', 'moved', 'flagged', 'withdrawn', 'kept'];
|
|
51
|
+
const STATES = ['todo', 'doing', 'review', 'done'];
|
|
52
|
+
const SET_FIELDS = ['title', 'stage', 'order', 'owner', 'after', 'start', 'due', 'milestone', 'note'];
|
|
53
|
+
const EVIDENCE_KINDS = ['commit', 'pr', 'file', 'session', 'note'];
|
|
54
|
+
const ID_RE = /^(.+)\.([0-9a-z]{10})$/;
|
|
55
|
+
const INIT_RE = /^[A-Za-z0-9][\w.-]{0,119}$/;
|
|
56
|
+
const EID_RE = /^[0-9a-z]{12}$/;
|
|
57
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
58
|
+
const AT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
|
|
59
|
+
const BY_RE = /^(?:pm:[a-z0-9_-]{1,40}|agent:unknown|agent:sess-\d+\.[0-9a-f]{8})$/;
|
|
60
|
+
|
|
61
|
+
const isStr = (v) => typeof v === 'string';
|
|
62
|
+
const oneLine = (v, max) => isStr(v) && v.trim().length >= 1 && v.length <= max && !/[\r\n]/.test(v);
|
|
63
|
+
const initiativeOf = (id) => { const m = ID_RE.exec(String(id || '')); return m ? m[1] : null; };
|
|
64
|
+
function ownerOk(v) {
|
|
65
|
+
if (v === 'you' || v === 'agent') return true;
|
|
66
|
+
const m = /^(person|tracker):(.+)$/s.exec(String(v));
|
|
67
|
+
return !!m && m[2].length >= 1 && m[2].length <= 60 && !/[\r\n]/.test(m[2]);
|
|
68
|
+
}
|
|
69
|
+
const dateOk = (v) => v === null || (isStr(v) && DATE_RE.test(v) && (() => {
|
|
70
|
+
const t = Date.parse(v + 'T00:00:00Z');
|
|
71
|
+
return Number.isFinite(t) && new Date(t).toISOString().slice(0, 10) === v;
|
|
72
|
+
})());
|
|
73
|
+
function evidenceBad(e) {
|
|
74
|
+
if (!e || typeof e !== 'object' || Array.isArray(e) || !EVIDENCE_KINDS.includes(e.kind) || !isStr(e.ref)) return true;
|
|
75
|
+
const ref = e.ref;
|
|
76
|
+
if (e.kind === 'commit') return !/^[0-9a-f]{7,40}$/i.test(ref);
|
|
77
|
+
if (e.kind === 'pr') return !/^#?\d+$/.test(ref);
|
|
78
|
+
if (e.kind === 'file') return !ref || ref.length > 300 || path.isAbsolute(ref) || ref.split(/[\\/]/).includes('..') || /[\r\n]/.test(ref);
|
|
79
|
+
if (e.kind === 'session') return !/^sess-\d+$/.test(ref);
|
|
80
|
+
return !oneLine(ref, 200);
|
|
81
|
+
}
|
|
82
|
+
function fieldBad(k, v) {
|
|
83
|
+
switch (k) {
|
|
84
|
+
case 'title': return !oneLine(v, 200);
|
|
85
|
+
case 'stage': return !(isStr(v) && v.trim());
|
|
86
|
+
case 'order': return !(typeof v === 'number' && Number.isFinite(v));
|
|
87
|
+
case 'owner': return !ownerOk(v);
|
|
88
|
+
case 'after': return !(Array.isArray(v) && v.every((x) => isStr(x) && ID_RE.test(x)));
|
|
89
|
+
case 'start': case 'due': return !dateOk(v);
|
|
90
|
+
case 'milestone': return typeof v !== 'boolean';
|
|
91
|
+
case 'note': return !(v === null || (isStr(v) && v.length <= 1000));
|
|
92
|
+
default: return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** true when lib/plan-items.js would ignore this stored line as schema-invalid. */
|
|
96
|
+
function lineBad(r) {
|
|
97
|
+
if (!r || typeof r !== 'object' || Array.isArray(r)) return true;
|
|
98
|
+
if (r.v !== 1 || !isStr(r.eid) || !EID_RE.test(r.eid) || !EVENTS.includes(r.ev)) return true;
|
|
99
|
+
if (!isStr(r.by) || !BY_RE.test(r.by) || (r.via !== 'ui' && r.via !== 'cli')) return true;
|
|
100
|
+
if (!isStr(r.at) || !AT_RE.test(r.at) || Number.isNaN(Date.parse(r.at))) return true;
|
|
101
|
+
if (!isStr(r.initiative) || !INIT_RE.test(r.initiative)) return true;
|
|
102
|
+
if (r.ev !== 'kept' && (!isStr(r.item) || !ID_RE.test(r.item) || initiativeOf(r.item) !== r.initiative)) return true;
|
|
103
|
+
switch (r.ev) {
|
|
104
|
+
case 'planned': return SET_FIELDS.some((k) => fieldBad(k, r[k]));
|
|
105
|
+
case 'set': {
|
|
106
|
+
const f = r.fields;
|
|
107
|
+
if (!f || typeof f !== 'object' || Array.isArray(f) || !Object.keys(f).length) return true;
|
|
108
|
+
return Object.keys(f).some((k) => !SET_FIELDS.includes(k)
|
|
109
|
+
|| (!((k === 'start' || k === 'due' || k === 'note') && f[k] === null) && fieldBad(k, f[k])));
|
|
110
|
+
}
|
|
111
|
+
case 'moved':
|
|
112
|
+
if (!STATES.includes(r.to)) return true;
|
|
113
|
+
if (r.evidence !== undefined && evidenceBad(r.evidence)) return true;
|
|
114
|
+
if (r.override !== undefined && (r.override !== true || !String(r.by).startsWith('pm:'))) return true;
|
|
115
|
+
return false;
|
|
116
|
+
case 'flagged': return r.blocked === true ? !oneLine(r.reason, 200) : r.blocked !== false;
|
|
117
|
+
case 'withdrawn': return false;
|
|
118
|
+
case 'kept':
|
|
119
|
+
return r.item !== undefined || !isStr(r.batch) || !/^agent:/.test(r.batch)
|
|
120
|
+
|| !Array.isArray(r.items) || !r.items.length || !r.items.every((x) => isStr(x) && ID_RE.test(x));
|
|
121
|
+
default: return true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The plan-items ledger text → each live item's initiative and stage, and the withdrawn ids. The same order-free
|
|
127
|
+
* fold as lib/plan-items.js: drop byte-identical duplicates, sort by (at, eid, line), keep the first line of an event
|
|
128
|
+
* id, ignore a second `planned` of one id and every line after a withdraw.
|
|
129
|
+
*/
|
|
130
|
+
function foldStages(text) {
|
|
131
|
+
const seenRaw = new Set();
|
|
132
|
+
const lines = [];
|
|
133
|
+
for (const raw0 of String(text == null ? '' : text).split('\n')) {
|
|
134
|
+
const raw = raw0.replace(/\r$/, '').trim();
|
|
135
|
+
if (!raw || seenRaw.has(raw)) continue;
|
|
136
|
+
let rec;
|
|
137
|
+
try { rec = JSON.parse(raw); } catch { continue; }
|
|
138
|
+
if (lineBad(rec)) continue;
|
|
139
|
+
seenRaw.add(raw);
|
|
140
|
+
lines.push({ raw, rec });
|
|
141
|
+
}
|
|
142
|
+
lines.sort((a, b) => (Date.parse(a.rec.at) - Date.parse(b.rec.at))
|
|
143
|
+
|| (a.rec.eid < b.rec.eid ? -1 : a.rec.eid > b.rec.eid ? 1 : 0)
|
|
144
|
+
|| (a.raw < b.raw ? -1 : a.raw > b.raw ? 1 : 0));
|
|
145
|
+
const seenEid = new Set();
|
|
146
|
+
const items = {};
|
|
147
|
+
const withdrawn = new Set();
|
|
148
|
+
for (const { rec } of lines) {
|
|
149
|
+
if (seenEid.has(rec.eid)) continue;
|
|
150
|
+
seenEid.add(rec.eid);
|
|
151
|
+
if (rec.ev === 'kept') continue;
|
|
152
|
+
if (rec.ev === 'planned') {
|
|
153
|
+
if (items[rec.item] || withdrawn.has(rec.item)) continue;
|
|
154
|
+
items[rec.item] = { initiative: rec.initiative, stage: rec.stage };
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const it = items[rec.item];
|
|
158
|
+
if (!it || withdrawn.has(rec.item)) continue;
|
|
159
|
+
if (rec.ev === 'set' && Object.prototype.hasOwnProperty.call(rec.fields, 'stage')) it.stage = rec.fields.stage;
|
|
160
|
+
if (rec.ev === 'withdrawn') { delete items[rec.item]; withdrawn.add(rec.item); }
|
|
161
|
+
}
|
|
162
|
+
return { items, withdrawn: [...withdrawn].sort() };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* D65(d)'s chain per type (scripts/work-item.js CHAIN, chainFor, NEGATED_BY, standsSatisfied), restated. */
|
|
166
|
+
const FACTORY_EVENTS = ['declared', 'claimed', 'released', 'verified', 'failed', 'integrated', 'shipped', 'withdrawn'];
|
|
167
|
+
const CHAIN_DEFAULT = ['claimed', 'verified', 'integrated', 'shipped'];
|
|
168
|
+
const CHAIN_HOTFIX = ['claimed', 'verified', 'shipped', 'integrated'];
|
|
169
|
+
const chainFor = (type) => (type === 'spike-item' ? CHAIN_DEFAULT.filter((e) => e !== 'verified') : type === 'hotfix' ? CHAIN_HOTFIX : CHAIN_DEFAULT);
|
|
170
|
+
const NEGATED_BY = { verified: 'failed', claimed: 'released' };
|
|
171
|
+
function standsSatisfied(history, need) {
|
|
172
|
+
const negator = NEGATED_BY[need];
|
|
173
|
+
if (!negator) return history.includes(need);
|
|
174
|
+
for (let i = history.length - 1; i >= 0; i -= 1) {
|
|
175
|
+
if (history[i] === need) return true;
|
|
176
|
+
if (history[i] === negator) return false;
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The factory ledger text → per work item: its initiative, its plan item, and whether it is integrated or withdrawn. */
|
|
182
|
+
function readFactory(text) {
|
|
183
|
+
const lines = [];
|
|
184
|
+
String(text == null ? '' : text).split('\n').forEach((raw, idx) => {
|
|
185
|
+
if (!raw.trim()) return;
|
|
186
|
+
let rec;
|
|
187
|
+
try { rec = JSON.parse(raw); } catch { return; }
|
|
188
|
+
if (!rec || typeof rec !== 'object' || !isStr(rec.item_id) || !isStr(rec.initiative_id) || !FACTORY_EVENTS.includes(rec.event)) return;
|
|
189
|
+
const t = typeof rec.at === 'string' ? Date.parse(rec.at) : NaN;
|
|
190
|
+
if (Number.isNaN(t)) return;
|
|
191
|
+
lines.push({ rec, idx, t });
|
|
192
|
+
});
|
|
193
|
+
lines.sort((a, b) => a.t - b.t || a.idx - b.idx);
|
|
194
|
+
const items = {}; const fold = {};
|
|
195
|
+
for (const { rec } of lines) {
|
|
196
|
+
const id = rec.item_id;
|
|
197
|
+
if (rec.event === 'declared') {
|
|
198
|
+
if (items[id]) continue; // declared twice: the fold keeps the first
|
|
199
|
+
items[id] = { initiative: rec.initiative_id, plan_item: isStr(rec.plan_item) ? rec.plan_item : null, integrated: false, withdrawn: false };
|
|
200
|
+
fold[id] = { type: rec.type, history: ['declared'] };
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const it = items[id];
|
|
204
|
+
if (!it || it.withdrawn) continue;
|
|
205
|
+
const chain = chainFor(fold[id].type);
|
|
206
|
+
const pos = chain.indexOf(rec.event === 'failed' ? 'verified' : rec.event);
|
|
207
|
+
if (pos > 0 && !standsSatisfied(fold[id].history, chain[pos - 1])) continue; // out of order: deriveItems discards it
|
|
208
|
+
fold[id].history.push(rec.event);
|
|
209
|
+
if (rec.event === 'integrated') it.integrated = true;
|
|
210
|
+
if (rec.event === 'withdrawn') it.withdrawn = true;
|
|
211
|
+
}
|
|
212
|
+
return { items };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The Build rule over the two ledgers' text, for one initiative. */
|
|
216
|
+
function buildRule({ planText, workText, initiative }) {
|
|
217
|
+
const plan = foldStages(planText);
|
|
218
|
+
const work = readFactory(workText);
|
|
219
|
+
const materialized = []; const integrated = []; const pending = [];
|
|
220
|
+
for (const [wid, w] of Object.entries(work.items)) {
|
|
221
|
+
if (w.initiative !== initiative || w.withdrawn || !w.plan_item) continue;
|
|
222
|
+
const p = plan.items[w.plan_item];
|
|
223
|
+
if (!p || p.initiative !== initiative || p.stage !== 'build') continue;
|
|
224
|
+
materialized.push(wid);
|
|
225
|
+
(w.integrated ? integrated : pending).push(wid);
|
|
226
|
+
}
|
|
227
|
+
const sort = (a) => a.sort();
|
|
228
|
+
return { applies: materialized.length > 0, materialized: sort(materialized), integrated: sort(integrated), pending: sort(pending) };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The main working tree of root's repository: root itself, unless root is a linked worktree. Filesystem only. */
|
|
232
|
+
function mainTree(root) {
|
|
233
|
+
try {
|
|
234
|
+
const dotgit = path.join(root, '.git');
|
|
235
|
+
if (fs.statSync(dotgit).isDirectory()) return root;
|
|
236
|
+
const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dotgit, 'utf8'));
|
|
237
|
+
if (!m) return root;
|
|
238
|
+
const gitdir = path.resolve(root, m[1].trim());
|
|
239
|
+
const common = path.resolve(gitdir, fs.readFileSync(path.join(gitdir, 'commondir'), 'utf8').trim());
|
|
240
|
+
if (path.basename(common) !== '.git') return root;
|
|
241
|
+
const main = path.dirname(common);
|
|
242
|
+
return fs.statSync(path.join(main, '.git')).isDirectory() ? main : root;
|
|
243
|
+
} catch { return root; }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The plan-items ledger of a repository: planning/runs/ for PMOS-self, pmos/state/ in a kit (workflow-state layout). */
|
|
247
|
+
function planItemsPath(root) {
|
|
248
|
+
const main = mainTree(path.resolve(String(root)));
|
|
249
|
+
const self = path.join(main, 'planning', 'initiatives');
|
|
250
|
+
const kit = path.join(main, 'pmos', 'state', 'initiatives');
|
|
251
|
+
const isKit = !fs.existsSync(self) && fs.existsSync(kit);
|
|
252
|
+
return isKit ? path.join(main, 'pmos', 'state', 'plan-items.jsonl') : path.join(main, 'planning', 'runs', 'plan-items.jsonl');
|
|
253
|
+
}
|
|
254
|
+
/** The D65 factory ledger of a repository (the one scripts/check-work-items.js reads), in the main working tree. */
|
|
255
|
+
const workItemsPath = (root) => path.join(mainTree(path.resolve(String(root))), 'planning', 'work-items.jsonl');
|
|
256
|
+
|
|
257
|
+
function readText(file) {
|
|
258
|
+
try { return { ok: true, text: fs.readFileSync(file, 'utf8') }; }
|
|
259
|
+
catch (e) { return { ok: e && e.code === 'ENOENT', text: '', missing: true, error: e && e.code !== 'ENOENT' ? String(e.message || e) : null }; }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function buildRuleFor(root, initiative) {
|
|
263
|
+
const work = readText(workItemsPath(root));
|
|
264
|
+
if (work.missing && work.ok) return { applies: false, materialized: [], integrated: [], pending: [] };
|
|
265
|
+
if (!work.ok) return { applies: false, unknown: true, reason: `the factory ledger could not be read: ${work.error}`, materialized: [], integrated: [], pending: [] };
|
|
266
|
+
const plan = readText(planItemsPath(root));
|
|
267
|
+
if (!plan.ok) return { applies: false, unknown: true, reason: `the plan-items ledger could not be read: ${plan.error}`, materialized: [], integrated: [], pending: [] };
|
|
268
|
+
return buildRule({ planText: plan.text, workText: work.text, initiative });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
module.exports = { foldStages, readFactory, buildRule, buildRuleFor, mainTree, planItemsPath, workItemsPath, lineBad };
|
|
@@ -151,7 +151,7 @@ case "$cmd" in
|
|
|
151
151
|
nonempty note "$4"
|
|
152
152
|
run_exists "$1" || fail "run_id '$1' is not in state/runs.jsonl — log the run first (pmos-log.sh run ...), then accept it."
|
|
153
153
|
acc=true; [ "$3" = fail ] && acc=false
|
|
154
|
-
append acceptances.jsonl "{\"run_id\":$(js "$1"),\"initiative_id\":$(js "$2"),\"accepted\":$acc,\"pm_label\":$(js "$3"),\"note\":$(js "${4:-}"),\"date\":\"$today\"}"
|
|
154
|
+
append acceptances.jsonl "{\"run_id\":$(js "$1"),\"initiative_id\":$(js "$2"),\"accepted\":$acc,\"pm_label\":$(js "$3"),\"note\":$(js "${4:-}"),\"date\":\"$today\",\"via\":\"cli\"}"
|
|
155
155
|
;;
|
|
156
156
|
outcome)
|
|
157
157
|
[ $# -ge 2 ] || fail "usage: outcome <initiative_id> <validated|refuted|inconclusive> [evidence]"
|
|
@@ -169,7 +169,7 @@ case "$cmd" in
|
|
|
169
169
|
echo "✅ appended to state/discoveries.md: - $today $* #friction"
|
|
170
170
|
;;
|
|
171
171
|
grant)
|
|
172
|
-
[ $# -ge 3 ] || fail "usage: grant <merge:<PR or branch>|publish:<artifact
|
|
172
|
+
[ $# -ge 3 ] || fail "usage: grant <merge:<PR or branch>|publish:<artifact>|release:<initiative>:<batch-id>|lifecycle-bulk:<state>:<id>,…> <pm> <instruction> [ttl_minutes] [initiative_id]"
|
|
173
173
|
nonempty pm "$2"; nonempty instruction "$3"
|
|
174
174
|
row="$(python3 - "$1" "$2" "$3" "${4:-120}" "${5:-}" "$STATE" <<'PY'
|
|
175
175
|
import datetime, json, os, re, secrets, sys
|
|
@@ -178,12 +178,23 @@ def die(m):
|
|
|
178
178
|
sys.stderr.write("pmos-log: ERROR — %s\n" % m); sys.exit(1)
|
|
179
179
|
if re.match(r"^(docs-sync|outward)(:|$)", scope):
|
|
180
180
|
die('"%s" is a standing scope. Standing grants are not allowed in this release (D81). Mint one single-use grant per action.' % scope)
|
|
181
|
+
# The same scopes and target patterns as scripts/record-grant.js and the cockpit mod's grants.mjs (D81;
|
|
182
|
+
# D86 point 2 added release and lifecycle-bulk). scripts/tests/record-grant.test.js runs one table through all three.
|
|
183
|
+
SLUG = r"[a-z0-9][a-z0-9._-]{0,99}"
|
|
184
|
+
PATS = {
|
|
185
|
+
"merge": r"^(?:[0-9]{1,9}|[A-Za-z0-9][A-Za-z0-9._/-]{0,79})$",
|
|
186
|
+
"publish": r"^[A-Za-z0-9@][A-Za-z0-9@._/-]{0,99}$",
|
|
187
|
+
"release": r"^" + SLUG + r":[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
|
|
188
|
+
"lifecycle-bulk": r"^(?:active|closed|on-hold|cancelled):" + SLUG + r"(?:," + SLUG + r"){0,19}$",
|
|
189
|
+
}
|
|
190
|
+
WHAT = {"merge": "PR number or branch name", "publish": "artifact name", "release": "<initiative>:<batch-id>",
|
|
191
|
+
"lifecycle-bulk": "<state>:<id>,<id>,… (one of active, closed, on-hold, cancelled, then 1 to 20 initiative ids)"}
|
|
181
192
|
kind, sep, target = scope.partition(":")
|
|
182
|
-
if not sep or kind not in
|
|
183
|
-
die('unknown scope "%s". Use merge:<PR number or branch> or
|
|
184
|
-
|
|
185
|
-
if not re.match(
|
|
186
|
-
die('"%s" is not a single %s. A grant names one target; a wildcard is refused.' % (target,
|
|
193
|
+
if not sep or kind not in PATS:
|
|
194
|
+
die('unknown scope "%s". Use merge:<PR number or branch>, publish:<artifact>, release:<initiative>:<batch-id> or lifecycle-bulk:<state>:<id>,<id>,….' % scope)
|
|
195
|
+
ids = target.partition(":")[2].split(",") if kind == "lifecycle-bulk" else []
|
|
196
|
+
if not re.match(PATS[kind] + r"\Z", target) or "*" in target or ".." in target or len(set(ids)) != len(ids):
|
|
197
|
+
die('"%s" is not a single %s. A grant names one target; a wildcard is refused.' % (target, WHAT[kind]))
|
|
187
198
|
if not re.match(r"^[0-9]{1,4}$", ttl) or not (1 <= int(ttl) <= 1440):
|
|
188
199
|
die("ttl_minutes must be a whole number from 1 to 1440, got '%s'." % ttl)
|
|
189
200
|
if initiative:
|
|
@@ -1067,8 +1067,24 @@ function computeWorkflowDerived(id, opts) {
|
|
|
1067
1067
|
* commits in this range" is a statement about the RANGE, and on a branch level with `main` that
|
|
1068
1068
|
* range is legitimately empty. A reader who cannot tell those apart calls the second one "never
|
|
1069
1069
|
* built", which is the console's half of this defect.
|
|
1070
|
+
*
|
|
1071
|
+
* THE BUILD RULE (PRD R6, pm-os-absorb Run 4). Items never complete a stage, with one exception: when plan items
|
|
1072
|
+
* under Build were materialized (a D65 `declared` line names them as `plan_item`, D86 point 1), the step is done
|
|
1073
|
+
* when every one of those work items is integrated, and pending until then, whatever the commits say. With no
|
|
1074
|
+
* materialized item the commits decide, as above. The rule reads both ledgers through plan-items-stage.js, the
|
|
1075
|
+
* engine's own pure fold (X29: the engine cannot require workspace/lib). A broken delivered_by stays blocked first.
|
|
1070
1076
|
*/
|
|
1071
|
-
|
|
1077
|
+
const buildRule = require('./plan-items-stage').buildRuleFor(root, id);
|
|
1078
|
+
const ruleStep = build.failedDeclaration || !(buildRule.applies || buildRule.unknown) ? null
|
|
1079
|
+
: buildRule.unknown
|
|
1080
|
+
? step('build', 'build', 'unknown', buildRule.reason)
|
|
1081
|
+
: buildRule.pending.length === 0
|
|
1082
|
+
? step('build', 'build', 'done',
|
|
1083
|
+
`every materialized item under Build is integrated (${buildRule.integrated.length} of ${buildRule.materialized.length} in planning/work-items.jsonl)`)
|
|
1084
|
+
: step('build', 'build', 'pending',
|
|
1085
|
+
`${buildRule.integrated.length} of ${buildRule.materialized.length} materialized item(s) under Build integrated — waiting on ${buildRule.pending.join(', ')}`,
|
|
1086
|
+
'merge each work item with a `Work-Item: <id>` trailer on the trunk, then run `pmos verify-work`');
|
|
1087
|
+
steps.push(ruleStep || (build.unknown
|
|
1072
1088
|
? step('build', 'build', 'unknown', build.reason || range.reason)
|
|
1073
1089
|
: build.failedDeclaration
|
|
1074
1090
|
// REPORTED, never CI-blocking — the same stance R1 takes on an unresolvable anchor. Making a
|
|
@@ -1087,7 +1103,7 @@ function computeWorkflowDerived(id, opts) {
|
|
|
1087
1103
|
: 'no implementation commits in this range')
|
|
1088
1104
|
: step('build', 'build', 'done', build.signal
|
|
1089
1105
|
? `${build.count} commit(s) on implementation paths — ${delivery.attributionEvidence(build.signal, id, build.declaredSha)}`
|
|
1090
|
-
: `${build.count} commit(s) on implementation paths`));
|
|
1106
|
+
: `${build.count} commit(s) on implementation paths`)));
|
|
1091
1107
|
|
|
1092
1108
|
// ── quality ─ CI's verdict, never this engine's ──────────────────────────────────────────────
|
|
1093
1109
|
steps.push(step('quality', 'quality', 'unknown', 'CI decides on the PR — this engine never claims a test result it did not run'));
|
|
@@ -1325,9 +1341,19 @@ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, re
|
|
|
1325
1341
|
const accepted = cachedJsonl(path.join(L.runsDir, 'acceptances.jsonl'))
|
|
1326
1342
|
.filter((a) => a && a.initiative_id === id && a.accepted === true);
|
|
1327
1343
|
const acceptedOn = accepted.length ? (accepted[accepted.length - 1].accepted_at || accepted[accepted.length - 1].at) : null;
|
|
1344
|
+
// D86 point 3 — a report is approved when the PM issues it: an `issued` line in issues.jsonl naming its doc_path.
|
|
1345
|
+
// Its own minimal parse (X29: the vendored engine never requires workspace/lib); a line counts only if the PM wrote it.
|
|
1346
|
+
const norm = (p) => String(p || '').trim().replace(/^\.\//, '');
|
|
1347
|
+
const issued = effLane !== 'report' || !docPath ? []
|
|
1348
|
+
: cachedJsonl(path.join(L.runsDir, 'issues.jsonl')).filter((r) => r && r.ev === 'issued' && norm(r.path) === norm(docPath)
|
|
1349
|
+
&& /^pm:/.test(String(r.by || '')) && (r.via === 'ui' || r.via === 'cli') && /^[0-9a-f]{40}$/.test(String(r.sha || '')));
|
|
1328
1350
|
steps.push(!flows('approved') ? skipped('approved', 'approved')
|
|
1351
|
+
: issued.length
|
|
1352
|
+
? step('approved', 'approved', 'done', `issued by the PM on ${String(issued[0].at || '').slice(0, 10)} (issues.jsonl)`)
|
|
1329
1353
|
: accepted.length
|
|
1330
1354
|
? step('approved', 'approved', 'done', `accepted by the PM${acceptedOn ? ' on ' + String(acceptedOn).slice(0, 10) : ''}`)
|
|
1355
|
+
: effLane === 'report'
|
|
1356
|
+
? step('approved', 'approved', 'pending', 'awaiting the PM\'s Issue (D86 point 3)', 'the PM issues the report (pmos report issue, or Issue on Reports) — never automatic')
|
|
1331
1357
|
: step('approved', 'approved', 'pending', 'awaiting the PM\'s acceptance (D12)', 'the PM accepts the document — never automatic'));
|
|
1332
1358
|
|
|
1333
1359
|
if (!flows('handoff')) steps.push(step('handoff', 'handed off', 'na', `${effLane} — nothing to hand off`));
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T01:59:33Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/payload/state/log.md
CHANGED
package/workspace/README.md
CHANGED
|
@@ -33,6 +33,8 @@ pmos status # is it running? which sessions?
|
|
|
33
33
|
pmos stop # shut it down (scrollback is preserved; sessions respawn next time)
|
|
34
34
|
pmos doctor # diagnose the environment (node-pty arch, PTY spawn test, ports)
|
|
35
35
|
```
|
|
36
|
+
**Without a terminal.** `pmos launcher on` (once, from the repository you want) writes a PMOS app — `~/Applications/PMOS.app` on macOS, an app-menu entry on Linux — and a login item that starts the server at login without opening a browser. Both run `pmos up <repo>` with the PATH your terminal had at setup. `pmos launcher status` checks it, `pmos launcher off` removes it. Guide: [`guide/launcher.md`](guide/launcher.md).
|
|
37
|
+
|
|
36
38
|
**The port is found, not assumed.** A running server records the port it serves, so `pmos` in a second repo joins the workspace you already have open — whatever port it ended up on, and however it was started. If another program holds the default port (7433), `pmos` starts on the next free one and says so; `PMOS_WS_PORT=7500 pmos` pins it by hand. `pmos stop` shuts down every workspace server it finds, which is how you collapse two back into one.
|
|
37
39
|
|
|
38
40
|
<details><summary>Without the global install (running from the repo)</summary>
|
package/workspace/REMOTE.md
CHANGED
|
@@ -1,32 +1,23 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Workspace remote access over a private tailnet (Tailscale)"
|
|
4
|
-
timestamp: 2026-
|
|
4
|
+
timestamp: 2026-10-06
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Reaching the workspace from your phone — over a private tailnet, never the public internet
|
|
8
8
|
|
|
9
|
-
The workspace server owns **real terminal sessions (PTYs)** running your `claude` on **your own
|
|
10
|
-
subscription**. By default it binds `127.0.0.1` only, so it is reachable solely from the machine it
|
|
11
|
-
runs on. This runbook enables reaching **the same local server** from another of your own devices —
|
|
12
|
-
a phone, a laptop — over a **private Tailscale tailnet**. It never exposes a public endpoint.
|
|
9
|
+
The workspace server owns **real terminal sessions (PTYs)** running your `claude` on **your own subscription**. By default it binds `127.0.0.1` only, so it is reachable solely from the machine it runs on. This runbook enables reaching **the same local server** from another of your own devices — a phone, a laptop — over a **private Tailscale tailnet**. It never exposes a public endpoint.
|
|
13
10
|
|
|
14
11
|
## Why a tailnet, and why no public endpoint (D62)
|
|
15
12
|
|
|
16
|
-
A [Tailscale](https://tailscale.com) tailnet is a private [WireGuard](https://www.wireguard.com/)
|
|
17
|
-
mesh between **your own devices**. Every device is authenticated and encrypted end-to-end; addresses
|
|
18
|
-
live in the CGNAT range `100.64.0.0/10` and are routable **only** between devices on your tailnet —
|
|
19
|
-
never from the public internet.
|
|
13
|
+
A [Tailscale](https://tailscale.com) tailnet is a private [WireGuard](https://www.wireguard.com/) mesh between **your own devices**. Every device is authenticated and encrypted end-to-end; addresses live in the CGNAT range `100.64.0.0/10` and are routable **only** between devices on your tailnet — never from the public internet.
|
|
20
14
|
|
|
21
15
|
PMOS's locked decision **D62** rejects, permanently:
|
|
22
16
|
|
|
23
17
|
- **any public endpoint** for the workspace server, and
|
|
24
18
|
- **any hosted PTY bridge** (proxying the terminal through a hosted, browser-reachable surface).
|
|
25
19
|
|
|
26
|
-
A public server that owns a PTY running your Claude subscription is severe exposure. The tailnet path
|
|
27
|
-
sidesteps that entirely: the phone attaches to the *same local server* across your own private
|
|
28
|
-
network. Accordingly, this feature will **only ever** bind loopback plus your machine's own tailnet
|
|
29
|
-
address. If you ask it to bind anything else (`0.0.0.0`, a LAN or public IP), it **refuses to start**.
|
|
20
|
+
A public server that owns a PTY running your Claude subscription is severe exposure. The tailnet path sidesteps that entirely: the phone attaches to the *same local server* across your own private network. Accordingly, this feature will **only ever** bind loopback plus your machine's own tailnet address. If you ask it to bind anything else (`0.0.0.0`, a LAN or public IP), it **refuses to start**.
|
|
30
21
|
|
|
31
22
|
## Setup
|
|
32
23
|
|
|
@@ -52,16 +43,15 @@ address. If you ask it to bind anything else (`0.0.0.0`, a LAN or public IP), it
|
|
|
52
43
|
loopback requests are token-free; every tailnet request must present the token.
|
|
53
44
|
```
|
|
54
45
|
|
|
55
|
-
**
|
|
46
|
+
Under those lines it prints the phone URL as a **QR code**, and the same URL as text (`http://100.x.y.z:7433/?token=<token>`). The token is shown only at startup and never logged again. A token you supply with `PMOS_TOKEN` is never printed, so that mode prints no QR code.
|
|
56
47
|
|
|
57
|
-
3. **Open it from your phone.**
|
|
48
|
+
3. **Open it from your phone.** Point the phone camera at the QR code and open the link. Or type the URL into the phone's browser. The server sets a sign-in cookie (7 days) and redirects to `http://100.x.y.z:7433/`, so the token does not stay in the address bar or the history.
|
|
58
49
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
50
|
+
If the phone has no valid cookie, it shows a **sign-in page**. Paste the token there. The field is a password field, so the phone's password manager can save it. This is also what you see after the cookie expires, or after a restart that generated a new token.
|
|
51
|
+
|
|
52
|
+
The client attaches to the **same live sessions** the desktop sees. Requests from the machine itself (`127.0.0.1`) never need the token.
|
|
62
53
|
|
|
63
|
-
|
|
64
|
-
page and its WebSocket. Requests from the machine itself (`127.0.0.1`) never need the token.
|
|
54
|
+
4. **Optional: put it on the home screen.** In Safari, tap Share › Add to Home Screen. In Chrome on Android, tap ⋮ › Add to Home screen. The PMOS icon then opens the workspace full screen, without the browser bar. On iOS the home-screen app has its own cookies, so it asks for the token once. On Android, Chrome opens a home-screen app full screen only from an HTTPS origin. Over the plain `http://100.x.y.z` address it adds a shortcut that opens in a normal browser tab. There is no offline mode: the workspace shows live state, and it needs the server to be running.
|
|
65
55
|
|
|
66
56
|
## Configuration
|
|
67
57
|
|
|
@@ -76,56 +66,27 @@ address. If you ask it to bind anything else (`0.0.0.0`, a LAN or public IP), it
|
|
|
76
66
|
|
|
77
67
|
## Security model (what is guaranteed)
|
|
78
68
|
|
|
79
|
-
- **Never public.** The only addresses the server will ever bind are loopback and a
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
- **
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
- **Origin/Host allowlist (D64).** Both the HTTP guard and the WebSocket-upgrade handshake reject any
|
|
86
|
-
request whose `Origin` is foreign (not same-origin with the `Host` it arrived on) or whose `Host`
|
|
87
|
-
hostname is not loopback/localhost or your tailnet address. This closes the cross-origin-localhost /
|
|
88
|
-
DNS-rebinding vector: without it, **any website open in your browser** could open a WebSocket to
|
|
89
|
-
`ws://127.0.0.1:<port>` and spawn a shell + type into it, because loopback peers are token-exempt.
|
|
90
|
-
A same-origin page served by the workspace passes; a cross-site page is rejected `403`. Native
|
|
91
|
-
clients / `curl` (which send no `Origin`) still pass this check and are then gated by the token.
|
|
92
|
-
- **No header/token logging (D08).** The request-guard path logs nothing about the request. The
|
|
93
|
-
token is printed at most once at startup (only when generated); a token you supply is never echoed.
|
|
94
|
-
- **Rate-limited control channel (D64).** The WS messages that spawn shells (`new`/`respawn`) and
|
|
95
|
-
feed a PTY (`input`) are per-connection rate-limited, bounding abuse if the socket is ever reached.
|
|
96
|
-
- **Independent layers.** The tailnet (WireGuard device auth), the bearer token, and the origin/host
|
|
97
|
-
allowlist all gate access — a device must be *on your tailnet*, *hold the token*, **and** present a
|
|
98
|
-
same-origin request from an allowed host.
|
|
69
|
+
- **Never public.** The only addresses the server will ever bind are loopback and a `100.64.0.0/10` tailnet address. `0.0.0.0`, `::`, LAN, and public IPs are refused — the server exits with a clear error rather than binding them. There is no code path to a public bind.
|
|
70
|
+
- **Token beyond loopback.** With remote mode on, every non-loopback HTTP request and WebSocket upgrade must present the bearer token, or it is rejected (`401` / socket closed). A browser page load gets the sign-in page with that `401`. The one exception is `POST /auth`, the sign-in form's target: it only compares the posted token and, on a match, sets the session cookie. It still sits behind the Origin/Host allowlist. Loopback requests stay token-free for local development. The comparison is constant-time.
|
|
71
|
+
- **Origin/Host allowlist (D64).** Both the HTTP guard and the WebSocket-upgrade handshake reject any request whose `Origin` is foreign (not same-origin with the `Host` it arrived on) or whose `Host` hostname is not loopback/localhost or your tailnet address. This closes the cross-origin-localhost / DNS-rebinding vector: without it, **any website open in your browser** could open a WebSocket to `ws://127.0.0.1:<port>` and spawn a shell + type into it, because loopback peers are token-exempt. A same-origin page served by the workspace passes; a cross-site page is rejected `403`. Native clients / `curl` (which send no `Origin`) still pass this check and are then gated by the token.
|
|
72
|
+
- **No header/token logging (D08).** The request-guard path logs nothing about the request. The token is printed at most once at startup (only when generated); a token you supply is never echoed.
|
|
73
|
+
- **Rate-limited control channel (D64).** The WS messages that spawn shells (`new`/`respawn`) and feed a PTY (`input`) are per-connection rate-limited, bounding abuse if the socket is ever reached.
|
|
74
|
+
- **Independent layers.** The tailnet (WireGuard device auth), the bearer token, and the origin/host allowlist all gate access — a device must be *on your tailnet*, *hold the token*, **and** present a same-origin request from an allowed host.
|
|
99
75
|
|
|
100
76
|
## Exposing to the internet — the required hardening (D64)
|
|
101
77
|
|
|
102
|
-
The workspace is **tailnet-first**. The tailnet path above is the supported way to reach it from
|
|
103
|
-
another of your own devices and needs no internet exposure. If you nonetheless expose an instance to
|
|
104
|
-
the internet, D64 permits it **only after all three of the following are in place — never before, and
|
|
105
|
-
never as a raw public bind**:
|
|
78
|
+
The workspace is **tailnet-first**. The tailnet path above is the supported way to reach it from another of your own devices and needs no internet exposure. If you nonetheless expose an instance to the internet, D64 permits it **only after all three of the following are in place — never before, and never as a raw public bind**:
|
|
106
79
|
|
|
107
80
|
1. **The bearer token for every non-loopback peer** (already enforced in remote mode).
|
|
108
81
|
2. **The Origin/Host allowlist** (already enforced — see above).
|
|
109
|
-
3. **TLS termination at the exposure boundary.** **The workspace server does not terminate TLS.** It
|
|
110
|
-
speaks plain HTTP/WS and must sit behind something that terminates TLS for it — Tailscale's own
|
|
111
|
-
HTTPS (`tailscale serve` / `tailscale funnel`) or a reverse proxy (Caddy, nginx, a tunnel) that
|
|
112
|
-
holds the certificate and forwards to the loopback/tailnet listener. Do **not** put the raw HTTP
|
|
113
|
-
port on a public interface — the server refuses non-loopback/non-tailnet binds precisely so this
|
|
114
|
-
stays a conscious proxy decision, not an accident.
|
|
82
|
+
3. **TLS termination at the exposure boundary.** **The workspace server does not terminate TLS.** It speaks plain HTTP/WS and must sit behind something that terminates TLS for it — Tailscale's own HTTPS (`tailscale serve` / `tailscale funnel`) or a reverse proxy (Caddy, nginx, a tunnel) that holds the certificate and forwards to the loopback/tailnet listener. Do **not** put the raw HTTP port on a public interface — the server refuses non-loopback/non-tailnet binds precisely so this stays a conscious proxy decision, not an accident.
|
|
115
83
|
|
|
116
|
-
When a reverse proxy forwards to the workspace, keep the forwarded `Host` set to the loopback/tailnet
|
|
117
|
-
host the server actually binds, so the Origin/Host allowlist still recognises same-origin requests.
|
|
84
|
+
When a reverse proxy forwards to the workspace, keep the forwarded `Host` set to the loopback/tailnet host the server actually binds, so the Origin/Host allowlist still recognises same-origin requests.
|
|
118
85
|
|
|
119
86
|
## Troubleshooting
|
|
120
87
|
|
|
121
|
-
- **"refusing to bind … only loopback or a Tailscale tailnet address (100.64.0.0/10) is permitted"**
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
- **
|
|
125
|
-
|
|
126
|
-
- **"cannot bind 100.x.y.z:PORT — that address is not present on this machine"** — the pinned
|
|
127
|
-
`PMOS_TAILNET_IP` isn't actually assigned to this machine. Check `tailscale ip -4`.
|
|
128
|
-
- **Phone gets 401** — the token is missing or wrong. Re-open with `?token=<token>` (or send
|
|
129
|
-
`Authorization: Bearer <token>`). Restarting the server without `PMOS_TOKEN` mints a new token.
|
|
130
|
-
- **Phone can't connect at all** — confirm both devices show each other in the Tailscale app, and
|
|
131
|
-
that you used the `100.x.y.z` address, not a LAN IP.
|
|
88
|
+
- **"refusing to bind … only loopback or a Tailscale tailnet address (100.64.0.0/10) is permitted"** — you passed `PMOS_BIND_ADDR` (or `PMOS_TAILNET_IP`) that isn't loopback or tailnet. Use your real `tailscale ip -4` value.
|
|
89
|
+
- **"no Tailscale tailnet address … was found"** — Tailscale isn't up on this machine. Run `tailscale up`, or set `PMOS_TAILNET_IP`.
|
|
90
|
+
- **"cannot bind 100.x.y.z:PORT — that address is not present on this machine"** — the pinned `PMOS_TAILNET_IP` isn't actually assigned to this machine. Check `tailscale ip -4`.
|
|
91
|
+
- **Phone shows the sign-in page** — the cookie is missing, expired, or from an older token. Paste the token, or scan the QR code again. Restarting the server without `PMOS_TOKEN` mints a new token, so every phone must sign in again. (API clients without a browser still get a JSON `401`.)
|
|
92
|
+
- **Phone can't connect at all** — confirm both devices show each other in the Tailscale app, and that you used the `100.x.y.z` address, not a LAN IP.
|
package/workspace/bin/pmos.js
CHANGED
|
@@ -45,7 +45,9 @@ const pidFile = (port) => path.join(STATE, D.pidFileName(port));
|
|
|
45
45
|
const logFile = (port) => path.join(STATE, `server-${port}.log`);
|
|
46
46
|
const APP_VERSION = (() => { try { return require('../package.json').version || null; } catch { return null; } })();
|
|
47
47
|
const urlOf = (port) => `http://127.0.0.1:${port}`;
|
|
48
|
-
|
|
48
|
+
// Tests set PMOS_NO_OPEN. `--no-open` is the login item's (`pmos launcher on`): a flag, not the env var, so the
|
|
49
|
+
// server it starts does not hand PMOS_NO_OPEN to every session, where a `pmos` typed later would open nothing.
|
|
50
|
+
const NO_OPEN = process.env.PMOS_NO_OPEN === '1' || process.argv.includes('--no-open');
|
|
49
51
|
|
|
50
52
|
const say = (s) => console.log(s);
|
|
51
53
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -406,6 +408,18 @@ function doctor() {
|
|
|
406
408
|
// Reads tracked .md files straight from disk (lib/kg-cli.js); needs no running server.
|
|
407
409
|
code = require('../lib/kg-cli').run(process.argv.slice(2), { say });
|
|
408
410
|
}
|
|
411
|
+
else if (cmd === 'item') {
|
|
412
|
+
// An initiative's breakdown items (pm-os-absorb Run 1): the same writer and fold as the workspace page,
|
|
413
|
+
// straight from disk, no running server. The write is attributed from this terminal, never from an argument.
|
|
414
|
+
code = await require('../lib/item-cli.js').run(process.argv.slice(2), { say });
|
|
415
|
+
await new Promise((r) => process.stdout.write('', r)); // drain a long --json before process.exit below
|
|
416
|
+
}
|
|
417
|
+
else if (require('../lib/pmos-verbs.js').has(cmd)) {
|
|
418
|
+
// pm-os-absorb Runs 2–5: one module per verb (lib/pmos-verbs.js). A verb whose run has not built it says so.
|
|
419
|
+
code = await require('../lib/pmos-verbs.js').run(cmd, process.argv.slice(2), {
|
|
420
|
+
say, call: async (m, p, b) => { const live = await discover(); return live.length ? api(live[0].port, m, p, b) : null; } });
|
|
421
|
+
await new Promise((r) => process.stdout.write('', r));
|
|
422
|
+
}
|
|
409
423
|
else if (cmd === 'upgrade') {
|
|
410
424
|
// Escape hatch: pull the latest bundled app and force-reinstall over this global copy. Delegates
|
|
411
425
|
// to the scaffolder (which owns the install path) rather than reinstalling itself in place.
|
|
@@ -417,18 +431,20 @@ function doctor() {
|
|
|
417
431
|
if (r.error) { say('✗ could not run ' + npx + ' — ' + r.error.message); code = 1; }
|
|
418
432
|
else code = r.status === null ? 1 : r.status;
|
|
419
433
|
} else if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
420
|
-
say('usage: pmos [<path>|status|ls|stop|doctor|upgrade|schedule|skill|proactive|kg]');
|
|
434
|
+
say('usage: pmos [<path>|status|ls|stop|doctor|upgrade|schedule|skill|proactive|kg|item|' + require('../lib/pmos-verbs.js').names().join('|') + ']');
|
|
421
435
|
say(' pmos open the current repo as a workspace (starts the server if needed)');
|
|
422
436
|
say(' pmos <path> open another repo as a workspace');
|
|
423
437
|
say(' pmos schedule … create, edit and run scheduled tasks (pmos schedule --help)');
|
|
424
438
|
say(' pmos skill … list, read, create and edit skills');
|
|
425
439
|
say(' pmos proactive … what is waiting for you, feedback, settings (pmos proactive --help)');
|
|
426
440
|
say(' pmos kg … find a card, a section or a decision\'s citers without reading whole files (pmos kg --help)');
|
|
441
|
+
say(' pmos item add|set|move|withdraw|list — an initiative\'s breakdown items (pmos item help)');
|
|
442
|
+
for (const line of require('../lib/pmos-verbs.js').helpLines()) say(line);
|
|
427
443
|
say(' pmos upgrade reinstall the workspace app from the latest published create-pmos');
|
|
428
444
|
code = 0;
|
|
429
445
|
} else if (cmd === 'up' || cmd === 'start' || cmd === 'open') {
|
|
430
446
|
// `pmos open ../other-repo` — the path is the argument AFTER the verb
|
|
431
|
-
code = await up(process.argv[3] ? path.resolve(process.argv[3]) : process.cwd());
|
|
447
|
+
code = await up(process.argv[3] && !process.argv[3].startsWith('-') ? path.resolve(process.argv[3]) : process.cwd());
|
|
432
448
|
} else if (arg && !arg.startsWith('-')) {
|
|
433
449
|
code = await up(path.resolve(arg)); // `pmos ../other-repo`
|
|
434
450
|
} else {
|