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,108 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* board-cli.js — `pmos board`: the Board's items, by column, from a terminal (pm-os-absorb Run 4, slice 4D, PRD R24).
|
|
3
|
+
*
|
|
4
|
+
* pmos board [--mine | --init <initiative>] [--json] [--root <dir>]
|
|
5
|
+
*
|
|
6
|
+
* The SAME fold the Board page reads (lib/board-fold.js, the one behind GET /api/board), so a terminal line and a card
|
|
7
|
+
* cannot disagree about an item. No server is needed: the command reads the ledgers of the repository. With no flag it
|
|
8
|
+
* prints the Active filter (an item with an event in the last stall_days, an open item under an initiative in Now, and
|
|
9
|
+
* your own items). `--mine` prints your own items. `--init <initiative>` prints one initiative. `--json` prints the same
|
|
10
|
+
* answer as GET /api/board for that filter, without the workspace id. The command only reads: it writes no line.
|
|
11
|
+
*
|
|
12
|
+
* Exit codes: 0 done, 1 refused (an initiative that is not on the Board, an unreadable repository), 2 usage.
|
|
13
|
+
*/
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { execFileSync } = require('child_process');
|
|
17
|
+
const consoleState = require('./console-state');
|
|
18
|
+
const boardFold = require('./board-fold.js');
|
|
19
|
+
const { parseArgs } = require('./schedule-cli');
|
|
20
|
+
|
|
21
|
+
const COMMANDS = { board: [] };
|
|
22
|
+
const USAGE = [
|
|
23
|
+
'usage: pmos board [--mine | --init <initiative>] [--json] [--root <dir>] — the items of every initiative, by column',
|
|
24
|
+
' pmos board the Active filter: recent events, open items under Now, and your own items',
|
|
25
|
+
' pmos board --mine your own items',
|
|
26
|
+
' pmos board --init <initiative> one initiative',
|
|
27
|
+
' pmos board --json the answer of GET /api/board, for scripts',
|
|
28
|
+
'Done shows the last 14 days. A work initiative only: a document initiative has no item on the Board.',
|
|
29
|
+
];
|
|
30
|
+
const BOOL = new Set(['json', 'mine', 'help']);
|
|
31
|
+
const VALUE = new Set(['init', 'root']);
|
|
32
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
33
|
+
const COLUMNS = [['todo', 'Todo'], ['doing', 'Doing'], ['review', 'Review'], ['done', 'Done']];
|
|
34
|
+
|
|
35
|
+
function parse(argv) {
|
|
36
|
+
const rest = []; const bools = {};
|
|
37
|
+
for (const a of argv) {
|
|
38
|
+
if (a.startsWith('--') && BOOL.has(a.slice(2))) bools[a.slice(2)] = true;
|
|
39
|
+
else if (a === '-h') bools.help = true;
|
|
40
|
+
else rest.push(a);
|
|
41
|
+
}
|
|
42
|
+
const { pos, flags } = parseArgs(rest);
|
|
43
|
+
return { pos, flags: { ...flags, ...bools } };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function repoRoot(cwd) {
|
|
47
|
+
try { return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd; }
|
|
48
|
+
catch { return cwd; }
|
|
49
|
+
}
|
|
50
|
+
const shortDate = (d) => { const t = new Date(d + 'T00:00:00Z'); return `${t.getUTCDate()} ${MONTHS[t.getUTCMonth()]}`; };
|
|
51
|
+
const ownerOf = (o) => (o === 'you' ? 'you' : o === 'agent' ? 'agent' : String(o).replace(/^[a-z]+:/, ''));
|
|
52
|
+
|
|
53
|
+
/** One card as a line: initiative · stage · "title" · owner, then what a person must notice. */
|
|
54
|
+
function line(it, titleOf) {
|
|
55
|
+
const bits = [titleOf[it.initiative] || it.initiative, it.stageLabel, `"${it.title}"`, ownerOf(it.owner)];
|
|
56
|
+
if (it.due) bits.push(`due ${shortDate(it.due)}`);
|
|
57
|
+
if (it.blocked) bits.push(`blocked: ${it.blocked.reason}`);
|
|
58
|
+
if (it.noEvidence) bits.push('no evidence');
|
|
59
|
+
if (it.late) bits.push('late');
|
|
60
|
+
if (it.overrideShown) bits.push('moved by you');
|
|
61
|
+
if (it.proposed) bits.push('proposed');
|
|
62
|
+
if (it.factory && it.factory.notMerged && it.factory.label) bits.push(it.factory.label);
|
|
63
|
+
return bits.join(' · ');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), now } = {}) {
|
|
67
|
+
const args = argv[0] === 'board' ? argv.slice(1) : argv.slice();
|
|
68
|
+
const { pos, flags } = parse(args);
|
|
69
|
+
if (flags.help) { for (const l of USAGE) say(l); return 0; }
|
|
70
|
+
for (const k of Object.keys(flags)) {
|
|
71
|
+
if (!BOOL.has(k) && !VALUE.has(k)) { say(`unknown option --${k}`); return 2; }
|
|
72
|
+
if (VALUE.has(k) && flags[k] === '') { say(`--${k} needs a value`); return 2; }
|
|
73
|
+
}
|
|
74
|
+
if (pos.length) { say(`pmos board takes no argument (${pos[0]}). To show one initiative, type: pmos board --init <initiative>`); return 2; }
|
|
75
|
+
if (flags.mine && flags.init) { say('--mine and --init cannot be used together. Choose one filter.'); return 2; }
|
|
76
|
+
const json = !!flags.json;
|
|
77
|
+
const filter = flags.mine ? 'mine' : flags.init ? 'init:' + flags.init : 'active';
|
|
78
|
+
const fail = (error, code = 1) => { if (json) say(JSON.stringify({ ok: false, error })); else say(`✗ ${error}`); return code; };
|
|
79
|
+
const root = flags.root ? path.resolve(cwd, flags.root) : repoRoot(cwd);
|
|
80
|
+
// a root that is not a PMOS repository is refused: the fold would read it as a repository with no initiatives
|
|
81
|
+
try {
|
|
82
|
+
const loaded = consoleState.loadEngine();
|
|
83
|
+
if (!loaded.ok) return fail('the workflow engine is not installed: ' + loaded.error);
|
|
84
|
+
if (!fs.statSync(root).isDirectory() || !fs.statSync(loaded.engine.layout(root).initiatives).isDirectory()) throw new Error('no initiatives directory');
|
|
85
|
+
} catch (e) { return fail(`${root} is not a PMOS repository (no initiatives directory)`); }
|
|
86
|
+
let out;
|
|
87
|
+
try { out = boardFold.boardFor({ id: 'cli', root }, { filter, now }); }
|
|
88
|
+
catch (e) { return fail('the Board could not be read: ' + String((e && e.message) || e).slice(0, 200)); }
|
|
89
|
+
if (!out.ok) return fail(out.error, out.status === 400 ? 2 : 1);
|
|
90
|
+
if (json) { say(JSON.stringify(out)); return 0; }
|
|
91
|
+
const titleOf = Object.fromEntries(out.initiatives.map((i) => [i.id, i.title]));
|
|
92
|
+
const label = out.filter === 'active' ? 'Active' : out.filter === 'mine' ? 'Mine' : out.filter.slice(5);
|
|
93
|
+
const n = COLUMNS.reduce((s, [k]) => s + out.board[k].length, 0);
|
|
94
|
+
say(`Board · ${label} · ${n === 1 ? '1 item' : n + ' items'}`);
|
|
95
|
+
for (const [k, name] of COLUMNS) {
|
|
96
|
+
const ids = out.board[k];
|
|
97
|
+
say(`${name} · ${ids.length}${k === 'done' && out.board.doneOlder ? ` · ${out.board.doneOlder} older in the Tree` : ''}`);
|
|
98
|
+
for (const id of ids) say(' ' + line(out.items[id], titleOf));
|
|
99
|
+
}
|
|
100
|
+
const rel = out.releases || [];
|
|
101
|
+
if (rel.length) {
|
|
102
|
+
say(`Release · ${rel.length}`);
|
|
103
|
+
for (const r of rel) say(` ${r.initiativeTitle} · batch ${r.batch} · ${r.items.length === 1 ? '1 work item' : r.items.length + ' work items'} ready`);
|
|
104
|
+
}
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { run, COMMANDS, USAGE };
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* board-fold.js — the Board's ONE fold: every work item of every work-lane initiative, in its column (pm-os-absorb Run 4,
|
|
3
|
+
* slice 4D; PRD R9, R19, R12 kind 4; D86 points 1 and 4).
|
|
4
|
+
*
|
|
5
|
+
* boardFor(w, { filter, now }) → { ok: true, filter, now, sig, initiatives, items, board, counts }
|
|
6
|
+
* | { ok: false, status, error }
|
|
7
|
+
* parseFilter(text) → { kind: 'active' | 'mine' | 'init', init? } | null
|
|
8
|
+
* workInitiatives(root) → [{ id, title, lane, laneLabel }] the work lanes only (D86 point 4)
|
|
9
|
+
* factoryView(entry, item) → { item, state, pr, integrated, notMerged, label } | null
|
|
10
|
+
*
|
|
11
|
+
* THREE READERS, ONE ANSWER. GET /api/board (lib/routes-board.js), `pmos board` (lib/board-cli.js) and the Today rows of
|
|
12
|
+
* lib/work-check-needs.js all read through this file, so a card, a terminal line and a Today card cannot disagree about
|
|
13
|
+
* an item. The fold is lib/plan-items.js's: a card's column is its tree row's state, because both read the same fold and
|
|
14
|
+
* lib/plan-tree.js layout() builds each initiative's columns (R19, the same 14-day Done window).
|
|
15
|
+
*
|
|
16
|
+
* FILTERS (R9). `active` is the default: an item with an event in the last stall_days (planning/plan-rules.json), an open
|
|
17
|
+
* item under an initiative that is in Now, and the PM's own items (owner `you`). `mine` is the PM's own items. `init:<id>`
|
|
18
|
+
* is one initiative. Done shows the last 14 days under Active and under an initiative, however old the item's last event is
|
|
19
|
+
* (the layout's own window, lib/plan-tree.js DONE_WINDOW_MS); older Done items are counted, never listed. Under Mine, Done
|
|
20
|
+
* shows the PM's own items by the same window.
|
|
21
|
+
*
|
|
22
|
+
* WORK LANES ONLY. A document lane (scripts/lane-defs.js group 'document': prd, strategy, project-plan, decision-record,
|
|
23
|
+
* report) is a file, not a row: its initiative has no card, no option in the filter and no needs row. The group is read
|
|
24
|
+
* from the lane definitions, never from a list kept here.
|
|
25
|
+
*
|
|
26
|
+
* FACTORY. A work item that the factory materialized from a plan item carries `factory` (lib/factory-join.js): its state,
|
|
27
|
+
* its pull request, whether `pmos verify-work` saw it integrated. A card dragged to Done before that reads "Done · your
|
|
28
|
+
* call · not merged" (R7). Nothing here writes a factory record; the factory graph never writes the control graph.
|
|
29
|
+
*
|
|
30
|
+
* THE PAGE NEVER SEES A SESSION TOKEN (F5). Every `by` in the answer goes through actor.pageName, and so does every
|
|
31
|
+
* batch name. The signature is taken BEFORE the ledgers are read (the F2 fix): a line appended during the read leaves a
|
|
32
|
+
* signature older than the data at worst, which costs one more read and never hides a change.
|
|
33
|
+
*/
|
|
34
|
+
const fs = require('fs');
|
|
35
|
+
const crypto = require('crypto');
|
|
36
|
+
const path = require('path');
|
|
37
|
+
const consoleState = require('./console-state');
|
|
38
|
+
const planItems = require('./plan-items.js');
|
|
39
|
+
const planTree = require('./plan-tree.js');
|
|
40
|
+
const placements = require('./placements.js');
|
|
41
|
+
const ledgers = require('./plan-ledgers.js');
|
|
42
|
+
const factoryJoin = require('./factory-join.js');
|
|
43
|
+
const planRules = require('./plan-rules.js');
|
|
44
|
+
const actor = require('./actor.js');
|
|
45
|
+
|
|
46
|
+
const DAY = 86400000;
|
|
47
|
+
const HORIZON_RANK = { now: 0, next: 1, later: 2 };
|
|
48
|
+
const STATES = ['todo', 'doing', 'review', 'done'];
|
|
49
|
+
const NOT_MERGED = planTree.NOT_MERGED_LABEL || 'Done · your call · not merged';
|
|
50
|
+
const TOKEN_TEXT_RE = /\b(sess-\d+)\.[0-9a-f]{8}\b/g;
|
|
51
|
+
const scrub = (t) => (typeof t === 'string' ? t.replace(TOKEN_TEXT_RE, '$1') : t);
|
|
52
|
+
|
|
53
|
+
function parseFilter(text) {
|
|
54
|
+
const f = text == null || text === '' ? 'active' : String(text);
|
|
55
|
+
if (f === 'active' || f === 'mine') return { kind: f };
|
|
56
|
+
const m = /^init:(.+)$/.exec(f);
|
|
57
|
+
if (m && planItems.INIT_RE.test(m[1])) return { kind: 'init', init: m[1] };
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const canonical = (f) => (f.kind === 'init' ? 'init:' + f.init : f.kind);
|
|
61
|
+
|
|
62
|
+
/** The initiatives of `root` whose lane is a work lane, with the title their record gives. */
|
|
63
|
+
function workInitiatives(root) {
|
|
64
|
+
const loaded = consoleState.loadEngine();
|
|
65
|
+
if (!loaded.ok) throw new Error(loaded.error);
|
|
66
|
+
const eng = loaded.engine;
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const id of eng.listInitiatives(root)) {
|
|
69
|
+
let pl;
|
|
70
|
+
try { pl = planTree.profileAndLane(root, id); } catch { continue; }
|
|
71
|
+
if (pl.group !== 'work') continue;
|
|
72
|
+
const def = (pl.defs && pl.defs[pl.lane]) || {};
|
|
73
|
+
let title = id;
|
|
74
|
+
if (pl.record) {
|
|
75
|
+
try { title = String((eng.parseLeadingFrontmatter(fs.readFileSync(pl.record, 'utf8')) || {}).title || '').trim() || id; } catch { title = id; }
|
|
76
|
+
}
|
|
77
|
+
out.push({ id, title, lane: pl.lane, laneLabel: def.label || pl.lane });
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One initiative's batches as the page names them: a repeated actor reads `agent:sess-3#2` (as the Breakdown names them). */
|
|
83
|
+
function pageBatches(batches) {
|
|
84
|
+
const seen = new Map();
|
|
85
|
+
return (batches || []).map((b) => {
|
|
86
|
+
const shown = actor.pageName(b.by);
|
|
87
|
+
const n = (seen.get(shown) || 0) + 1;
|
|
88
|
+
seen.set(shown, n);
|
|
89
|
+
const name = n === 1 ? shown : `${shown}#${n}`;
|
|
90
|
+
return { ...b, key: name, by: name };
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** An item as the page is sent it: no session token in any actor or text. */
|
|
95
|
+
function pageItem(it, extra) {
|
|
96
|
+
return {
|
|
97
|
+
...it, ...extra,
|
|
98
|
+
createdBy: actor.pageName(it.createdBy), updatedBy: actor.pageName(it.updatedBy), movedBy: actor.pageName(it.movedBy),
|
|
99
|
+
note: scrub(it.note),
|
|
100
|
+
blocked: it.blocked ? { ...it.blocked, by: actor.pageName(it.blocked.by), reason: scrub(it.blocked.reason) } : it.blocked,
|
|
101
|
+
history: (it.history || []).map((h) => ({ ...h, by: actor.pageName(h.by), text: scrub(h.text) })),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The factory's view of one plan item: { item, state, pr, integrated, notMerged, label } or null. */
|
|
106
|
+
function factoryView(entry, it) {
|
|
107
|
+
if (it.factory && typeof it.factory === 'object') return it.factory; // lib/plan-tree.js already joined it
|
|
108
|
+
const w = entry && (entry.items || []).find((x) => x && x.plan_item === it.id);
|
|
109
|
+
if (!w) return null;
|
|
110
|
+
const notMerged = it.state === 'done' && w.integrated !== true;
|
|
111
|
+
return { item: w.id, state: w.state, pr: w.pr || null, integrated: w.integrated === true, notMerged, label: notMerged ? NOT_MERGED : null };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const sha1 = (s) => crypto.createHash('sha1').update(s).digest('hex');
|
|
115
|
+
const statOf = (f) => { try { const st = fs.statSync(f); return `${st.size}:${st.mtimeMs}`; } catch { return 'none'; } };
|
|
116
|
+
|
|
117
|
+
/** The grants a release press made, by target `<initiative>:<batch>`: read only, and only the lines that still hold. A grant
|
|
118
|
+
* holds while it is unexpired, or once it is used (the factory spent it). Expired and unused, it is gone: the PM may press again. */
|
|
119
|
+
function releaseGrants(root, now = Date.now()) {
|
|
120
|
+
const out = new Map();
|
|
121
|
+
let text = '';
|
|
122
|
+
try { text = fs.readFileSync(path.join(ledgers.runsDir(root), 'grants.jsonl'), 'utf8'); } catch { return out; }
|
|
123
|
+
const rows = [];
|
|
124
|
+
for (const raw of text.split('\n')) { if (!raw.trim()) continue; try { rows.push(JSON.parse(raw)); } catch { /* a torn line is the writer's to name */ } }
|
|
125
|
+
const used = new Set(rows.filter((r) => r && r.type === 'use' && typeof r.grant_id === 'string').map((r) => r.grant_id));
|
|
126
|
+
for (const r of rows) {
|
|
127
|
+
if (!r || r.type !== 'grant' || r.scope !== 'release' || typeof r.target !== 'string') continue;
|
|
128
|
+
if (used.has(r.id) || !(Date.parse(r.expires_at) <= now)) out.set(r.target, { grant_id: r.id });
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** What the page's views read besides the two plan ledgers and the factory join: the initiative records (title, lane), the
|
|
134
|
+
* rules file (stall_days) and the grants ledger (a pressed Release card goes). One stat each, so a write by the CLI shows. */
|
|
135
|
+
function sideSig(root) {
|
|
136
|
+
const parts = [];
|
|
137
|
+
try {
|
|
138
|
+
const eng = consoleState.loadEngine();
|
|
139
|
+
if (eng.ok) for (const id of eng.engine.listInitiatives(root)) { const pl = planTree.profileAndLane(root, id); parts.push(id + '=' + (pl.record ? statOf(pl.record) : 'none') + ':' + pl.lane); }
|
|
140
|
+
} catch { parts.push('initiatives:unreadable'); }
|
|
141
|
+
try { const rp = planRules.rulesPath(root); parts.push('rules=' + (rp ? statOf(rp) : 'none')); } catch { parts.push('rules:unreadable'); }
|
|
142
|
+
try { parts.push('grants=' + statOf(path.join(ledgers.runsDir(root), 'grants.jsonl'))); } catch { parts.push('grants:unreadable'); }
|
|
143
|
+
return sha1(parts.join('|'));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The signature alone, for the page's watcher: the same hash boardFor puts in `sig`, taken without reading the items. */
|
|
147
|
+
function sigFor(w) {
|
|
148
|
+
const root = w.root;
|
|
149
|
+
let itemsSig; let placeSig;
|
|
150
|
+
try { itemsSig = planItems.ledgerSigs([{ id: w.id, root }], '')[w.id].items; } catch { itemsSig = 'unreadable'; }
|
|
151
|
+
try { placeSig = ledgers.ledgerSig(root, 'placements'); } catch { placeSig = 'unreadable'; }
|
|
152
|
+
const join = factoryJoin.joinFactory(root);
|
|
153
|
+
return sha1([itemsSig, placeSig, JSON.stringify(join.byInit || {}), String((join.flags || []).length), sideSig(root)].join('|'));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function boardFor(w, { filter, now = Date.now() } = {}) {
|
|
157
|
+
const f = parseFilter(filter);
|
|
158
|
+
if (!f) return { ok: false, status: 400, error: 'the filter is active, mine or init:<initiative>' };
|
|
159
|
+
const root = w.root;
|
|
160
|
+
// the signature first (F2): the ledgers it names are read below
|
|
161
|
+
let itemsSig; let placeSig;
|
|
162
|
+
try { itemsSig = planItems.ledgerSigs([{ id: w.id, root }], '')[w.id].items; } catch { itemsSig = 'unreadable'; }
|
|
163
|
+
try { placeSig = ledgers.ledgerSig(root, 'placements'); } catch { placeSig = 'unreadable'; }
|
|
164
|
+
|
|
165
|
+
const sideSigAt = sideSig(root);
|
|
166
|
+
const inits = workInitiatives(root);
|
|
167
|
+
if (f.kind === 'init' && !inits.some((i) => i.id === f.init)) {
|
|
168
|
+
return { ok: false, status: 404, error: `no initiative ${f.init} on the Board` };
|
|
169
|
+
}
|
|
170
|
+
const rules = planRules.readRules(root);
|
|
171
|
+
const place = placements.readPlacements(root, { now, rules, records: inits.map((i) => ({ id: i.id, lane: i.lane })) });
|
|
172
|
+
const join = factoryJoin.joinFactory(root);
|
|
173
|
+
const fold = planItems.readFold(root, { now });
|
|
174
|
+
const stallMs = rules.stall_days * DAY;
|
|
175
|
+
|
|
176
|
+
const rows = inits.map((i) => {
|
|
177
|
+
const p = place.byInit && place.byInit[i.id];
|
|
178
|
+
return { ...i, horizon: placements.horizonOf(place, i.id), rank: p && Number.isFinite(p.rank) ? p.rank : 0 };
|
|
179
|
+
}).sort((a, b) => (HORIZON_RANK[a.horizon] - HORIZON_RANK[b.horizon]) || (a.rank - b.rank) || a.id.localeCompare(b.id));
|
|
180
|
+
|
|
181
|
+
const wanted = (it, row) => {
|
|
182
|
+
if (f.kind === 'init') return it.initiative === f.init;
|
|
183
|
+
if (it.owner === 'you') return true;
|
|
184
|
+
if (f.kind === 'mine') return false;
|
|
185
|
+
if (it.state === 'done') return true; // plan-tree's 14-day window lists the recent ones; the rest are counted
|
|
186
|
+
if (row.horizon === 'now') return true;
|
|
187
|
+
return Date.parse(it.updatedAt) >= now - stallMs;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const items = {};
|
|
191
|
+
const board = { todo: [], doing: [], review: [], done: [], doneOlder: 0 };
|
|
192
|
+
const initiatives = [];
|
|
193
|
+
for (const row of rows) {
|
|
194
|
+
const lay = planTree.layout({ root, initiative: row.id, fold, ladder: null, now });
|
|
195
|
+
const entry = factoryJoin.factoryOf(join, row.id);
|
|
196
|
+
const keep = new Set(Object.keys(lay.items).filter((id) => wanted(lay.items[id], row)));
|
|
197
|
+
let listed = 0;
|
|
198
|
+
for (const st of STATES) for (const id of lay.board[st]) {
|
|
199
|
+
if (!keep.has(id)) continue;
|
|
200
|
+
listed++;
|
|
201
|
+
items[id] = pageItem(lay.items[id], { stageLabel: (lay.stages.find((s) => s.id === lay.items[id].stage) || {}).label || lay.items[id].stage, factory: factoryView(entry, lay.items[id]) });
|
|
202
|
+
board[st].push(id);
|
|
203
|
+
}
|
|
204
|
+
const doneAll = [...keep].filter((id) => lay.items[id].state === 'done').length;
|
|
205
|
+
board.doneOlder += doneAll - lay.board.done.filter((id) => keep.has(id)).length;
|
|
206
|
+
initiatives.push({
|
|
207
|
+
id: row.id, title: row.title, lane: row.lane, laneLabel: row.laneLabel, horizon: row.horizon, rank: row.rank,
|
|
208
|
+
stages: lay.stages.map((s) => ({ id: s.id, label: s.label })), batches: pageBatches(lay.batches), shown: listed,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
const counts = { todo: board.todo.length, doing: board.doing.length, review: board.review.length, done: board.done.length, doneOlder: board.doneOlder };
|
|
212
|
+
const sig = sha1([itemsSig, placeSig, JSON.stringify(join.byInit || {}), String((join.flags || []).length), sideSigAt].join('|'));
|
|
213
|
+
const granted = releaseGrants(root, now);
|
|
214
|
+
// the ready batches a person can release: one card each (D86 point 2). Shown under every filter, for a work initiative.
|
|
215
|
+
const releases = [];
|
|
216
|
+
for (const row of rows) {
|
|
217
|
+
if (f.kind === 'init' && row.id !== f.init) continue;
|
|
218
|
+
const entry = factoryJoin.factoryOf(join, row.id);
|
|
219
|
+
for (const b of (entry && entry.batches) || []) {
|
|
220
|
+
if (!b || !b.ready || granted.has(`${row.id}:${b.id}`)) continue;
|
|
221
|
+
const ws = (b.items || []).map((wid) => {
|
|
222
|
+
const wi = (entry.items || []).find((x) => x && x.id === wid) || {};
|
|
223
|
+
const pi = wi.plan_item && items[wi.plan_item];
|
|
224
|
+
return { id: wid, plan_item: wi.plan_item || null, title: pi ? pi.title : wid, state: wi.state || null, pr: wi.pr || null, integrated: wi.integrated === true };
|
|
225
|
+
});
|
|
226
|
+
releases.push({ initiative: row.id, initiativeTitle: row.title, batch: b.id, items: ws });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { ok: true, filter: canonical(f), now: new Date(now).toISOString(), sig, initiatives, items, board, counts, releases };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = { boardFor, sigFor, sideSig, releaseGrants, parseFilter, workInitiatives, factoryView, STATES, NOT_MERGED };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* breakdown-export.js — an initiative's breakdown as markdown, for a document or a message (pm-os-absorb Run 2, D86
|
|
3
|
+
* point 4.3, map-spec X7).
|
|
4
|
+
*
|
|
5
|
+
* exportBreakdown(root, id) → { ok: true, id, markdown } | { ok: false, status, error }
|
|
6
|
+
*
|
|
7
|
+
* D86 point 4: a project document is an EXPORT of the Breakdown tree, so the document and the board it describes have
|
|
8
|
+
* one source and cannot disagree. The output is deterministic — the same ledger gives the same bytes, with no clock
|
|
9
|
+
* and no random part — so two exports can be compared with diff. Stage headers come in the stage order of the
|
|
10
|
+
* initiative's lane (lib/plan-tree.js), each with "n of m"; under each header the items in tree order, each with its
|
|
11
|
+
* state, owner, dates and the milestone mark. Proposed items (an agent's batch not kept yet) are listed with
|
|
12
|
+
* "proposed"; withdrawn items are left out. Items whose stage the lane does not have are listed under "Other".
|
|
13
|
+
*
|
|
14
|
+
* Nothing is written: the CLI prints the markdown, the route answers it, and the PM saves it where they choose.
|
|
15
|
+
* Work lanes only (D86 point 4): a document-lane initiative is a file already, so it is refused with 404.
|
|
16
|
+
* No heading or label says "plan" (glossary rule `plan`).
|
|
17
|
+
*/
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const planItems = require('./plan-items.js');
|
|
20
|
+
const planTree = require('./plan-tree.js');
|
|
21
|
+
const consoleState = require('./console-state');
|
|
22
|
+
|
|
23
|
+
const STATE_LABEL = { todo: 'Todo', doing: 'Doing', review: 'Review', done: 'Done' };
|
|
24
|
+
const refuse = (status, error) => ({ ok: false, status, error });
|
|
25
|
+
|
|
26
|
+
function ownerLabel(owner) {
|
|
27
|
+
const s = String(owner || '');
|
|
28
|
+
if (s === 'you') return 'you';
|
|
29
|
+
if (s === 'agent') return 'agent';
|
|
30
|
+
const m = /^(person|tracker):(.+)$/.exec(s);
|
|
31
|
+
return m ? m[2] : (s || 'no owner');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function itemLine(it) {
|
|
35
|
+
const bits = [STATE_LABEL[it.state] || it.state, ownerLabel(it.owner)];
|
|
36
|
+
if (it.start && it.due) bits.push(`${it.start} → ${it.due}`);
|
|
37
|
+
else if (it.due) bits.push(`due ${it.due}`);
|
|
38
|
+
else if (it.start) bits.push(`from ${it.start}`);
|
|
39
|
+
if (it.milestone) bits.push('milestone');
|
|
40
|
+
if (it.blocked) bits.push(`blocked: ${String(it.blocked.reason || '').replace(/\s+/g, ' ').trim()}`);
|
|
41
|
+
if (it.proposed) bits.push('proposed');
|
|
42
|
+
return `- [${it.state === 'done' ? 'x' : ' '}] ${it.title.replace(/\s+/g, ' ').trim()} · ${bits.join(' · ')}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function exportBreakdown(root, id) {
|
|
46
|
+
if (typeof id !== 'string' || !planTree.INIT_RE.test(id)) return refuse(400, 'an initiative id is required');
|
|
47
|
+
const file = planTree.recordPath(root, id);
|
|
48
|
+
if (!file) return refuse(404, `no initiative ${id} in this repository`);
|
|
49
|
+
const loaded = consoleState.loadEngine();
|
|
50
|
+
if (!loaded.ok) return refuse(500, loaded.error);
|
|
51
|
+
let fm = {};
|
|
52
|
+
try { fm = loaded.engine.parseLeadingFrontmatter(fs.readFileSync(file, 'utf8')) || {}; } catch { fm = {}; }
|
|
53
|
+
if (planTree.profileAndLane(root, id).group !== 'work') {
|
|
54
|
+
return refuse(404, `${id} is not on a work lane; a document is a file and has no breakdown to export`);
|
|
55
|
+
}
|
|
56
|
+
const fold = planItems.readFold(root, { now: 0 });
|
|
57
|
+
const lay = planTree.layout({ root, initiative: id, fold, ladder: null, now: 0 });
|
|
58
|
+
const title = String(fm.title || id).replace(/\s+/g, ' ').trim();
|
|
59
|
+
const out = [`# ${title}`, '', `Initiative \`${id}\` · ${lay.lane} lane · anchor ${String(fm.parent_kr || 'none').trim()} · ${lay.progress.label}`, ''];
|
|
60
|
+
const section = (heading, ids) => {
|
|
61
|
+
out.push(heading, '');
|
|
62
|
+
if (!ids.length) out.push('No items.');
|
|
63
|
+
for (const itemId of ids) out.push(itemLine(lay.items[itemId]));
|
|
64
|
+
out.push('');
|
|
65
|
+
};
|
|
66
|
+
for (const st of lay.stages) {
|
|
67
|
+
if (!st.items.length) continue;
|
|
68
|
+
section(`## ${st.label}${st.count ? ` · ${st.count}` : ''}`, st.items);
|
|
69
|
+
}
|
|
70
|
+
if (lay.other.items.length) section('## Other', lay.other.items);
|
|
71
|
+
if (!lay.stages.some((s) => s.items.length) && !lay.other.items.length) out.push('No breakdown yet.', '');
|
|
72
|
+
return { ok: true, id, markdown: out.join('\n') };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { exportBreakdown, itemLine };
|
package/workspace/lib/brief.js
CHANGED
|
@@ -10,12 +10,16 @@
|
|
|
10
10
|
* greeting(now, name) → the one line Today opens with
|
|
11
11
|
* localDate(now) → YYYY-MM-DD on this machine's clock
|
|
12
12
|
*
|
|
13
|
+
* Goals (initiative `today-goals`, PRD G3): an item or an event may name one of the day's goals with `goal`. With
|
|
14
|
+
* `ctx.goalIds` given, a goal that is not in that day's record is refused. An item may say it is a `mention` of the PM
|
|
15
|
+
* (Slack, Jira, Confluence, mail): a boolean, nothing more.
|
|
16
|
+
*
|
|
13
17
|
* The bucket is derived: an item under WORTH_ASKING confidence is "worth asking", whatever the session said. The order is
|
|
14
18
|
* fixed and stated: higher confidence first, then PM-owned before agent-owned, then by title.
|
|
15
19
|
*/
|
|
16
20
|
const crypto = require('crypto');
|
|
17
21
|
|
|
18
|
-
const SOURCES = ['calendar', 'wispr', 'mail', 'jira', 'confluence'];
|
|
22
|
+
const SOURCES = ['calendar', 'wispr', 'mail', 'slack', 'jira', 'confluence'];
|
|
19
23
|
const ITEM_SOURCES = [...SOURCES, 'derived'];
|
|
20
24
|
const SOURCE_STATES = ['read', 'not-reached', 'off'];
|
|
21
25
|
const OWNERS = ['pm', 'agent'];
|
|
@@ -24,6 +28,7 @@ const WORTH_ASKING = 0.5;
|
|
|
24
28
|
const MAX_ITEMS = 12;
|
|
25
29
|
const MAX_EVENTS = 20;
|
|
26
30
|
const INI_RE = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
31
|
+
const GOAL_RE = /^g-[0-9a-f]{8}$/;
|
|
27
32
|
const clip = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
28
33
|
|
|
29
34
|
const pad = (n) => String(n).padStart(2, '0');
|
|
@@ -40,6 +45,13 @@ const tooLong = (v, n) => typeof v === 'string' && v.trim().length > n;
|
|
|
40
45
|
function validateBrief(raw, ctx = {}) {
|
|
41
46
|
const now = ctx.now || Date.now();
|
|
42
47
|
const settings = ctx.settings || {};
|
|
48
|
+
const goalIds = Array.isArray(ctx.goalIds) ? new Set(ctx.goalIds) : null;
|
|
49
|
+
const badGoal = (g, at) => {
|
|
50
|
+
if (g === undefined || g === null) return null;
|
|
51
|
+
if (typeof g !== 'string' || !GOAL_RE.test(g)) return `${at}: goal is not a goal id`;
|
|
52
|
+
if (goalIds && !goalIds.has(g)) return `${at}: goal ${g} is not one of the goals for that day`;
|
|
53
|
+
return null;
|
|
54
|
+
};
|
|
43
55
|
const on = (src) => !settings.briefSources || settings.briefSources[src] !== false;
|
|
44
56
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, error: 'a brief is a JSON object' };
|
|
45
57
|
const date = raw.date === undefined ? localDate(now) : raw.date;
|
|
@@ -68,7 +80,9 @@ function validateBrief(raw, ctx = {}) {
|
|
|
68
80
|
if (typeof e.start !== 'string' || Number.isNaN(Date.parse(e.start))) return { ok: false, error: `calendar event ${i + 1}: start must be a date and time` };
|
|
69
81
|
if (e.end !== undefined && (typeof e.end !== 'string' || Number.isNaN(Date.parse(e.end)))) return { ok: false, error: `calendar event ${i + 1}: end must be a date and time` };
|
|
70
82
|
if (e.with !== undefined && tooLong(e.with, 120)) return { ok: false, error: `calendar event ${i + 1}: "with" is over 120 characters` };
|
|
71
|
-
|
|
83
|
+
const gErr = badGoal(e.goal, `calendar event ${i + 1}`);
|
|
84
|
+
if (gErr) return { ok: false, error: gErr };
|
|
85
|
+
calendar.push({ start: e.start, end: e.end || null, title: clip(e.title, 120), with: e.with ? clip(e.with, 120) : null, ...(e.goal ? { goal: e.goal } : {}) });
|
|
72
86
|
}
|
|
73
87
|
calendar.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
|
|
74
88
|
|
|
@@ -90,11 +104,15 @@ function validateBrief(raw, ctx = {}) {
|
|
|
90
104
|
if (!EFFORTS.includes(x.effort)) return { ok: false, error: `${at}: effort must be one of ${EFFORTS.join(', ')}` };
|
|
91
105
|
if (typeof x.confidence !== 'number' || !Number.isFinite(x.confidence) || x.confidence < 0 || x.confidence > 1) return { ok: false, error: `${at}: confidence must be a number from 0 to 1` };
|
|
92
106
|
if (x.initiative_id !== undefined && x.initiative_id !== null && (typeof x.initiative_id !== 'string' || !INI_RE.test(x.initiative_id))) return { ok: false, error: `${at}: initiative_id is not an initiative id` };
|
|
107
|
+
const gErr = badGoal(x.goal, at);
|
|
108
|
+
if (gErr) return { ok: false, error: gErr };
|
|
109
|
+
if (x.mention !== undefined && typeof x.mention !== 'boolean') return { ok: false, error: `${at}: mention must be true or false` };
|
|
93
110
|
const title = clip(x.title, 140);
|
|
94
111
|
items.push({
|
|
95
112
|
id: crypto.createHash('sha1').update(`${date}|${x.source}|${title}`).digest('hex').slice(0, 12),
|
|
96
113
|
title, why_now: clip(x.why_now, 240), evidence: clip(x.evidence, 240), source: x.source, initiative_id: x.initiative_id || null,
|
|
97
114
|
owner: x.owner, effort: x.effort, confidence: x.confidence, worth_asking: x.confidence < WORTH_ASKING,
|
|
115
|
+
...(x.goal ? { goal: x.goal } : {}), ...(x.mention ? { mention: true } : {}),
|
|
98
116
|
});
|
|
99
117
|
}
|
|
100
118
|
items.sort((a, b) => b.confidence - a.confidence || (a.owner === b.owner ? 0 : a.owner === 'pm' ? -1 : 1) || a.title.localeCompare(b.title));
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* capture.js — the ⌘K "Capture as note" writer (pm-os-absorb Run 3, PRD R11 and R16, X16).
|
|
3
|
+
*
|
|
4
|
+
* write(stateDir, { text, actor, now? }) → { ok: true, id, file, path, n, day } | { ok: false, status, error }
|
|
5
|
+
* list(stateDir) → [{ id, file, n, day, timestamp, by, text }], oldest first
|
|
6
|
+
* render({ text, day, n, timestamp, by }) → the file's whole text
|
|
7
|
+
*
|
|
8
|
+
* A capture is ONE NOTE FILE, `<state>/requirements/capture-YYYY-MM-DD-<n>.md`, with `<n>` counting from 1 per UTC day.
|
|
9
|
+
* There is no ledger: the folder is the one the day's live-notes note and the report skill already read, and the next
|
|
10
|
+
* step (triage, Run 2, slice 2C) reads the notes newer than its last run and files each as a proposal. Capture itself
|
|
11
|
+
* decides nothing: it keeps the words, which is why it is the row ⌘K always offers (D71).
|
|
12
|
+
*
|
|
13
|
+
* The frontmatter is `type: Reference` (`Requirement` is not in scripts/frontmatter_registry.txt, and the Frontmatter
|
|
14
|
+
* Gate would refuse it), `title`, `timestamp` (ISO UTC), `source: capture` and `by`. `by` comes from the CALL PATH (the
|
|
15
|
+
* `actor` the route got from pm-guard), never from a field the caller sends. The body is the captured text, unchanged.
|
|
16
|
+
*
|
|
17
|
+
* THE FILE IS NEVER OVERWRITTEN. The write is exclusive (`wx`): when two captures race for one number, the loser takes
|
|
18
|
+
* the next. Nothing is read from outside the folder, and no capture is deleted or edited here.
|
|
19
|
+
*/
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
const DIR = 'requirements';
|
|
24
|
+
const FILE_RE = /^capture-(\d{4}-\d{2}-\d{2})-(\d+)\.md$/;
|
|
25
|
+
const MAX_TEXT = 8000;
|
|
26
|
+
const BY_RE = /^(?:pm:[a-z0-9_-]{1,40}|agent:unknown|agent:sess-\d+\.[0-9a-f]{8})$/;
|
|
27
|
+
|
|
28
|
+
const refuse = (status, error) => ({ ok: false, status, error });
|
|
29
|
+
|
|
30
|
+
/** The whole file. Pure, so the test can hold it to the fixture byte for byte. */
|
|
31
|
+
function render({ text, day, n, timestamp, by }) {
|
|
32
|
+
const body = String(text).replace(/\r\n?/g, '\n').trim();
|
|
33
|
+
return `---\ntype: Reference\ntitle: "Capture ${day} ${n}"\ntimestamp: ${timestamp}\nsource: capture\nby: ${by}\n---\n\n${body}\n`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The text a capture may carry: a non-empty string of at most MAX_TEXT characters, with no NUL. Null when it passes. */
|
|
37
|
+
function textProblem(text) {
|
|
38
|
+
if (typeof text !== 'string') return 'The capture needs text.';
|
|
39
|
+
if (!text.trim()) return 'The capture is empty. Type the idea first.';
|
|
40
|
+
if (text.length > MAX_TEXT) return `The capture is too long. The limit is ${MAX_TEXT} characters.`;
|
|
41
|
+
if (text.includes('\u0000')) return 'The capture holds a character that a note cannot keep.';
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The numbers already taken on `day`, from the file names in the folder. */
|
|
46
|
+
function taken(dir, day) {
|
|
47
|
+
let names = [];
|
|
48
|
+
try { names = fs.readdirSync(dir); } catch { return []; }
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const f of names) { const m = FILE_RE.exec(f); if (m && m[1] === day) out.push(Number(m[2])); }
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function write(stateDir, { text, actor, now } = {}) {
|
|
55
|
+
if (!stateDir || typeof stateDir !== 'string') return refuse(409, 'This repository has no state folder to keep the capture in.');
|
|
56
|
+
const bad = textProblem(text);
|
|
57
|
+
if (bad) return refuse(400, bad);
|
|
58
|
+
if (!actor || typeof actor.by !== 'string' || !BY_RE.test(actor.by)) return refuse(500, 'capture: the writer needs the actor from the call path.');
|
|
59
|
+
const at = now instanceof Date && !Number.isNaN(now.getTime()) ? now : new Date();
|
|
60
|
+
const timestamp = at.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
61
|
+
const day = timestamp.slice(0, 10);
|
|
62
|
+
const dir = path.join(stateDir, DIR);
|
|
63
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch (e) { return refuse(500, 'The capture could not be saved: ' + String((e && e.message) || e).slice(0, 160)); }
|
|
64
|
+
let n = Math.max(0, ...taken(dir, day)) + 1;
|
|
65
|
+
for (let tries = 0; tries < 50; tries++, n++) {
|
|
66
|
+
const file = `capture-${day}-${n}.md`;
|
|
67
|
+
try {
|
|
68
|
+
fs.writeFileSync(path.join(dir, file), render({ text, day, n, timestamp, by: actor.by }), { flag: 'wx' });
|
|
69
|
+
return { ok: true, id: file.replace(/\.md$/, ''), file, path: path.join(dir, file), n, day, timestamp };
|
|
70
|
+
} catch (e) {
|
|
71
|
+
if (e && e.code === 'EEXIST') continue; // another capture took this number first
|
|
72
|
+
return refuse(500, 'The capture could not be saved: ' + String((e && e.message) || e).slice(0, 160));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return refuse(500, 'The capture could not be saved: no free number today.');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Every capture note in the folder, oldest first. A file that does not parse as a capture is left out, not an error. */
|
|
79
|
+
function list(stateDir) {
|
|
80
|
+
const dir = path.join(stateDir, DIR);
|
|
81
|
+
let names = [];
|
|
82
|
+
try { names = fs.readdirSync(dir); } catch { return []; }
|
|
83
|
+
const rows = [];
|
|
84
|
+
for (const file of names) {
|
|
85
|
+
const m = FILE_RE.exec(file);
|
|
86
|
+
if (!m) continue;
|
|
87
|
+
let raw = '';
|
|
88
|
+
try { raw = fs.readFileSync(path.join(dir, file), 'utf8'); } catch { continue; }
|
|
89
|
+
const f = /^---\n([\s\S]*?)\n---\n\n?([\s\S]*)$/.exec(raw);
|
|
90
|
+
if (!f) continue;
|
|
91
|
+
const fm = {};
|
|
92
|
+
for (const line of f[1].split('\n')) { const k = /^([a-z_]+): ?(.*)$/.exec(line); if (k) fm[k[1]] = k[2].replace(/^"(.*)"$/, '$1'); }
|
|
93
|
+
rows.push({ id: file.replace(/\.md$/, ''), file, day: m[1], n: Number(m[2]), timestamp: fm.timestamp || '', by: fm.by || '', text: f[2].replace(/\n$/, '') });
|
|
94
|
+
}
|
|
95
|
+
return rows.sort((a, b) => (a.day === b.day ? a.n - b.n : a.day < b.day ? -1 : 1));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { DIR, MAX_TEXT, FILE_RE, render, write, list, textProblem };
|