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 };
|
|
@@ -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`));
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* factory-join.js — THE ONE JOIN of the factory graph's work items onto plan items and initiatives (pm-os-absorb Run 4,
|
|
3
|
+
* PRD R6, R7, R8, R19; D65, D86 point 1).
|
|
4
|
+
*
|
|
5
|
+
* joinFactory(root, { initiative, fold } = {}) → {
|
|
6
|
+
* ok: true,
|
|
7
|
+
* byInit: {
|
|
8
|
+
* [initiativeId]: {
|
|
9
|
+
* items: [{ id, plan_item, state, pr, integrated, integrated_at }],
|
|
10
|
+
* id = work-item id; plan_item = plan item id | null; state = the work item's folded state
|
|
11
|
+
* (scripts/work-item.js deriveItems: declared, claimed, verified, failed, integrated, shipped,
|
|
12
|
+
* withdrawn); pr = a pull-request reference from its evidence | null; integrated = true once
|
|
13
|
+
* `pmos verify-work` saw its trailer on the default branch; integrated_at = that line's time | null
|
|
14
|
+
* batches: [{ id, ready, items: [<work-item id>] }], a D65 Release batch; ready = every item passed its checks
|
|
15
|
+
* }
|
|
16
|
+
* },
|
|
17
|
+
* flags: [{ kind, id, why }], a dangling plan_item, an unreadable line, a missing engine
|
|
18
|
+
* unreadable?: why only when the ledger EXISTS but cannot be read or folded
|
|
19
|
+
* }
|
|
20
|
+
* factoryOf(join, id) → that initiative's entry, or null; when the join is `unreadable`, { items: [], batches: [],
|
|
21
|
+
* unreadable: why } for every id, so a health rule says Unknown (R8) and never "on track"
|
|
22
|
+
*
|
|
23
|
+
* `initiative` narrows byInit to that one id. `fold` is an optional lib/plan-items.js fold the caller already holds
|
|
24
|
+
* (lib/plan-tree.js layout passes its own), so the dangling check does not read the plan-items ledger twice. Readers
|
|
25
|
+
* never throw; a bad line is a flag.
|
|
26
|
+
*
|
|
27
|
+
* THE FOLD IS D65's, NOT A COPY. Work-item state comes from scripts/work-item.js deriveItems, the fold the writer and
|
|
28
|
+
* the CI checker (scripts/check-work-items.js) use, so this join cannot call an item verified that the gate calls
|
|
29
|
+
* failed. That module lives in scripts/ and is not vendored into a published workspace (its sink guard requires a
|
|
30
|
+
* directory the vendoring walk refuses). In an install without it, a repository that HAS a factory ledger gets the
|
|
31
|
+
* flag `engine-missing` and an empty join; one without a ledger gets an empty join and no flag. Only PMOS-self runs a
|
|
32
|
+
* factory today.
|
|
33
|
+
*
|
|
34
|
+
* WHERE THE LEDGER IS. `planning/work-items.jsonl` of the MAIN working tree of `root`, the same tree the plan-items
|
|
35
|
+
* ledger resolves to (lib/plan-items.js ledgerRoot), so the two halves of the join are read from one tree.
|
|
36
|
+
*
|
|
37
|
+
* A BATCH (D65 Release, D86 point 2) is the work items one materialization declared: one Commit covers the whole batch
|
|
38
|
+
* (R14), and the materialization writes their `declared` lines with ONE `at`. The ledger has no batch field (D86 point
|
|
39
|
+
* 1 adds `plan_item` and nothing else), so the batch is that shared `at`. Only materialized items (a `plan_item`) form
|
|
40
|
+
* batches; a withdrawn or shipped item has left its batch. The batch is keyed on that `at` as an INSTANT, and its id is
|
|
41
|
+
* `b<UTC digits of that at>` — 14 digits to the second, and the 3 millisecond digits after them when they are not
|
|
42
|
+
* 000 — so two materializations in one second have two ids, and one Release grant names `<initiative>:<batch id>`. `ready` = every item in it is verified or integrated, and none failed.
|
|
43
|
+
*
|
|
44
|
+
* Consumers (all read through this module, never work-items.jsonl directly):
|
|
45
|
+
* - health ctx.factory = factoryOf(joinFactory(root), id) (lib/health-rules-factory.js reads it; 2C's routes and
|
|
46
|
+
* 5A's drafter assemble it);
|
|
47
|
+
* - lib/plan-tree.js layout (a materialized item reads Done once integrated, R7);
|
|
48
|
+
* - 4D's Board and work-check-needs (Review and Release rows);
|
|
49
|
+
* - 5A's blocks (factory progress when present).
|
|
50
|
+
*
|
|
51
|
+
* Nothing is cached (D33). No network, no git.
|
|
52
|
+
*/
|
|
53
|
+
const fs = require('fs');
|
|
54
|
+
const path = require('path');
|
|
55
|
+
|
|
56
|
+
const WORK_ITEM_PATHS = [path.join(__dirname, '..', '..', 'scripts', 'work-item.js')];
|
|
57
|
+
const READY = new Set(['verified', 'integrated']);
|
|
58
|
+
const PR_RE = /^(?:https?:\/\/\S+\/pull\/\d+\S*|pr:#?\d+)$/i;
|
|
59
|
+
|
|
60
|
+
function loadWorkItem() {
|
|
61
|
+
for (const p of WORK_ITEM_PATHS) {
|
|
62
|
+
if (!fs.existsSync(p)) continue;
|
|
63
|
+
try { return { ok: true, mod: require(p) }; }
|
|
64
|
+
catch (e) { return { ok: false, error: `scripts/work-item.js is present but failed to load — ${String((e && e.message) || e).slice(0, 160)}` }; }
|
|
65
|
+
}
|
|
66
|
+
return { ok: false, error: 'the work-item fold (scripts/work-item.js) is not in this install, so factory work cannot be read here' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function mainTreeOf(root) {
|
|
70
|
+
try { return require('./plan-tree.js').mainTree(path.resolve(String(root))); } catch { return path.resolve(String(root)); }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Per work item, from the raw lines: the `at` of its declaration, its last integrated time and its last PR evidence. */
|
|
74
|
+
function lineFacts(text) {
|
|
75
|
+
const facts = new Map();
|
|
76
|
+
for (const raw of String(text).split('\n')) {
|
|
77
|
+
if (!raw.trim()) continue;
|
|
78
|
+
let r;
|
|
79
|
+
try { r = JSON.parse(raw); } catch { continue; }
|
|
80
|
+
if (!r || typeof r !== 'object' || typeof r.item_id !== 'string') continue;
|
|
81
|
+
const f = facts.get(r.item_id) || { declaredAt: null, integratedAt: null, pr: null };
|
|
82
|
+
if (r.event === 'declared' && !f.declaredAt && typeof r.at === 'string') f.declaredAt = r.at;
|
|
83
|
+
if (r.event === 'integrated' && typeof r.at === 'string') f.integratedAt = r.at;
|
|
84
|
+
if (typeof r.evidence === 'string' && PR_RE.test(r.evidence.trim())) f.pr = r.evidence.trim();
|
|
85
|
+
facts.set(r.item_id, f);
|
|
86
|
+
}
|
|
87
|
+
return facts;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The batch id of one declared instant: b + 14 digits, + the milliseconds when they are not 000. Unique per instant. */
|
|
91
|
+
function batchId(at) {
|
|
92
|
+
const t = Date.parse(at);
|
|
93
|
+
if (!Number.isFinite(t)) return `b${String(at).replace(/\D/g, '')}`;
|
|
94
|
+
const digits = new Date(t).toISOString().replace(/\D/g, ''); // YYYYMMDDHHMMSSmmm
|
|
95
|
+
return `b${digits.endsWith('000') ? digits.slice(0, 14) : digits}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function joinFactory(root, { initiative, fold } = {}) {
|
|
99
|
+
const out = { ok: true, byInit: {}, flags: [] };
|
|
100
|
+
const unreadable = (kind, why) => { out.flags.push({ kind, id: null, why }); out.unreadable = why; return out; };
|
|
101
|
+
const file = path.join(mainTreeOf(root), 'planning', 'work-items.jsonl');
|
|
102
|
+
let text = '';
|
|
103
|
+
try { text = fs.readFileSync(file, 'utf8'); }
|
|
104
|
+
catch (e) {
|
|
105
|
+
if (!(e && e.code === 'ENOENT')) return unreadable('unreadable', `planning/work-items.jsonl cannot be read (${(e && e.code) || e})`);
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
if (!text.trim()) return out;
|
|
109
|
+
const W = loadWorkItem();
|
|
110
|
+
if (!W.ok) return unreadable('engine-missing', W.error);
|
|
111
|
+
|
|
112
|
+
let derived;
|
|
113
|
+
try { derived = W.mod.deriveItems(text, { now: new Date().toISOString() }); }
|
|
114
|
+
catch (e) { return unreadable('unreadable', `the work-item fold failed: ${String((e && e.message) || e).slice(0, 160)}`); }
|
|
115
|
+
for (const d of derived.defects) out.flags.push({ kind: d.kind, id: d.item_id || null, why: `line ${d.line}: ${d.detail}` });
|
|
116
|
+
|
|
117
|
+
let planFold = fold || null;
|
|
118
|
+
if (!planFold && derived.items.some((it) => typeof it.plan_item === 'string')) {
|
|
119
|
+
try { planFold = require('./plan-items.js').readFold(root); }
|
|
120
|
+
catch (e) { out.flags.push({ kind: 'plan-items-unreadable', id: null, why: `the plan-items ledger cannot be read, so no plan_item is checked: ${String((e && e.message) || e).slice(0, 160)}` }); }
|
|
121
|
+
}
|
|
122
|
+
const known = planFold ? new Set([...Object.keys(planFold.items || {}), ...(planFold.withdrawnIds || [])]) : null;
|
|
123
|
+
|
|
124
|
+
const facts = lineFacts(text);
|
|
125
|
+
const batchesOf = new Map();
|
|
126
|
+
for (const it of derived.items) {
|
|
127
|
+
if (initiative && it.initiative_id !== initiative) continue;
|
|
128
|
+
const id = it.item_id;
|
|
129
|
+
const f = facts.get(id) || {};
|
|
130
|
+
const planItem = typeof it.plan_item === 'string' ? it.plan_item : null;
|
|
131
|
+
if (planItem && known && !known.has(planItem)) {
|
|
132
|
+
out.flags.push({ kind: 'dangling-plan-item', id, why: `plan_item ${planItem} names no plan item` });
|
|
133
|
+
}
|
|
134
|
+
const integrated = (it.history || []).includes('integrated');
|
|
135
|
+
const entry = out.byInit[it.initiative_id] || (out.byInit[it.initiative_id] = { items: [], batches: [] });
|
|
136
|
+
entry.items.push({ id, plan_item: planItem, state: it.state, pr: f.pr || null, integrated, integrated_at: integrated ? f.integratedAt || null : null });
|
|
137
|
+
if (planItem && it.state !== 'withdrawn' && it.state !== 'shipped' && f.declaredAt) {
|
|
138
|
+
const t = Date.parse(f.declaredAt);
|
|
139
|
+
const key = `${it.initiative_id}\u0000${Number.isFinite(t) ? t : f.declaredAt}`;
|
|
140
|
+
if (!batchesOf.has(key)) batchesOf.set(key, { init: it.initiative_id, at: f.declaredAt, items: [], states: [] });
|
|
141
|
+
const b = batchesOf.get(key);
|
|
142
|
+
b.items.push(id); b.states.push(it.state);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (const b of batchesOf.values()) {
|
|
146
|
+
out.byInit[b.init].batches.push({ id: batchId(b.at), ready: b.states.every((s) => READY.has(s)), items: b.items.slice().sort() });
|
|
147
|
+
}
|
|
148
|
+
for (const e of Object.values(out.byInit)) e.batches.sort((a, b) => a.id.localeCompare(b.id));
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The health ctx.factory value for one initiative: its join entry, or null. */
|
|
153
|
+
function factoryOf(join, id) {
|
|
154
|
+
if (join && typeof join.unreadable === 'string') return { items: [], batches: [], unreadable: join.unreadable };
|
|
155
|
+
return (join && join.byInit && Object.prototype.hasOwnProperty.call(join.byInit, id)) ? join.byInit[id] : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { joinFactory, factoryOf };
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* goals.js — the PM's goals for the day, and the plan that joins them to what is known and what is owed (initiative
|
|
3
|
+
* `today-goals`, PRD G1 and G4).
|
|
4
|
+
*
|
|
5
|
+
* Pure: no I/O, no clock of its own, no model, no network. The server reads the goals file, the brief and the Today cards,
|
|
6
|
+
* passes them with `now`, and writes what `edit` returns.
|
|
7
|
+
*
|
|
8
|
+
* emptyDay(date) → { date, goals: [] }
|
|
9
|
+
* edit(day, op, { now }) → { ok:true, day } | { ok:false, error }
|
|
10
|
+
* op: { action: 'add', text, initiative_id? } | { action: 'done'|'drop'|'reopen'|'remove', id }
|
|
11
|
+
* readDay(raw, date) → a checked day record, or an empty day when `raw` is not one
|
|
12
|
+
* planDay({ day, brief, cards, now, endOfDay }) → the plan Today and `pmos proactive goals` show
|
|
13
|
+
*
|
|
14
|
+
* THE JOIN IS CHECKABLE. A brief item or calendar event belongs to a goal when it names the goal's id (the morning-brief
|
|
15
|
+
* session sets it). Failing that, an item or Today card belongs to the first open goal tied to the same initiative. There
|
|
16
|
+
* is no word matching: a link is an id, so the PM can see why a step is under a goal. One step is under one goal only.
|
|
17
|
+
*/
|
|
18
|
+
const crypto = require('crypto');
|
|
19
|
+
|
|
20
|
+
const MAX_GOALS = 7;
|
|
21
|
+
const MAX_TEXT = 140;
|
|
22
|
+
const STATUSES = ['open', 'done', 'dropped'];
|
|
23
|
+
const ACTIONS = ['add', 'done', 'drop', 'reopen', 'remove'];
|
|
24
|
+
const INI_RE = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
25
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
26
|
+
const GOAL_ID_RE = /^g-[0-9a-f]{8}$/;
|
|
27
|
+
const START_OF_DAY = '09:00';
|
|
28
|
+
const END_OF_DAY = '18:00';
|
|
29
|
+
const clip = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
30
|
+
|
|
31
|
+
const emptyDay = (date) => ({ date, goals: [] });
|
|
32
|
+
|
|
33
|
+
/** A day record read from disk, checked. Anything that is not a day record for `date` reads as an empty day. */
|
|
34
|
+
function readDay(raw, date) {
|
|
35
|
+
if (!raw || typeof raw !== 'object' || raw.date !== date || !Array.isArray(raw.goals)) return emptyDay(date);
|
|
36
|
+
const goals = raw.goals.filter((g) => g && typeof g === 'object' && GOAL_ID_RE.test(g.id) && typeof g.text === 'string' && g.text.trim()
|
|
37
|
+
&& STATUSES.includes(g.status)).slice(0, MAX_GOALS).map((g) => ({
|
|
38
|
+
id: g.id, text: clip(g.text, MAX_TEXT), initiative_id: typeof g.initiative_id === 'string' && INI_RE.test(g.initiative_id) ? g.initiative_id : null,
|
|
39
|
+
status: g.status, added_at: typeof g.added_at === 'string' ? g.added_at : null, closed_at: typeof g.closed_at === 'string' ? g.closed_at : null,
|
|
40
|
+
}));
|
|
41
|
+
return { date, goals };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One edit of the day's goals. A refused edit returns the reason and the caller writes nothing. */
|
|
45
|
+
function edit(day, op, { now = Date.now() } = {}) {
|
|
46
|
+
if (!day || !DATE_RE.test(String(day.date)) || !Array.isArray(day.goals)) return { ok: false, error: 'no day record to edit' };
|
|
47
|
+
if (!op || typeof op !== 'object' || !ACTIONS.includes(op.action)) return { ok: false, error: `action must be one of ${ACTIONS.join(', ')}` };
|
|
48
|
+
const at = new Date(now).toISOString();
|
|
49
|
+
const goals = day.goals.map((g) => ({ ...g }));
|
|
50
|
+
if (op.action === 'add') {
|
|
51
|
+
if (typeof op.text !== 'string' || !op.text.trim()) return { ok: false, error: 'a goal needs text' };
|
|
52
|
+
if (op.text.trim().length > MAX_TEXT) return { ok: false, error: `a goal is at most ${MAX_TEXT} characters` };
|
|
53
|
+
if (goals.length >= MAX_GOALS) return { ok: false, error: `a day has at most ${MAX_GOALS} goals. Drop or remove one first.` };
|
|
54
|
+
const ini = op.initiative_id == null || op.initiative_id === '' ? null : op.initiative_id;
|
|
55
|
+
if (ini !== null && (typeof ini !== 'string' || !INI_RE.test(ini))) return { ok: false, error: 'initiative_id is not an initiative id' };
|
|
56
|
+
const text = clip(op.text, MAX_TEXT);
|
|
57
|
+
if (goals.some((g) => g.text.toLowerCase() === text.toLowerCase())) return { ok: false, error: 'that goal is already on the list' };
|
|
58
|
+
const id = 'g-' + crypto.createHash('sha1').update(`${day.date}|${text}|${at}|${goals.length}`).digest('hex').slice(0, 8);
|
|
59
|
+
goals.push({ id, text, initiative_id: ini, status: 'open', added_at: at, closed_at: null });
|
|
60
|
+
return { ok: true, day: { date: day.date, goals }, goal: goals[goals.length - 1] };
|
|
61
|
+
}
|
|
62
|
+
const i = goals.findIndex((g) => g.id === op.id);
|
|
63
|
+
if (i < 0) return { ok: false, error: 'no goal with that id today' };
|
|
64
|
+
const g = goals[i];
|
|
65
|
+
if (op.action === 'remove') { goals.splice(i, 1); return { ok: true, day: { date: day.date, goals }, goal: g }; }
|
|
66
|
+
const to = { done: 'done', drop: 'dropped', reopen: 'open' }[op.action];
|
|
67
|
+
if (g.status === to) return { ok: false, error: `that goal is already ${to}` };
|
|
68
|
+
g.status = to; g.closed_at = to === 'open' ? null : at;
|
|
69
|
+
return { ok: true, day: { date: day.date, goals }, goal: g };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* ── the plan ──────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
73
|
+
|
|
74
|
+
const minutesOf = (hhmm) => { const [h, m] = String(hhmm).split(':').map(Number); return h * 60 + m; };
|
|
75
|
+
|
|
76
|
+
/** Free minutes of the working day still ahead (from `now`, or 09:00 if earlier, to 18:00 local), and the next event. */
|
|
77
|
+
function freeTime(events, at, endOfDay = END_OF_DAY) {
|
|
78
|
+
const d = new Date(at);
|
|
79
|
+
const end = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, minutesOf(endOfDay)).getTime();
|
|
80
|
+
const now = Math.max(at, new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, minutesOf(START_OF_DAY)).getTime());
|
|
81
|
+
if (now >= end) return { free_minutes: 0, until: endOfDay, next: null };
|
|
82
|
+
const spans = [];
|
|
83
|
+
for (const e of events || []) {
|
|
84
|
+
const s = Date.parse(e.start); const f = e.end ? Date.parse(e.end) : s + 30 * 60000;
|
|
85
|
+
if (!Number.isFinite(s) || !Number.isFinite(f) || f <= now || s >= end) continue;
|
|
86
|
+
spans.push([Math.max(s, now), Math.min(f, end)]);
|
|
87
|
+
}
|
|
88
|
+
spans.sort((a, b) => a[0] - b[0]);
|
|
89
|
+
let busy = 0; let cur = null;
|
|
90
|
+
for (const sp of spans) {
|
|
91
|
+
if (!cur || sp[0] > cur[1]) { if (cur) busy += cur[1] - cur[0]; cur = sp.slice(); }
|
|
92
|
+
else cur[1] = Math.max(cur[1], sp[1]);
|
|
93
|
+
}
|
|
94
|
+
if (cur) busy += cur[1] - cur[0];
|
|
95
|
+
const upcoming = (events || []).filter((e) => Date.parse(e.start) > now).sort((a, b) => Date.parse(a.start) - Date.parse(b.start))[0] || null;
|
|
96
|
+
return { free_minutes: Math.max(0, Math.round((end - now - busy) / 60000)), until: endOfDay, next: upcoming ? { start: upcoming.start, title: upcoming.title } : null };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const stepOrder = (a, b) => (a.owner === b.owner ? 0 : a.owner === 'pm' ? -1 : 1) || (b.confidence - a.confidence) || a.title.localeCompare(b.title);
|
|
100
|
+
|
|
101
|
+
function itemStep(x) {
|
|
102
|
+
return { ref: 'brief:' + x.id, from: 'brief', source: x.source, title: x.title, why: x.why_now, evidence: x.evidence || '', owner: x.owner,
|
|
103
|
+
effort: x.effort, confidence: x.confidence, initiative_id: x.initiative_id || null, mention: !!x.mention, worth_asking: !!x.worth_asking,
|
|
104
|
+
prepare: x.owner === 'agent' && !!x.initiative_id };
|
|
105
|
+
}
|
|
106
|
+
function cardStep(c) {
|
|
107
|
+
return { ref: 'today:' + c.id, from: 'today', source: c.source || 'today', title: c.cause, why: c.cause, evidence: c.evidence || '', owner: 'pm',
|
|
108
|
+
effort: null, confidence: 1, initiative_id: c.initiative_id || null, kind: c.kind, act: c.act ? c.act.label : null, ws: c.ws || null, mention: false,
|
|
109
|
+
worth_asking: false, prepare: false };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The day plan.
|
|
114
|
+
* day the goals record for the day (readDay)
|
|
115
|
+
* brief the stored brief (lib/brief.js shape) or null
|
|
116
|
+
* cards Today cards (lib/today.js) across the workspaces read, or []
|
|
117
|
+
* now ms; the clock the server passes
|
|
118
|
+
* A brief for another day is not joined: it is named as out of date, and only the Today cards plan the goals.
|
|
119
|
+
*/
|
|
120
|
+
function planDay({ day, brief = null, cards = [], now = Date.now(), endOfDay = END_OF_DAY } = {}) {
|
|
121
|
+
const date = day && day.date;
|
|
122
|
+
const fresh = !!brief && brief.date === date;
|
|
123
|
+
const items = fresh ? [...(brief.priorities || []), ...(brief.worth_asking || [])] : [];
|
|
124
|
+
const events = fresh ? (brief.calendar || []) : [];
|
|
125
|
+
const goals = (day && day.goals ? day.goals : []).map((g) => ({ ...g, steps: [], context: [] }));
|
|
126
|
+
const open = goals.filter((g) => g.status === 'open');
|
|
127
|
+
const byId = new Map(goals.map((g) => [g.id, g]));
|
|
128
|
+
const byIni = (ini) => (ini ? open.find((g) => g.initiative_id === ini) || null : null);
|
|
129
|
+
const unplaced = { items: [], cards: [] };
|
|
130
|
+
|
|
131
|
+
for (const x of items) {
|
|
132
|
+
const g = (x.goal && byId.get(x.goal)) || byIni(x.initiative_id);
|
|
133
|
+
if (g) g.steps.push(itemStep(x)); else unplaced.items.push(x);
|
|
134
|
+
}
|
|
135
|
+
for (const c of cards || []) {
|
|
136
|
+
const g = byIni(c.initiative_id);
|
|
137
|
+
if (g) g.steps.push(cardStep(c)); else unplaced.cards.push(c);
|
|
138
|
+
}
|
|
139
|
+
for (const e of events) {
|
|
140
|
+
const g = e.goal && byId.get(e.goal);
|
|
141
|
+
if (g) g.context.push({ start: e.start, end: e.end || null, title: e.title, with: e.with || null });
|
|
142
|
+
}
|
|
143
|
+
for (const g of goals) {
|
|
144
|
+
g.steps.sort(stepOrder);
|
|
145
|
+
g.coverage = g.status === 'done' ? 'done' : g.status === 'dropped' ? 'dropped' : g.steps.length ? 'planned' : 'needs-plan';
|
|
146
|
+
g.agent_steps = g.steps.filter((s) => s.prepare).length;
|
|
147
|
+
}
|
|
148
|
+
const also = [
|
|
149
|
+
...unplaced.items.filter((x) => !x.worth_asking).map(itemStep),
|
|
150
|
+
...unplaced.cards.map(cardStep),
|
|
151
|
+
].sort(stepOrder);
|
|
152
|
+
const worthAsking = unplaced.items.filter((x) => x.worth_asking).map(itemStep).sort(stepOrder);
|
|
153
|
+
const notRead = fresh ? Object.entries(brief.sources || {}).filter(([, v]) => v !== 'read').map(([k, v]) => ({ source: k, state: v })) : [];
|
|
154
|
+
const counts = { open: open.length, done: goals.filter((g) => g.status === 'done').length, planned: goals.filter((g) => g.coverage === 'planned').length,
|
|
155
|
+
needs_plan: goals.filter((g) => g.coverage === 'needs-plan').length };
|
|
156
|
+
return {
|
|
157
|
+
date, goals, also, worth_asking: worthAsking, counts,
|
|
158
|
+
brief: brief ? { date: brief.date, fresh, generated_at: brief.generated_at || null } : null,
|
|
159
|
+
not_read: notRead,
|
|
160
|
+
time: fresh ? freeTime(events, now, endOfDay) : null,
|
|
161
|
+
mentions: items.filter((x) => x.mention).length,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = { MAX_GOALS, MAX_TEXT, STATUSES, ACTIONS, GOAL_ID_RE, START_OF_DAY, END_OF_DAY, emptyDay, readDay, edit, freeTime, planDay };
|