create-pmos 0.52.0 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/mods/pmos-cockpit/README.md +13 -0
- package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +2 -1
- package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +41 -11
- package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +228 -8
- package/payload/mods/pmos-cockpit/hooks/register.tsx +11 -3
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/plan-items-stage.js +271 -0
- package/payload/scripts/pmos-log.sh +18 -7
- package/payload/scripts/workflow-state.js +28 -2
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/bin/pmos.js +15 -1
- package/workspace/cut-stub.js +40 -0
- package/workspace/first-paint-audit.js +145 -0
- package/workspace/fixtures/capture/requirements/capture-2026-10-04-1.md +10 -0
- package/workspace/fixtures/initiatives-api/list.json +176 -0
- package/workspace/fixtures/initiatives-api/post-403.json +5 -0
- package/workspace/fixtures/initiatives-api/post-ok.json +10 -0
- package/workspace/fixtures/initiatives-api/roadmap.json +208 -0
- package/workspace/fixtures/initiatives-api/sheet.json +112 -0
- package/workspace/fixtures/report-api/draft.json +177 -0
- package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +97 -0
- package/workspace/fixtures/today-api/provider-rows.json +204 -0
- package/workspace/fixtures/today-api/seam-probe-kinds.json +5 -0
- package/workspace/guide/board.md +81 -0
- package/workspace/guide/breakdown.md +154 -0
- package/workspace/guide/command-palette.md +75 -0
- package/workspace/guide/index.md +27 -17
- package/workspace/guide/initiatives-roadmap.md +130 -0
- package/workspace/guide/initiatives-screen.md +89 -0
- package/workspace/guide/initiatives-timeline.md +57 -0
- package/workspace/guide/integrated-work.md +94 -0
- package/workspace/guide/knowledge-graph.md +3 -68
- package/workspace/guide/layout.md +98 -32
- package/workspace/guide/live-notes.md +3 -3
- package/workspace/guide/pm-only-acts.md +89 -0
- package/workspace/guide/pm-only-lines.md +61 -0
- package/workspace/guide/reports-page.md +80 -0
- package/workspace/guide/reports.md +73 -0
- package/workspace/guide/timeline.md +70 -0
- package/workspace/guide/today.md +85 -0
- package/workspace/guide/validate-with-typesafe.md +1 -1
- package/workspace/lib/actor.js +91 -0
- package/workspace/lib/board-cache.js +32 -2
- package/workspace/lib/board-cli.js +108 -0
- package/workspace/lib/board-fold.js +232 -0
- package/workspace/lib/breakdown-export.js +80 -0
- package/workspace/lib/capture.js +98 -0
- package/workspace/lib/engine/plan-items-stage.js +271 -0
- package/workspace/lib/engine/workflow-state.js +28 -2
- package/workspace/lib/factory-join.js +158 -0
- package/workspace/lib/grant-mint.js +113 -0
- package/workspace/lib/health-rules-factory.js +92 -0
- package/workspace/lib/health-rules-plan.js +164 -0
- package/workspace/lib/health.js +79 -0
- package/workspace/lib/initiative-cli.js +222 -0
- package/workspace/lib/initiative-display.js +183 -0
- package/workspace/lib/initiative-needs.js +127 -0
- package/workspace/lib/initiative-rows.js +206 -0
- package/workspace/lib/initiative-writer.js +141 -0
- package/workspace/lib/issues.js +206 -0
- package/workspace/lib/item-cli.js +195 -0
- package/workspace/lib/item-spans.js +137 -0
- package/workspace/lib/measure-cli.js +100 -0
- package/workspace/lib/measurements.js +189 -0
- package/workspace/lib/mobile-nav.js +57 -53
- package/workspace/lib/needs-providers.js +120 -0
- package/workspace/lib/placements.js +440 -0
- package/workspace/lib/plan-items.js +922 -0
- package/workspace/lib/plan-ledgers.js +85 -0
- package/workspace/lib/plan-rules.js +72 -0
- package/workspace/lib/plan-tree.js +367 -0
- package/workspace/lib/pm-guard.js +174 -0
- package/workspace/lib/pm-lines-needs.js +331 -0
- package/workspace/lib/pmos-verbs.js +66 -0
- package/workspace/lib/proactive.js +7 -4
- package/workspace/lib/report-cli.js +152 -0
- package/workspace/lib/report-draft.js +579 -0
- package/workspace/lib/report-needs.js +213 -0
- package/workspace/lib/route-modules.js +128 -0
- package/workspace/lib/routes-board.js +91 -0
- package/workspace/lib/routes-capture.js +52 -0
- package/workspace/lib/routes-initiatives.js +371 -0
- package/workspace/lib/routes-reports.js +163 -0
- package/workspace/lib/routes-timeline.js +197 -0
- package/workspace/lib/routes-today.js +32 -0
- package/workspace/lib/today-cli.js +99 -0
- package/workspace/lib/today.js +333 -0
- package/workspace/lib/triage.js +163 -0
- package/workspace/lib/verify-work-cli.js +81 -0
- package/workspace/lib/work-check-needs.js +99 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +4 -5
- package/workspace/public/index.html +29 -19
- package/workspace/public/pm/app.js +299 -30
- package/workspace/public/pm/bd-board.js +113 -0
- package/workspace/public/pm/bd-drag.js +59 -0
- package/workspace/public/pm/bd-model.js +152 -0
- package/workspace/public/pm/bd-timeline.js +233 -0
- package/workspace/public/pm/bd-tree.js +244 -0
- package/workspace/public/pm/board-copy.js +57 -0
- package/workspace/public/pm/breakdown-api.js +58 -0
- package/workspace/public/pm/breakdown-copy.js +138 -0
- package/workspace/public/pm/components.css +3054 -0
- package/workspace/public/pm/cycle-review.js +125 -0
- package/workspace/public/pm/data.js +40 -187
- package/workspace/public/pm/dest-board.js +438 -0
- package/workspace/public/pm/dest-initiatives.js +134 -0
- package/workspace/public/pm/dest-reports.js +270 -0
- package/workspace/public/pm/dest-settings.js +217 -0
- package/workspace/public/pm/dest-today.js +176 -0
- package/workspace/public/pm/dialogs.js +88 -0
- package/workspace/public/pm/display-word.js +56 -0
- package/workspace/public/pm/dock.js +105 -0
- package/workspace/public/pm/fixture.js +8 -227
- package/workspace/public/pm/frame-copy.js +94 -0
- package/workspace/public/pm/handoff-box.js +82 -0
- package/workspace/public/pm/http.js +88 -0
- package/workspace/public/pm/icons.js +53 -0
- package/workspace/public/pm/initiative-list.js +218 -0
- package/workspace/public/pm/initiative-sheet.js +261 -0
- package/workspace/public/pm/initiatives-api.js +59 -0
- package/workspace/public/pm/initiatives-copy.js +215 -0
- package/workspace/public/pm/initiatives-timeline-copy.js +59 -0
- package/workspace/public/pm/initiatives-timeline.js +410 -0
- package/workspace/public/pm/initiatives-ui.js +123 -0
- package/workspace/public/pm/item-sheet.js +251 -0
- package/workspace/public/pm/keep-card.js +204 -0
- package/workspace/public/pm/lifecycle.js +7 -1
- package/workspace/public/pm/mobile.css +49 -20
- package/workspace/public/pm/mobile.js +157 -29
- package/workspace/public/pm/modes-live.js +7 -883
- package/workspace/public/pm/modes.js +32 -1024
- package/workspace/public/pm/new-initiative-sheet.js +74 -0
- package/workspace/public/pm/next-step-ui.js +3 -2
- package/workspace/public/pm/palette-copy.js +51 -0
- package/workspace/public/pm/palette.js +312 -0
- package/workspace/public/pm/panel-breakdown.js +702 -0
- package/workspace/public/pm/panel-cockpit.js +63 -295
- package/workspace/public/pm/panel-detail.js +25 -172
- package/workspace/public/pm/panel-initiatives.js +1 -19
- package/workspace/public/pm/panel-repos.js +31 -81
- package/workspace/public/pm/panel-tabs.js +197 -110
- package/workspace/public/pm/panel-trees.js +5 -2
- package/workspace/public/pm/portfolio-report.js +61 -0
- package/workspace/public/pm/primitives.js +656 -0
- package/workspace/public/pm/print.css +33 -0
- package/workspace/public/pm/provider-cards.js +136 -0
- package/workspace/public/pm/registry.js +194 -0
- package/workspace/public/pm/release-card.js +110 -0
- package/workspace/public/pm/report-view.js +309 -0
- package/workspace/public/pm/reports-copy.js +114 -0
- package/workspace/public/pm/roadmap.js +271 -0
- package/workspace/public/pm/sig-watch.js +41 -0
- package/workspace/public/pm/state.js +8 -4
- package/workspace/public/pm/status-banner.js +13 -5
- package/workspace/public/pm/tab-doc.js +2 -4
- package/workspace/public/pm/tab-term.js +12 -2
- package/workspace/public/pm/tab-web.js +3 -2
- package/workspace/public/pm/timeline-copy.js +48 -0
- package/workspace/public/pm/today-cards.js +244 -0
- package/workspace/public/pm/today-copy.js +97 -0
- package/workspace/public/pm/tokens.css +16 -18
- package/workspace/public/pm/topbar.js +102 -0
- package/workspace/public/pm/triage-card.js +60 -0
- package/workspace/public/pm/undo-hold.js +103 -0
- package/workspace/public/pm/view-addrepo.js +2 -1
- package/workspace/public/pm/view-livenotes.js +3 -1
- package/workspace/public/pm/view-today.js +7 -162
- package/workspace/public/vendor/Sortable.min.js +2 -0
- package/workspace/public/vendor/lucide-sprite.js +79 -0
- package/workspace/replaced-tests.json +2864 -0
- package/workspace/run-tests.js +56 -5
- package/workspace/server.js +312 -36
- package/workspace/sheet-answer.js +61 -0
- package/workspace/skills/measure.skill +41 -0
- package/workspace/skills/report-biweekly.skill +23 -25
- package/workspace/skills/tracker-refresh.skill +50 -0
- package/workspace/test-files.json +19 -3
- package/workspace/test-lists/base.json +6 -0
- package/workspace/test-lists/run2-a.json +6 -0
- package/workspace/test-lists/run2-b.json +13 -0
- package/workspace/test-lists/run2-c.json +19 -0
- package/workspace/test-lists/run2-d.json +22 -0
- package/workspace/test-lists/run3-0.json +5 -0
- package/workspace/test-lists/run3-a.json +5 -0
- package/workspace/test-lists/run3-b.json +6 -0
- package/workspace/test-lists/run3-c.json +15 -0
- package/workspace/test-lists/run3-d.json +6 -0
- package/workspace/test-lists/run4-a.json +8 -0
- package/workspace/test-lists/run4-b.json +6 -0
- package/workspace/test-lists/run4-c.json +13 -0
- package/workspace/test-lists/run4-d.json +9 -0
- package/workspace/test-lists/run5-a.json +6 -0
- package/workspace/test-lists/run5-b.json +6 -0
- package/workspace/test-lists/run5-c.json +15 -0
- package/workspace/test-run.js +27 -1
- package/workspace/third-party/README.md +44 -0
- package/workspace/third-party/lucide-static-1.52.0.LICENSE +43 -0
- package/workspace/third-party/sortablejs-1.15.7.LICENSE +21 -0
- package/workspace/design-parity.js +0 -673
- package/workspace/public/pm/tab-task.js +0 -86
- package/workspace/public/pm/view-guide.js +0 -141
- package/workspace/public/pm/view-knowledge.js +0 -225
- package/workspace/public/pm/view-memory.js +0 -122
- package/workspace/public/pm/view-portfolio.js +0 -153
- package/workspace/public/pm/view-reports.js +0 -190
- package/workspace/public/pm/view-scheduled.js +0 -161
- package/workspace/public/pm/view-skills.js +0 -216
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* placements.js — THE ONE WRITER and THE ONE FOLD of placements.jsonl: where each initiative sits on the Roadmap
|
|
3
|
+
* (Now, Next, Later), in what order, and its check-by date (pm-os-absorb, PRD R20 A, R23, R24, R26; D86).
|
|
4
|
+
*
|
|
5
|
+
* readPlacements(root, { now, rules, records }) → {
|
|
6
|
+
* ok: true,
|
|
7
|
+
* byInit: { [id]: { horizon: 'now'|'next'|'later', rank, after, check_by, confirmed, dormant, source } },
|
|
8
|
+
* order: { now: [id…], next: [id…], later: [id…] }, ranked, first is top
|
|
9
|
+
* proposals: [{ eid, init, horizon, after, by, at }], agent lines (proposed:true) not yet adopted or dropped
|
|
10
|
+
* moves: [{ init, from, to, at, by, via }], every horizon change, oldest first
|
|
11
|
+
* flags: [{ kind, eid, init, why }],
|
|
12
|
+
* }
|
|
13
|
+
* horizonOf(result, id) → the initiative's horizon; 'later' when it has no placement
|
|
14
|
+
* appendFromUi(root, events, { actor, now }) → { ok, written, lines } | { ok: false, status, error, index }
|
|
15
|
+
* appendFromCli(root, events, { actor, now }) → the same
|
|
16
|
+
*
|
|
17
|
+
* `records` is the list of workflow rows ({ id, lane, last_activity, accepted, stopped, group? }, scripts/workflow-state.js
|
|
18
|
+
* --json) the day-one derivation (R26) reads; `rules` is lib/plan-rules.js readRules(root). A consumer that needs a
|
|
19
|
+
* horizon reads it through horizonOf, never from the file.
|
|
20
|
+
*
|
|
21
|
+
* The actor is never chosen by the caller of a route: the route takes it from pm-guard.js (Move to Now, adopt — PM
|
|
22
|
+
* only) or actor.js (every other press); the CLI from actor.fromCli or requirePmCli. Only a PM line moves an initiative
|
|
23
|
+
* into Now; an agent's line is a proposal (`proposed: true`), whatever horizon it names.
|
|
24
|
+
*
|
|
25
|
+
* THE `placed` LINE (the shared contract; 2C may add events, never change this one):
|
|
26
|
+
* { v: 1, eid, ev: 'placed', initiative, horizon: 'now'|'next'|'later', after: <initiative id> | null,
|
|
27
|
+
* check_by?: 'YYYY-MM-DD' | null, proposed?: true, by, via, at }
|
|
28
|
+
* `after: null` is the top of the horizon; an `after` that is not in that horizon puts it at the end. A line without
|
|
29
|
+
* `check_by` keeps the previous one; `check_by: null` clears it. A `proposed` line moves nothing: it is a proposal.
|
|
30
|
+
*
|
|
31
|
+
* THE EVENTS 2C ADDED (each with the same envelope):
|
|
32
|
+
* { ev: 'created', initiative, from?: 'capture-YYYY-MM-DD-<n>' } the initiative writer's landing line: top of
|
|
33
|
+
* Later, so a new record never reads Now through day one (R16, R23: what an agent creates lands in Later).
|
|
34
|
+
* Any actor; only for an initiative with no line yet. `from` names the capture it came from (triage reads it).
|
|
35
|
+
* { ev: 'adopted', proposal: <eid> } the PM keeps an agent's proposal: its placement applies as the PM's.
|
|
36
|
+
* { ev: 'dropped', proposal: <eid> } the proposal is closed unapplied (the PM, or the agent that proposed it).
|
|
37
|
+
* pmOnly(rec) → true for a line only the PM may write: a non-proposed `placed` into Now, and `adopted`. The checker
|
|
38
|
+
* (scripts/check-pm-lines.js, 2B) asks this function and never reads the schema itself.
|
|
39
|
+
*
|
|
40
|
+
* DAY ONE (R26), with no migration. An initiative of a work lane with no line in the ledger is placed by its activity:
|
|
41
|
+
* active within `stall_days` reads Now, unconfirmed (`confirmed: false`, `source: 'day-one'`, outside any cap); every
|
|
42
|
+
* other one reads Later, newest activity first. Day one's rows are seeded into their horizon before the ledger lines
|
|
43
|
+
* apply: a line with `after: null` goes above them, and a line whose `after` names a day-one row lands right after it
|
|
44
|
+
* (the page and the CLI send `after` from the displayed order). A finished initiative (accepted, or stopped by a lifecycle line) never
|
|
45
|
+
* reads Now: it leaves Now by itself (R3) and goes to the end of Later. In Later, an initiative untouched for
|
|
46
|
+
* `dormant_days` is `dormant: true` (the page folds it behind "N dormant"). Without `records` there is no day one and no
|
|
47
|
+
* lane filter: the fold answers the ledger alone.
|
|
48
|
+
*
|
|
49
|
+
* WORK LANES ONLY (D86 point 4). With `records`, an initiative whose lane is not in the `work` group of
|
|
50
|
+
* scripts/lane-defs.js (read through the engine's lanesFor, never a list kept here) is left out of every answer, and
|
|
51
|
+
* so are its proposals. A placement of an initiative that has no record any more is left out with a flag.
|
|
52
|
+
*
|
|
53
|
+
* THE FOLD IS ORDER-FREE, like lib/plan-items.js: byte-identical duplicate lines (a union merge leaves them) count
|
|
54
|
+
* once, lines apply in (at, eid) order, and one eid with two contents keeps the first and flags the second. A later
|
|
55
|
+
* PM `placed` line for an initiative closes that initiative's open proposals (they are superseded, not adopted).
|
|
56
|
+
*
|
|
57
|
+
* HONEST LIMIT. The fold tells an agent's line by its `by`. An agent `placed` line without `proposed` is a line the
|
|
58
|
+
* writer never writes: the fold reads it as a proposal and flags it (`agent-not-proposed`); an `adopted` line by an
|
|
59
|
+
* agent is ignored and flagged. A forged `pm:` line is caught only by scripts/check-pm-lines.js (2B), not here.
|
|
60
|
+
*
|
|
61
|
+
* The ledger is one per repository: lib/plan-ledgers.js ledgerPath(root, 'placements'). Nothing is cached (D33).
|
|
62
|
+
*/
|
|
63
|
+
const fs = require('fs');
|
|
64
|
+
const path = require('path');
|
|
65
|
+
const crypto = require('crypto');
|
|
66
|
+
const ledgers = require('./plan-ledgers.js');
|
|
67
|
+
const planTree = require('./plan-tree.js');
|
|
68
|
+
const actorLib = require('./actor.js');
|
|
69
|
+
const { createSinkGuard } = require('./sink.js');
|
|
70
|
+
|
|
71
|
+
const HORIZONS = Object.freeze(['now', 'next', 'later']);
|
|
72
|
+
const EVENTS = Object.freeze(['placed', 'created', 'adopted', 'dropped']);
|
|
73
|
+
const MAX_LINES = 50;
|
|
74
|
+
const DAY = 86400000;
|
|
75
|
+
const INIT_RE = planTree.INIT_RE;
|
|
76
|
+
const EID_RE = /^[0-9a-z]{12}$/;
|
|
77
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
78
|
+
const AT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
|
|
79
|
+
const CAPTURE_RE = /^capture-\d{4}-\d{2}-\d{2}-[1-9]\d{0,3}$/;
|
|
80
|
+
/** Fields only the writer sets. `proposed` too: the writer decides it from the actor. */
|
|
81
|
+
const WRITER_SET = Object.freeze(['v', 'eid', 'by', 'via', 'at', 'override', 'pm', 'actor', 'nonce', 'proposed']);
|
|
82
|
+
/** What a caller may send, per event (besides `ev`). */
|
|
83
|
+
const CALLER_KEYS = Object.freeze({
|
|
84
|
+
placed: ['initiative', 'horizon', 'after', 'check_by'],
|
|
85
|
+
created: ['initiative', 'from'],
|
|
86
|
+
adopted: ['proposal'],
|
|
87
|
+
dropped: ['proposal'],
|
|
88
|
+
});
|
|
89
|
+
const ENVELOPE = ['v', 'eid', 'ev', 'by', 'via', 'at'];
|
|
90
|
+
const STORED_KEYS = Object.freeze({
|
|
91
|
+
placed: [...ENVELOPE, ...CALLER_KEYS.placed, 'proposed'],
|
|
92
|
+
created: [...ENVELOPE, ...CALLER_KEYS.created],
|
|
93
|
+
adopted: [...ENVELOPE, ...CALLER_KEYS.adopted],
|
|
94
|
+
dropped: [...ENVELOPE, ...CALLER_KEYS.dropped],
|
|
95
|
+
});
|
|
96
|
+
const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
|
97
|
+
|
|
98
|
+
const isStr = (v) => typeof v === 'string';
|
|
99
|
+
const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
100
|
+
const isPmBy = (by) => isStr(by) && by.startsWith('pm:');
|
|
101
|
+
const dateOk = (v) => isStr(v) && DATE_RE.test(v) && !Number.isNaN(Date.parse(v + 'T00:00:00Z'))
|
|
102
|
+
&& new Date(v + 'T00:00:00Z').toISOString().slice(0, 10) === v;
|
|
103
|
+
|
|
104
|
+
function newEventId() {
|
|
105
|
+
let out = '';
|
|
106
|
+
while (out.length < 12) {
|
|
107
|
+
for (const b of crypto.randomBytes(24)) {
|
|
108
|
+
if (b >= 252) continue; // 252 = 7 × 36: keep the modulo unbiased
|
|
109
|
+
out += ALPHABET[b % 36];
|
|
110
|
+
if (out.length === 12) break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── the stored line ─────────────────────────────────────────────────────────────────────────────────
|
|
117
|
+
/** Why a stored line is not a valid line of this ledger, or null. */
|
|
118
|
+
function lineErr(r) {
|
|
119
|
+
if (r.v !== 1) return 'v must be 1';
|
|
120
|
+
if (!isStr(r.eid) || !EID_RE.test(r.eid)) return 'eid must be 12 characters of [0-9a-z]';
|
|
121
|
+
if (!EVENTS.includes(r.ev)) return `ev must be one of ${EVENTS.join(', ')}`;
|
|
122
|
+
if (!isStr(r.by) || !actorLib.BY_RE.test(r.by)) return 'by must be pm:<slug>, agent:unknown or agent:<session>';
|
|
123
|
+
if (!actorLib.VIA.includes(r.via)) return 'via must be ui or cli';
|
|
124
|
+
if (!isStr(r.at) || !AT_RE.test(r.at)) return 'at must be an ISO UTC time';
|
|
125
|
+
const extra = Object.keys(r).filter((k) => !STORED_KEYS[r.ev].includes(k));
|
|
126
|
+
if (extra.length) return `a ${r.ev} line has no field ${extra.join(', ')}`;
|
|
127
|
+
if (r.ev === 'placed' || r.ev === 'created') {
|
|
128
|
+
if (!isStr(r.initiative) || !INIT_RE.test(r.initiative)) return 'initiative must be a record name';
|
|
129
|
+
}
|
|
130
|
+
if (r.ev === 'placed') {
|
|
131
|
+
if (!HORIZONS.includes(r.horizon)) return 'horizon must be now, next or later';
|
|
132
|
+
if (r.after !== null && !(isStr(r.after) && INIT_RE.test(r.after))) return 'after must be an initiative id or null';
|
|
133
|
+
if (r.after === r.initiative) return 'an initiative cannot come after itself';
|
|
134
|
+
if (has(r, 'check_by') && r.check_by !== null && !dateOk(r.check_by)) return 'check_by must be YYYY-MM-DD or null';
|
|
135
|
+
if (has(r, 'proposed') && r.proposed !== true) return 'proposed is true or absent';
|
|
136
|
+
}
|
|
137
|
+
if (r.ev === 'created' && has(r, 'from') && !(isStr(r.from) && CAPTURE_RE.test(r.from))) return 'from must name a capture note';
|
|
138
|
+
if ((r.ev === 'adopted' || r.ev === 'dropped') && !(isStr(r.proposal) && EID_RE.test(r.proposal))) return 'proposal must be the eid of a proposal';
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── the fold ────────────────────────────────────────────────────────────────────────────────────────
|
|
143
|
+
function emptyResult() {
|
|
144
|
+
return { ok: true, byInit: {}, order: { now: [], next: [], later: [] }, proposals: [], moves: [], flags: [] };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Sort, de-duplicate and validate the raw lines: [{ rec }] in (at, eid) order. Flags go into `out.flags`. */
|
|
148
|
+
function orderLines(lines, out) {
|
|
149
|
+
const seen = new Map(); // eid -> canonical JSON
|
|
150
|
+
const kept = [];
|
|
151
|
+
for (const { n, rec } of lines) {
|
|
152
|
+
const why = lineErr(rec);
|
|
153
|
+
if (why) { out.flags.push({ kind: 'invalid', eid: isStr(rec.eid) ? rec.eid : null, init: isStr(rec.initiative) ? rec.initiative : null, why: `line ${n}: ${why}` }); continue; }
|
|
154
|
+
kept.push({ n, rec });
|
|
155
|
+
}
|
|
156
|
+
kept.sort((a, b) => (Date.parse(a.rec.at) - Date.parse(b.rec.at)) || (a.rec.eid < b.rec.eid ? -1 : a.rec.eid > b.rec.eid ? 1 : 0) || (a.n - b.n));
|
|
157
|
+
const outLines = [];
|
|
158
|
+
for (const l of kept) {
|
|
159
|
+
const text = JSON.stringify(l.rec);
|
|
160
|
+
if (seen.has(l.rec.eid)) {
|
|
161
|
+
if (seen.get(l.rec.eid) !== text) out.flags.push({ kind: 'duplicate-event', eid: l.rec.eid, init: l.rec.initiative || null, why: `line ${l.n}: the eid ${l.rec.eid} is used by two different lines; the first one counts` });
|
|
162
|
+
continue; // byte-identical duplicates (a union merge) count once
|
|
163
|
+
}
|
|
164
|
+
seen.set(l.rec.eid, text);
|
|
165
|
+
outLines.push(l);
|
|
166
|
+
}
|
|
167
|
+
return outLines;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function place(out, id, horizon, after, checkBy, { confirmed, source, rec }) {
|
|
171
|
+
// A row seeded only as an anchor (it has a ledger line of its own, below) was never placed: its first line is its
|
|
172
|
+
// first placement, as when it was not seeded.
|
|
173
|
+
const prev = out.byInit[id] && !out.byInit[id].anchorOnly ? out.byInit[id] : null;
|
|
174
|
+
for (const h of HORIZONS) out.order[h] = out.order[h].filter((x) => x !== id);
|
|
175
|
+
const list = out.order[horizon];
|
|
176
|
+
const k = after ? list.indexOf(after) : -1;
|
|
177
|
+
if (!after) list.unshift(id); else if (k === -1) list.push(id); else list.splice(k + 1, 0, id);
|
|
178
|
+
out.byInit[id] = {
|
|
179
|
+
horizon, rank: 0, after: after || null,
|
|
180
|
+
check_by: checkBy === undefined ? (prev ? prev.check_by : null) : checkBy,
|
|
181
|
+
confirmed, dormant: false, source,
|
|
182
|
+
};
|
|
183
|
+
if (rec && (!prev || prev.horizon !== horizon)) out.moves.push({ init: id, from: prev ? prev.horizon : null, to: horizon, at: rec.at, by: rec.by, via: rec.via });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Every line applied in order. `open` is the map of open proposals by eid (the writer reads it). `seed` is day one's
|
|
187
|
+
* rows ({ now: [id…], later: [id…] }, initiatives with no ledger line), placed BEFORE the lines apply, so a line whose
|
|
188
|
+
* `after` names a day-one row finds it in the horizon. Without `seed` it is the ledger alone. */
|
|
189
|
+
function foldLines(lines, seed) {
|
|
190
|
+
const out = emptyResult();
|
|
191
|
+
if (seed) {
|
|
192
|
+
for (const h of HORIZONS) {
|
|
193
|
+
for (const id of seed[h] || []) {
|
|
194
|
+
out.order[h].push(id);
|
|
195
|
+
out.byInit[id] = { horizon: h, rank: 0, after: null, check_by: null, confirmed: false, dormant: false, source: 'day-one' };
|
|
196
|
+
if (seed.anchorOnly && seed.anchorOnly.has(id)) out.byInit[id].anchorOnly = true;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const open = new Map();
|
|
201
|
+
const touched = new Set();
|
|
202
|
+
const created = new Map(); // initiative -> capture id it came from (or null)
|
|
203
|
+
for (const { n, rec } of orderLines(lines, out)) {
|
|
204
|
+
if (rec.ev === 'placed') {
|
|
205
|
+
const agentLine = !isPmBy(rec.by);
|
|
206
|
+
if (rec.proposed === true || agentLine) {
|
|
207
|
+
if (agentLine && rec.proposed !== true) out.flags.push({ kind: 'agent-not-proposed', eid: rec.eid, init: rec.initiative, why: `line ${n}: an agent's placement is a proposal; this one moved nothing` });
|
|
208
|
+
open.set(rec.eid, { eid: rec.eid, init: rec.initiative, horizon: rec.horizon, after: rec.after || null, check_by: has(rec, 'check_by') ? rec.check_by : undefined, by: rec.by, at: rec.at });
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
for (const [eid, p] of open) if (p.init === rec.initiative) open.delete(eid); // superseded by the PM's own line
|
|
212
|
+
place(out, rec.initiative, rec.horizon, rec.after, has(rec, 'check_by') ? rec.check_by : undefined, { confirmed: true, source: 'ledger', rec });
|
|
213
|
+
touched.add(rec.initiative);
|
|
214
|
+
} else if (rec.ev === 'created') {
|
|
215
|
+
if (touched.has(rec.initiative)) { out.flags.push({ kind: 'created-twice', eid: rec.eid, init: rec.initiative, why: `line ${n}: ${rec.initiative} already had a placement; this landing line moved nothing` }); continue; }
|
|
216
|
+
place(out, rec.initiative, 'later', null, undefined, { confirmed: isPmBy(rec.by), source: 'created', rec });
|
|
217
|
+
touched.add(rec.initiative);
|
|
218
|
+
created.set(rec.initiative, rec.from || null);
|
|
219
|
+
} else {
|
|
220
|
+
const p = open.get(rec.proposal);
|
|
221
|
+
if (!p) { out.flags.push({ kind: 'unknown-proposal', eid: rec.eid, init: null, why: `line ${n}: ${rec.ev} names ${rec.proposal}, which is not an open proposal` }); continue; }
|
|
222
|
+
if (rec.ev === 'adopted') {
|
|
223
|
+
if (!isPmBy(rec.by)) { out.flags.push({ kind: 'adopt-not-pm', eid: rec.eid, init: p.init, why: `line ${n}: only the PM adopts a proposal; this line moved nothing` }); continue; }
|
|
224
|
+
open.delete(rec.proposal);
|
|
225
|
+
for (const [eid, q] of open) if (q.init === p.init) open.delete(eid);
|
|
226
|
+
place(out, p.init, p.horizon, p.after, p.check_by, { confirmed: true, source: 'ledger', rec });
|
|
227
|
+
touched.add(p.init);
|
|
228
|
+
} else {
|
|
229
|
+
if (!isPmBy(rec.by) && rec.by !== p.by) { out.flags.push({ kind: 'drop-not-allowed', eid: rec.eid, init: p.init, why: `line ${n}: only the PM or the agent that proposed it drops a proposal` }); continue; }
|
|
230
|
+
open.delete(rec.proposal);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return { out, open, created };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const activityMs = (r) => {
|
|
238
|
+
const v = r && r.last_activity;
|
|
239
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v < 1e11 ? v * 1000 : v; // the engine answers epoch seconds
|
|
240
|
+
const t = Date.parse(String(v || ''));
|
|
241
|
+
return Number.isFinite(t) ? t : null;
|
|
242
|
+
};
|
|
243
|
+
const finished = (r) => !!(r && (r.accepted || r.stopped));
|
|
244
|
+
|
|
245
|
+
/** The `work` group of each lane, from the engine (lane-defs.js), unless every record already carries `group`. */
|
|
246
|
+
function groupsOf(root, records) {
|
|
247
|
+
if (records.every((r) => isStr(r.group))) return null;
|
|
248
|
+
try {
|
|
249
|
+
const loaded = require('./console-state').loadEngine();
|
|
250
|
+
if (!loaded.ok) return {};
|
|
251
|
+
const { defs } = loaded.engine.lanesFor(root);
|
|
252
|
+
const out = {};
|
|
253
|
+
for (const [lane, d] of Object.entries(defs || {})) out[lane] = d && d.group;
|
|
254
|
+
return out;
|
|
255
|
+
} catch { return {}; }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function readPlacements(root, { now, rules, records } = {}) {
|
|
259
|
+
const { lines, bad } = ledgers.readLines(root, 'placements');
|
|
260
|
+
const ledgerOnly = foldLines(lines);
|
|
261
|
+
|
|
262
|
+
if (!Array.isArray(records)) {
|
|
263
|
+
const { out, open } = ledgerOnly;
|
|
264
|
+
for (const b of bad) out.flags.unshift({ kind: 'unreadable', eid: null, init: null, why: `line ${b.n}: ${b.why}` });
|
|
265
|
+
out.proposals = [...open.values()].map((p) => ({ eid: p.eid, init: p.init, horizon: p.horizon, after: p.after, by: p.by, at: p.at }));
|
|
266
|
+
for (const h of HORIZONS) out.order[h].forEach((id, i) => { out.byInit[id].rank = i; });
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── day one and the work-lane filter (R26, D86 point 4) ──
|
|
271
|
+
const t = Number.isFinite(now) ? now : Date.now();
|
|
272
|
+
const stallMs = ((rules && rules.stall_days) || 5) * DAY;
|
|
273
|
+
const dormantMs = ((rules && rules.dormant_days) || 45) * DAY;
|
|
274
|
+
const groups = groupsOf(root, records);
|
|
275
|
+
const groupOf = (r) => (isStr(r.group) ? r.group : (groups && groups[r.lane]) || null);
|
|
276
|
+
const rows = new Map();
|
|
277
|
+
for (const r of records) if (r && isStr(r.id) && r.ok !== false) rows.set(r.id, r);
|
|
278
|
+
const isWork = (id) => rows.has(id) && groupOf(rows.get(id)) === 'work';
|
|
279
|
+
|
|
280
|
+
// Day one's rows: a work initiative with no line the ledger applied, placed by its activity. They are seeded into
|
|
281
|
+
// their horizon before the ledger lines apply, so a line placed `after` a day-one row (the page and the CLI send the
|
|
282
|
+
// displayed order) lands next to it. A ledger line with `after: null` still goes above them.
|
|
283
|
+
const byActivity = (a, b) => ((activityMs(rows.get(b)) || 0) - (activityMs(rows.get(a)) || 0)) || (a < b ? -1 : 1);
|
|
284
|
+
// A row that has a line of its own is seeded too, as an ANCHOR only: a line written while it was still a day-one row
|
|
285
|
+
// named it as `after`, and that line must find it where it stood then. Its own line moves it when it applies. Seeding
|
|
286
|
+
// only the rows with no line lost that anchor the moment the row got a line (the earlier line then appended its row
|
|
287
|
+
// at the end of the horizon), so one reorder could scramble rows it never touched.
|
|
288
|
+
const seed = { now: [], next: [], later: [], anchorOnly: new Set() };
|
|
289
|
+
for (const id of [...rows.keys()].filter((x) => isWork(x)).sort(byActivity)) {
|
|
290
|
+
const r = rows.get(id);
|
|
291
|
+
const ms = activityMs(r);
|
|
292
|
+
seed[!finished(r) && ms !== null && t - ms < stallMs ? 'now' : 'later'].push(id);
|
|
293
|
+
if (ledgerOnly.out.byInit[id]) seed.anchorOnly.add(id);
|
|
294
|
+
}
|
|
295
|
+
const { out, open } = foldLines(lines, seed);
|
|
296
|
+
for (const id of Object.keys(out.byInit)) if (out.byInit[id].anchorOnly) delete out.byInit[id].anchorOnly;
|
|
297
|
+
for (const b of bad) out.flags.unshift({ kind: 'unreadable', eid: null, init: null, why: `line ${b.n}: ${b.why}` });
|
|
298
|
+
const proposalsOf = (keep) => [...open.values()].filter((p) => keep(p.init))
|
|
299
|
+
.map((p) => ({ eid: p.eid, init: p.init, horizon: p.horizon, after: p.after, by: p.by, at: p.at }));
|
|
300
|
+
|
|
301
|
+
for (const id of Object.keys(out.byInit)) {
|
|
302
|
+
if (isWork(id)) continue;
|
|
303
|
+
if (!rows.has(id)) out.flags.push({ kind: 'no-record', eid: null, init: id, why: `${id} has a placement but no initiative record; it is left out` });
|
|
304
|
+
delete out.byInit[id];
|
|
305
|
+
for (const h of HORIZONS) out.order[h] = out.order[h].filter((x) => x !== id);
|
|
306
|
+
}
|
|
307
|
+
out.moves = out.moves.filter((m) => isWork(m.init));
|
|
308
|
+
out.proposals = proposalsOf(isWork);
|
|
309
|
+
|
|
310
|
+
// A finished initiative leaves Now by itself (R3): to the end of Later, whatever the ledger says.
|
|
311
|
+
for (const id of out.order.now.slice()) {
|
|
312
|
+
if (!finished(rows.get(id))) continue;
|
|
313
|
+
out.order.now = out.order.now.filter((x) => x !== id);
|
|
314
|
+
out.order.later.push(id);
|
|
315
|
+
out.byInit[id].horizon = 'later';
|
|
316
|
+
}
|
|
317
|
+
// Finished initiatives sit at the end of Later, in the order they were in.
|
|
318
|
+
out.order.later = [...out.order.later.filter((id) => !finished(rows.get(id))), ...out.order.later.filter((id) => finished(rows.get(id)))];
|
|
319
|
+
for (const id of out.order.later) {
|
|
320
|
+
const ms = activityMs(rows.get(id));
|
|
321
|
+
out.byInit[id].dormant = ms !== null && t - ms >= dormantMs;
|
|
322
|
+
}
|
|
323
|
+
for (const h of HORIZONS) out.order[h].forEach((id, i) => { out.byInit[id].rank = i; });
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function pmOnly(rec) {
|
|
328
|
+
if (!rec) return false;
|
|
329
|
+
if (rec.ev === 'adopted') return true;
|
|
330
|
+
return rec.ev === 'placed' && rec.horizon === 'now' && rec.proposed !== true;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function horizonOf(result, id) {
|
|
334
|
+
const p = result && result.byInit && Object.prototype.hasOwnProperty.call(result.byInit, id) ? result.byInit[id] : null;
|
|
335
|
+
return p && HORIZONS.includes(p.horizon) ? p.horizon : 'later';
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The capture notes an initiative was created from: { [captureId]: initiativeId } (lib/triage.js reads it). */
|
|
339
|
+
function createdFrom(root) {
|
|
340
|
+
const { lines } = ledgers.readLines(root, 'placements');
|
|
341
|
+
const { created } = foldLines(lines);
|
|
342
|
+
const outMap = {};
|
|
343
|
+
for (const [id, from] of created) if (from) outMap[from] = id;
|
|
344
|
+
return outMap;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── the writer ──────────────────────────────────────────────────────────────────────────────────────
|
|
348
|
+
const refuse = (status, error, index) => ({ ok: false, status, error, index: index == null ? null : index });
|
|
349
|
+
|
|
350
|
+
/** One caller event → the stored line (without the envelope), or a refusal. `state` is the fold so far. */
|
|
351
|
+
function buildRecord(root, ev, i, actor, state) {
|
|
352
|
+
if (!ev || typeof ev !== 'object' || Array.isArray(ev)) return refuse(400, 'each line must be a JSON object', i);
|
|
353
|
+
const sent = WRITER_SET.find((k) => has(ev, k));
|
|
354
|
+
if (sent) return refuse(400, `the writer sets "${sent}"; do not send it`, i);
|
|
355
|
+
if (!has(CALLER_KEYS, ev.ev)) return refuse(400, `ev must be one of ${EVENTS.join(', ')}`, i);
|
|
356
|
+
const extra = Object.keys(ev).filter((k) => k !== 'ev' && !CALLER_KEYS[ev.ev].includes(k));
|
|
357
|
+
if (extra.length) return refuse(400, `a ${ev.ev} line has no field ${extra.join(', ')}`, i);
|
|
358
|
+
const agent = !actorLib.isPm(actor);
|
|
359
|
+
const rec = { ev: ev.ev };
|
|
360
|
+
if (ev.ev === 'placed' || ev.ev === 'created') {
|
|
361
|
+
if (!isStr(ev.initiative) || !INIT_RE.test(ev.initiative)) return refuse(400, 'initiative must be an initiative id', i);
|
|
362
|
+
if (!planTree.recordPath(root, ev.initiative)) return refuse(404, `no initiative ${ev.initiative} in this repository`, i);
|
|
363
|
+
rec.initiative = ev.initiative;
|
|
364
|
+
}
|
|
365
|
+
if (ev.ev === 'placed') {
|
|
366
|
+
if (!HORIZONS.includes(ev.horizon)) return refuse(400, 'horizon must be now, next or later', i);
|
|
367
|
+
const after = ev.after == null ? null : ev.after;
|
|
368
|
+
if (after !== null && !(isStr(after) && INIT_RE.test(after))) return refuse(400, 'after must be an initiative id or null', i);
|
|
369
|
+
if (after === ev.initiative) return refuse(400, 'an initiative cannot come after itself', i);
|
|
370
|
+
rec.horizon = ev.horizon;
|
|
371
|
+
rec.after = after;
|
|
372
|
+
if (has(ev, 'check_by')) {
|
|
373
|
+
if (ev.check_by !== null && !dateOk(ev.check_by)) return refuse(400, 'check_by must be YYYY-MM-DD, or null to clear it', i);
|
|
374
|
+
rec.check_by = ev.check_by;
|
|
375
|
+
}
|
|
376
|
+
if (agent) rec.proposed = true; // an agent's placement is a proposal, whatever it names (R23)
|
|
377
|
+
} else if (ev.ev === 'created') {
|
|
378
|
+
if (has(ev, 'from')) {
|
|
379
|
+
if (!(isStr(ev.from) && CAPTURE_RE.test(ev.from))) return refuse(400, 'from must name a capture note, like capture-2026-10-04-1', i);
|
|
380
|
+
rec.from = ev.from;
|
|
381
|
+
}
|
|
382
|
+
if (state.out.byInit[ev.initiative] || state.created.has(ev.initiative)) return refuse(409, `${ev.initiative} already has a place on the Roadmap`, i);
|
|
383
|
+
} else {
|
|
384
|
+
if (!isStr(ev.proposal) || !EID_RE.test(ev.proposal)) return refuse(400, 'proposal must be the eid of a proposal', i);
|
|
385
|
+
const p = state.open.get(ev.proposal);
|
|
386
|
+
if (!p) return refuse(404, `${ev.proposal} is not an open proposal`, i);
|
|
387
|
+
if (ev.ev === 'adopted' && agent) return refuse(403, 'only the PM adopts a proposal', i);
|
|
388
|
+
if (ev.ev === 'dropped' && agent && actor.by !== p.by) return refuse(403, 'an agent drops only its own proposal', i);
|
|
389
|
+
rec.proposal = ev.proposal;
|
|
390
|
+
}
|
|
391
|
+
return { ok: true, rec };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function append(root, events, actor, { now } = {}) {
|
|
395
|
+
if (!actorLib.valid(actor)) return refuse(500, 'placements: the caller must pass the actor from pm-guard.js or actor.js', null);
|
|
396
|
+
if (!Array.isArray(events) || !events.length) return refuse(400, 'send at least one line', null);
|
|
397
|
+
if (events.length > MAX_LINES) return refuse(413, `send at most ${MAX_LINES} lines at once`, null);
|
|
398
|
+
const read = ledgers.readLines(root, 'placements');
|
|
399
|
+
if (read.bad.some((b) => b.n === 0)) return refuse(500, 'could not read the placements ledger — nothing was saved: ' + read.bad[0].why.slice(0, 160), null);
|
|
400
|
+
const lines = read.lines.slice();
|
|
401
|
+
// Strictly increasing `at`, as plan-items.js: a line dated more than a second ahead is ignored for the floor.
|
|
402
|
+
const clock = now == null ? Date.now() : now;
|
|
403
|
+
let last = 0;
|
|
404
|
+
for (const l of lines) {
|
|
405
|
+
const t = isStr(l.rec.at) ? Date.parse(l.rec.at) : NaN;
|
|
406
|
+
if (Number.isFinite(t) && t <= clock + 1000 && t > last) last = t;
|
|
407
|
+
}
|
|
408
|
+
const recs = [];
|
|
409
|
+
for (let i = 0; i < events.length; i++) {
|
|
410
|
+
const state = foldLines(lines);
|
|
411
|
+
const r = buildRecord(root, events[i], i, actor, state);
|
|
412
|
+
if (!r.ok) return r;
|
|
413
|
+
last = Math.max(clock, last + 1);
|
|
414
|
+
const rec = { v: 1, eid: newEventId(), ...r.rec, by: actor.by, via: actor.via, at: new Date(last).toISOString() };
|
|
415
|
+
recs.push(rec);
|
|
416
|
+
lines.push({ n: lines.length + 1, rec });
|
|
417
|
+
}
|
|
418
|
+
let file;
|
|
419
|
+
try { file = ledgers.ledgerPath(root, 'placements'); fs.mkdirSync(path.dirname(file), { recursive: true }); }
|
|
420
|
+
catch { return refuse(500, 'could not record — nothing was saved', null); }
|
|
421
|
+
const guard = createSinkGuard({ sink: file, what: 'placements' });
|
|
422
|
+
let wrote = 0;
|
|
423
|
+
for (const rec of recs) {
|
|
424
|
+
try { guard.appendLine(file, rec); wrote++; }
|
|
425
|
+
catch {
|
|
426
|
+
return refuse(500, wrote ? `could not record — ${wrote} of ${recs.length} lines were saved before the write failed` : 'could not record — nothing was saved', wrote);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return { ok: true, written: recs.length, lines: recs.map((r) => ({ eid: r.eid, ev: r.ev, initiative: r.initiative || null, proposed: r.proposed === true })) };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** A page press. The actor is the guard's (requirePmPress for Now and adopt, requirePagePress otherwise). */
|
|
433
|
+
function appendFromUi(root, events, { actor, now } = {}) { return append(root, events, actor, { now }); }
|
|
434
|
+
/** `pmos initiative`. The actor is requirePmCli's (Now, adopt) or actor.fromCli's. */
|
|
435
|
+
function appendFromCli(root, events, { actor, now } = {}) { return append(root, events, actor, { now }); }
|
|
436
|
+
|
|
437
|
+
module.exports = {
|
|
438
|
+
HORIZONS, EVENTS, MAX_LINES, CAPTURE_RE,
|
|
439
|
+
readPlacements, horizonOf, pmOnly, createdFrom, lineErr, appendFromUi, appendFromCli,
|
|
440
|
+
};
|