create-pmos 0.534.0 → 0.536.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.534.0",
3
+ "version": "0.536.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -32,6 +32,32 @@
32
32
  "audit"
33
33
  ],
34
34
  "pmosChanges": [
35
+ {
36
+ "v": "0.536.0",
37
+ "at": "2026-11-05T10:03:22.177Z",
38
+ "f": 4,
39
+ "prs": [
40
+ {
41
+ "n": 1252,
42
+ "t": "Apply an order to a report draft, and save my own",
43
+ "s": "Apply an order to a report draft, and save my own",
44
+ "k": "new"
45
+ }
46
+ ]
47
+ },
48
+ {
49
+ "v": "0.535.0",
50
+ "at": "2026-11-05T09:07:13.086Z",
51
+ "f": 4,
52
+ "prs": [
53
+ {
54
+ "n": 1251,
55
+ "t": "Calmer Settings › Connectors, one list with trouble first",
56
+ "s": "Calmer Settings › Connectors, one list with trouble first",
57
+ "k": "new"
58
+ }
59
+ ]
60
+ },
35
61
  {
36
62
  "v": "0.534.0",
37
63
  "at": "2026-11-05T00:29:39.663Z",
@@ -385,40 +411,6 @@
385
411
  "k": "new"
386
412
  }
387
413
  ]
388
- },
389
- {
390
- "v": "0.516.0",
391
- "at": "2026-11-04T00:00:07.601Z",
392
- "f": 4,
393
- "prs": [
394
- {
395
- "n": 1209,
396
- "t": "Runbook of what Claude does behind the scenes, in the guide",
397
- "s": "Runbook of what Claude does behind the scenes, in the guide",
398
- "k": "x",
399
- "h": "See what Claude does behind the scenes",
400
- "d": "Claude's background jobs were explained one page at a time, and nothing listed them all together. One guide page lists every job Claude runs by itself: when it runs, what it reads, where you see the result, how to stop it, and what Claude fixes without asking."
401
- },
402
- {
403
- "n": 1208,
404
- "t": "Reports that need you come first, one ✓ each (redesign R)",
405
- "s": "Reports that need you come first, one ✓ each (redesign R)",
406
- "k": "new"
407
- }
408
- ]
409
- },
410
- {
411
- "v": "0.515.0",
412
- "at": "2026-11-03T23:11:51.264Z",
413
- "f": 4,
414
- "prs": [
415
- {
416
- "n": 1206,
417
- "t": "Intelligence reads the OKR file as its goal list (redesign A)",
418
- "s": "Intelligence reads the OKR file as its goal list (redesign A)",
419
- "k": "new"
420
- }
421
- ]
422
414
  }
423
415
  ]
424
416
  }
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@c4ac9039
1
+ kernel-source: pmos@6d70978e
2
2
  assembled: 2026-11-05
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-11-05T00:29:36Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-05T10:03:18Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-11-05T00:29:36Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-05T10:03:18Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-11-05T00:29:36Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-05T10:03:18Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -148,25 +148,23 @@ const sh = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf8' });
148
148
  const { ctx, page, errors } = await open({ theme, w: theme === 'dark' ? 390 : 1280, h: 844 });
149
149
  // premium-agreed-design (shared foundation): the design draws no Sources chip, so there is no dot on the page; the level is still read (GET /api/sources)
150
150
  const dot = await page.evaluate(() => (document.querySelector('#srcDot, #srcChip') ? 'drawn' : 'none'));
151
- await moreItem(page, 'Sources', '#srcHealth');
152
- if (theme === 'light') {
153
- // briefing-source-flow, slice 5: Home names which reads each part shows and when, from the same Sources read (Home's Sources card)
154
- const rd = await page.evaluate(() => ({ cal: ((document.querySelector('#evSettings [data-reads="Today"]') || {}).textContent || '').replace(/^Today:\s*/, ''), moves: ((document.querySelector('#evSettings [data-reads="Needs you"]') || {}).textContent || '').replace(/^Needs you:\s*/, '') }));
155
- ok('H5 Settings › Connectors names the read Today shows, and the initiatives, My work and the brief that Needs you shows', /^Calendar (read|not read yet|failed)/.test(rd.cal) && /Initiatives/.test(rd.moves) && /My work/.test(rd.moves) && /Brief (built|not built yet|off)/.test(rd.moves), JSON.stringify(rd));
156
- if (shot) await page.screenshot({ path: path.join(shot, 'cards-reads-light.png'), fullPage: true });
157
- }
158
- // the panel rises into place: measure the line once it has a width, not while it is still animating in (H4 read 0 px)
159
- await page.waitForFunction(() => { const e = document.querySelector('#evSettings #srcHealth'); return !!e && e.getBoundingClientRect().width > 0; }, null, { timeout: 15000 });
160
- // Home may draw the panel again after it opens (batch 22: the line read 0 px between two draws), so read it until it has its width
161
- let line = null;
162
- for (let k = 0; k < 25 && !(line && line.w > 0); k++) {
151
+ await moreItem(page, 'Sources', '.ev-reads');
152
+ // batch 28 item 1 (canvas CnA): one list, trouble first. The background issues are rows of the list (trouble rows, the amber dot);
153
+ // all well is silence: no background line, no Today/Needs you footer, no count on the Connectors tab
154
+ let list = null;
155
+ for (let k = 0; k < 25 && !(list && list.w > 0); k++) {
163
156
  if (k) await page.waitForTimeout(200);
164
- line = await page.$eval('#evSettings #srcHealth', (e) => ({ text: e.textContent.trim(), dot: (e.querySelector('.ev-dot') || {}).className || '', w: Math.round(e.getBoundingClientRect().width), sw: document.documentElement.scrollWidth })).catch(() => null);
157
+ list = await page.evaluate(() => { const r = document.querySelector('#evSettings .ev-reads'); const rows = [...document.querySelectorAll('#evSettings .ev-reads .ev-fr')];
158
+ return r ? { w: Math.round(r.getBoundingClientRect().width), sw: document.documentElement.scrollWidth, rows: rows.length, trouble: rows.filter((x) => x.classList.contains('ev-fr-t')).length,
159
+ firstQuiet: rows.findIndex((x) => !x.classList.contains('ev-fr-t')), lastTrouble: rows.map((x) => x.classList.contains('ev-fr-t')).lastIndexOf(true),
160
+ line: !!document.querySelector('#evSettings #srcHealth'), footer: !!document.querySelector('#evSettings [data-reads]'), count: !!document.querySelector('#evSettings [data-ev-sec="connectors"] .ev-k') } : null; });
165
161
  }
166
162
  if (theme === 'light') {
167
- ok('H2 Settings › Connectors opens with the background line on top, in the level\'s colour', line.text.startsWith(h.line.slice(0, 20)) && line.dot === 'ev-dot ' + (h.level === 'ok' ? 'ok' : 'risk'), JSON.stringify(line));
168
- ok('H3 no Sources chip and no dot are drawn on the page; the line above carries the level (premium-agreed-design)', dot === 'none', dot);
169
- } else ok('H4 on a phone the line fits: no sideways scroll', line.sw <= 390 && line.w > 0, JSON.stringify(line));
163
+ ok('H2 Settings › Connectors: background issues are trouble rows, before every quiet row; all well draws no background line', list && !list.line && (h.level === 'ok' ? true : list.trouble >= 1) && (list.lastTrouble < 0 || list.firstQuiet < 0 || list.lastTrouble < list.firstQuiet), JSON.stringify(list));
164
+ ok('H5 the Today/Needs you footer and the tab count are gone (CnA)', list && !list.footer && !list.count, JSON.stringify(list));
165
+ ok('H3 no Sources chip and no dot are drawn on the page (premium-agreed-design)', dot === 'none', dot);
166
+ if (shot) await page.screenshot({ path: path.join(shot, 'cards-reads-light.png'), fullPage: true });
167
+ } else ok('H4 on a phone the list fits: no sideways scroll', list && list.sw <= 390 && list.w > 0, JSON.stringify(list));
170
168
  if (shot) { await sleep(1200); await page.screenshot({ path: path.join(shot, 'sources-health-' + theme + '.png') }); }
171
169
  ok('H no page error (' + theme + ')', errors.length === 0, errors.join(' | '));
172
170
  await ctx.close();
@@ -1,4 +1,30 @@
1
1
  [
2
+ {
3
+ "v": "0.536.0",
4
+ "at": "2026-11-05T10:03:22.177Z",
5
+ "f": 4,
6
+ "prs": [
7
+ {
8
+ "n": 1252,
9
+ "t": "Apply an order to a report draft, and save my own",
10
+ "s": "Apply an order to a report draft, and save my own",
11
+ "k": "new"
12
+ }
13
+ ]
14
+ },
15
+ {
16
+ "v": "0.535.0",
17
+ "at": "2026-11-05T09:07:13.086Z",
18
+ "f": 4,
19
+ "prs": [
20
+ {
21
+ "n": 1251,
22
+ "t": "Calmer Settings › Connectors, one list with trouble first",
23
+ "s": "Calmer Settings › Connectors, one list with trouble first",
24
+ "k": "new"
25
+ }
26
+ ]
27
+ },
2
28
  {
3
29
  "v": "0.534.0",
4
30
  "at": "2026-11-05T00:29:39.663Z",
@@ -131,8 +131,8 @@ const put = (root, rel, text) => { fs.mkdirSync(path.dirname(path.join(root, rel
131
131
  const stale = await pg.evaluate(() => !!document.querySelector('#czFdRisk:not([hidden]), #czMenu .ev-md.risk'));
132
132
  await pg.waitForSelector('#evSettings:not([hidden])');
133
133
  if (!stale) await pg.click('#evSettings [data-ev-sec="connectors"]');
134
- const src = await until(() => pg.evaluate(() => { const d = document.querySelector('#evSettings:not([hidden])'); const r = d ? [...d.querySelectorAll('.ev-fr[data-src]')] : []; return r.length && /never posts/.test(d.textContent) ? { n: r.length, h: (d.querySelector('#evStTitle') || {}).textContent, all: !!d.querySelector('.ev-top [data-ev-readall]'), t: d.textContent } : null; }), 20000);
135
- ok('R1 Settings › Connectors lists each source with when it was last read, and ↻ in its header reads all', !!src && src.h === 'Connectors' && src.n >= 3 && src.all && /never posts/.test(src.t), JSON.stringify(src).slice(0, 300));
134
+ const src = await until(() => pg.evaluate(() => { const d = document.querySelector('#evSettings:not([hidden])'); const r = d ? [...d.querySelectorAll('.ev-fr[data-src]')] : []; return r.length ? { n: r.length, h: (d.querySelector('#evStTitle') || {}).textContent, all: !!d.querySelector('.ev-top [data-ev-readall]'), info: !!d.querySelector('.ev-top [data-ev-info]'), t: d.textContent } : null; }), 20000);
135
+ ok('R1 Settings › Connectors lists each source with when it was last read, ↻ in its header reads all, and (i) says it never posts', !!src && src.h === 'Connectors' && src.n >= 3 && src.all && src.info, JSON.stringify(src).slice(0, 300)); // batch 28 item 1 (CnA): "never posts" moved into the (i) button
136
136
  await snap(pg, 'connectors-1440-light');
137
137
  await pg.keyboard.press('Escape');
138
138
  await until(() => pg.evaluate(() => !document.querySelector('#evSettings:not([hidden])')));
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Data freshness: how the workspace keeps up to date, and how old each source is"
4
- timestamp: 2026-11-04
4
+ timestamp: 2026-11-05
5
5
  ---
6
6
 
7
7
  # Data freshness
@@ -55,7 +55,13 @@ The Sources list shows them as one row, **Your tools**. Its time is the oldest r
55
55
 
56
56
  ## Refresh all: the sources first, then the Briefing
57
57
 
58
- On Home, the Board and Intelligence, the sources list is in **Settings › Connectors**. There is no Sources card in the place menu. Each source shows when PMOS last read it. A source that is old or failed has an amber dot and ↻: press ↻ to read it again. ↻ in the Connectors header is **Refresh all**. When a source needs you, an amber box at the top of Settings names it, and the place menu opens Settings at Connectors. Claude repairs the other problems by itself, so you do not see a box for them (see below).
58
+ On Home, the Board and Intelligence, the sources list is in **Settings › Connectors**. There is no Sources card in the place menu. Settings › Connectors is one list, and the rows that need a look come first. Each source says its status one time, on its own row:
59
+
60
+ - A row that needs a look has an amber dot, one reason and one icon action. For example: **Jira** · Not read for 4 days. Claude read it again 5 times since 08:48. The action is ↻ (read it again) or a link to the place where you fix it.
61
+ - A row that is well shows its name and when PMOS last read it, for example **Initiatives** · 3 h ago. A source that is old shows ↻ too, while Claude reads it again by itself. When a read found nothing new, the row says **Checked 10:42 · same since 09:15**.
62
+ - Claude's background work that failed, and Git sync when it fails, are rows of the same list. A failed read of a source is the reason on that source's row.
63
+
64
+ When all is well, there is no other line. ↻ in the Connectors header is **Refresh all**. The (i) button says what PMOS reads and that it never posts. The switches for what PMOS reads on its own, and the other connectors of your Claude session, are folded into one row: open it to see them. When a source needs you, the Connectors tab has an amber dot, an amber box at the top of the other Settings sections names it, and the place menu opens Settings at Connectors. Claude repairs the other problems by itself, so you do not see a box for them (see below).
59
65
 
60
66
  ## When a source cannot be read: Claude repairs it first
61
67
 
@@ -65,9 +71,9 @@ You do not need to watch the sources. When a source that is on is past its limit
65
71
  2. After each try, Claude finds why the read failed: the sign-in of the connector expired, the connector is not connected or did not start, the network or the proxy did not answer, the tool asked to slow down (rate limit), Claude is signed out on this computer, or the skill stopped with an error. A read that ends but saves nothing new also counts as a failure.
66
72
  3. Each try is a line in the refresh log, with the trigger **repair** and a note such as "Repair 2 of 5: the last read failed because the network or the proxy did not answer".
67
73
 
68
- While Claude repairs a source, the source row says **Reading again** with its amber dot, because its data is still old. There is no amber box. When a read proves that the source is up to date, the row shows its new time. PMOS says a source is up to date only when a read proved it.
74
+ While Claude repairs a source, the source row says **Reading again**. It has no amber dot, because you do not need to do anything, and there is no amber box. When a read proves that the source is up to date, the row shows its new time. PMOS says a source is up to date only when a read proved it.
69
75
 
70
- The amber box shows only when you must act. It names the one action, links to the place where you do it, and says what Claude already tried. For example: **Reconnect Jira.** Jira was last read 3 days ago because its sign-in has expired. Board and the Jira-linked initiatives may be out of date. Claude read it again 2 times since 07:02.
76
+ The amber row on Settings › Connectors, and the amber box on the other sections, show only when you must act. It names the one action, links to the place where you do it, and says what Claude already tried. For example: **Reconnect Jira.** Jira was last read 3 days ago because its sign-in has expired. Board and the Jira-linked initiatives may be out of date. Claude read it again 2 times since 07:02.
71
77
 
72
78
  | Why | When the box shows | The action |
73
79
  |---|---|---|
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Reports: draft and issue a cadence report"
4
4
  description: "How pmos report drafts a cadence report with every number, how the notes are kept when you draft again, and how you issue and freeze a report."
5
- timestamp: 2026-11-04
5
+ timestamp: 2026-11-05
6
6
  ---
7
7
 
8
8
  # Reports: draft and issue a cadence report
@@ -65,6 +65,16 @@ When you draft a slot again, every note stays as you wrote it, next to its initi
65
65
 
66
66
  When you ask Claude in a chat to change a report, the change goes to the latest draft of that report, in its own file. The report's session edits only the notes that the change is about, even when they have text, and keeps every other note and every number as it is. It keeps each caveat (a sentence that says something is provisional, not decided or unverified), also when you ask for shorter text, and it tells you which notes it changed and quotes any sentence it removed. It drafts the same slot again only when you ask for fresh numbers. It never drafts a new slot for today. A change to a number, a date, a section or a slide is not made in the draft: the session tells you which setting to change in the template or the studio. If the latest cycle is issued, it is frozen, and the change is not passed on.
67
67
 
68
+ ## Orders: the slide order of a draft
69
+
70
+ An order is a default order of slides, for example the Weekly Review order. The writer keeps every reading, retitles each part as a finding, and adds or drops a part when the findings say so. PMOS has seven orders. You can also save your own.
71
+
72
+ - **Claude's pick.** When an order fits the question of a draft better than its own order, a ✦ shows under the slides. It names the order and the parts that it adds (in blue) or drops (struck through). Press ✓ to write the draft again in that order. Press ✕ to hide the ✦. No ✦ shows on an issued report, on a draft that started from an order, or when your readers agreed the order.
73
+ - **The orders.** The grid icon under the summary opens the orders: the draft as it is now, your orders, then the seven PMOS orders. Each card shows its first slides and its number of slides. Press a card to see all its slides, its parts and what it changes in your draft. Press ✓ to write the draft again in that order.
74
+ - **Agreed order.** When the readers agreed the order of the last issued report, today's draft stays as agreed. ✓ then reads "Use from the next cycle": the next draft starts in the order that you picked.
75
+ - **The name and Undo.** The order's name shows under the summary, from the draft to the issued report. For one day after a redraft, the Undo icon puts the draft back as it was. An issued report is frozen: it has no Undo.
76
+ - **My orders.** The bookmark icon under the summary saves the draft's order as yours. When you change a draft that started from an order, a ✦ asks if you want to save it. The bookmark under the slide saves one slide's layout to My slides. Only the parts and the slide layouts are saved, never the numbers or the words. Your orders are private and show first, marked Mine. You can rename and delete only your own orders. To use a saved slide in a draft is not possible yet.
77
+
68
78
  ## The commands
69
79
 
70
80
  `pmos report draft <cadence> [<slot>]` writes the draft to `planning/reports/<cadence>/<slot>.md`. The slot is a date, YYYY-MM-DD. If you do not give a slot, the slot is today. If a draft for the slot exists, the command stops and writes nothing. To draft the slot again and keep every note, add `--redraft`. To fix the clock, add `--now <time>`: two drafts with the same inputs and the same `--now` are the same, byte for byte.
@@ -27,7 +27,8 @@
27
27
  *
28
28
  * SMART REFRESH (steps 1 and 2). Each done read of a stored source gets a fingerprint of its facts (lib/read-fingerprint.js); the end
29
29
  * line says whether it brought anything new (`change`) and whether it was a full read or only asked the tool first (`how`). The
30
- * fingerprint and the time of the last full read are kept in the source's state (`fp`, `full_at`). When two or more tool sources are
30
+ * fingerprint and the time of the last full read are kept in the source's state (`fp`, `full_at`), and `seen` { at, since }: when the
31
+ * last read was measured and since when its facts are the same; scheduleOfSkill hands both to Settings › Connectors (checked_at, same_since). When two or more tool sources are
31
32
  * due at the same minute, one shared session reads them one after another (startSkill with a list), instead of one session each. A source of a
32
33
  * shared session is done when its stored read is newer than the session's start, and failed when it is not.
33
34
  * Nothing here calls out of this machine: a skill source's run is the PM's own agent session, with the PM's connections.
@@ -214,6 +215,8 @@ function create(host) {
214
215
  const change = FP.compare(st.fp, fp);
215
216
  const asked = P.askedSince(dirs.runtime || dir, id, o.at);
216
217
  if (fp) { next.fp = FP.keep(fp); measured = { change, how: asked ? 'asked' : 'full', fp: fp.hash }; }
218
+ // "Checked 10:42 · same since 09:15" (canvas SrSee R3, CnA): when this read was measured, and since when its facts are the same
219
+ if (fp) next.seen = { at: t, since: change === 'same' ? (st.seen && st.seen.since) || null : t };
217
220
  else measured = { how: asked ? 'asked' : 'full' };
218
221
  if (!asked) next.full_at = t;
219
222
  } catch (e) { warn('read fingerprint', e); }
@@ -584,7 +587,9 @@ function create(host) {
584
587
  const l = log().lastRun(id);
585
588
  const next = x.on ? R.nextSlot(x.s, now(), win()) : null;
586
589
  return { cadence: x.on ? R.textOf(x.s) : 'off', time: null, day: null, next_run: next ? new Date(next).toISOString() : null,
587
- last_run: l ? l.start_at : null, last_status: l ? (l.result === 'failed' ? 'fail' : l.result === 'done' ? 'ok' : 'started') : st.last_result || null };
590
+ last_run: l ? l.start_at : null, last_status: l ? (l.result === 'failed' ? 'fail' : l.result === 'done' ? 'ok' : 'started') : st.last_result || null,
591
+ checked_at: st.seen && st.seen.at ? new Date(st.seen.at).toISOString() : null,
592
+ same_since: st.seen && st.seen.since && st.seen.at > st.seen.since ? new Date(st.seen.since).toISOString() : null };
588
593
  }
589
594
 
590
595
  return { heal: healView, tick, started, ended, briefDone, codeRun, jobGate, runNow, refreshAll, allState, derived, setSource, setRules, ensureOn, rows, settings, log, scheduleOfSkill, managedSkill: (skill) => R.SKILL_OF.get(skill) || null };
@@ -55,6 +55,7 @@ const READING_ID_RE = /^[a-z0-9][a-z0-9._-]{0,80}$/;
55
55
  const SOURCE_ID_RE = /^[a-z0-9][a-z0-9._-]{0,40}$/;
56
56
  const TOKEN_RE = /\{\{r:([a-z0-9][a-z0-9._-]{0,80})(?:\|(of|n|value|label))?\}\}/g;
57
57
  const DAY = 86400000;
58
+ const NEXT_ORDER = 'order-next.json'; // the order the PM chose for the next cycle (lib/report-orders.js)
58
59
  const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
59
60
  const CYCLE_DAYS = { daily: 1, weekly: 7, biweekly: 14, 'bi-weekly': 14, monthly: 30, quarterly: 91 };
60
61
  const LIMITS = Object.freeze({ readings: 2000, findings: 60, sections: 30, blocks: 40, rows: 60, cols: 12, text: 2000, title: 240, rules: 60 });
@@ -205,6 +206,14 @@ function issuedSlots(root, id) {
205
206
  /** The headings of a document the engine did not write: the agreed outline of an older issue. */
206
207
  const headingsOf = (md) => str(md).replace(/^---[\s\S]*?\n---\n?/, '').split('\n').filter((l) => /^## /.test(l)).map((l) => l.slice(3).trim()).filter(Boolean);
207
208
 
209
+ /** null when o is a usable order { id, name, outline: [{ part, layout }] } (1 to 30 parts, each layout one of lib/report-slides.js LAYOUTS), else why. */
210
+ function orderError(o) {
211
+ if (!isObj(o) || !/^[a-z0-9][a-z0-9:-]{0,60}$/.test(str(o.id)) || !str(o.name).trim()) return 'an order has an id and a name';
212
+ if (!Array.isArray(o.outline) || !o.outline.length || o.outline.length > 30) return 'an order has 1 to 30 parts';
213
+ const L = require('./report-slides.js').LAYOUTS;
214
+ const bad = o.outline.find((p) => !isObj(p) || !str(p.part).trim() || !L.includes(str(p.layout)));
215
+ return bad ? `part ${JSON.stringify(bad && bad.part)} has no known layout` : null;
216
+ }
208
217
  function briefOf(root, id, { slot, ask, readers, sources, template, now = Date.now() } = {}) {
209
218
  if (!ID_RE.test(str(id))) return refuse(400, 'the report id is lower case letters, digits and dashes, at most 40, starting with a letter');
210
219
  const today = slot || dayOf(now);
@@ -281,10 +290,20 @@ function briefOf(root, id, { slot, ask, readers, sources, template, now = Date.n
281
290
  from.push(LP.md.rel);
282
291
  }
283
292
  // a report template (lib/report-library.js) is a default order of slide layouts for a report with no agreed outline; the agreed one wins
293
+ // template is an id of lib/report-library.js TEMPLATES, or an order already resolved ({ id, name, outline }: a saved "my order" or the brief's own,
294
+ // kept across a re-read). chosen: the PM pressed ✓ on it for this cycle, so it wins over the agreed outline (batch 27 item 16, lib/report-orders.js).
295
+ // an order the PM chose on an agreed outline "from the next cycle" (lib/report-orders.js setNext): a later slot starts from it
296
+ if (!template) { const nx = readJson(path.join(paths(abs, id).dir, NEXT_ORDER)); if (nx && isObj(nx.order) && isDate(nx.from) && today > nx.from) template = { ...nx.order, chosen: true }; }
284
297
  if (template) {
285
- const t = require('./report-library.js').templateOf(str(template));
286
- if (!t) return refuse(400, `there is no report template ${template}. The templates: ${require('./report-library.js').TEMPLATES.map((x) => x.id).join(', ')}`);
287
- if (!brief.agreed_outline.length) { from.push(`template:${t.id}`); brief.template = { id: t.id, name: t.name, outline: t.outline.map((o) => ({ part: o.part, layout: o.layout })) }; }
298
+ const t = isObj(template) ? orderError(template) ? null : template : require('./report-library.js').templateOf(str(template));
299
+ if (!t) return refuse(400, isObj(template) ? `the order is not usable: ${orderError(template)}` : `there is no report template ${template}. The templates: ${require('./report-library.js').TEMPLATES.map((x) => x.id).join(', ')}`);
300
+ const chosen = !!(isObj(template) && template.chosen);
301
+ if (!brief.agreed_outline.length || chosen) {
302
+ from.push(`template:${t.id}`);
303
+ brief.template = { id: str(t.id), name: str(t.name), outline: t.outline.map((o) => ({ part: str(o.part), layout: str(o.layout) })), ...(chosen ? { chosen: true } : {}) };
304
+ // the PM chose another order over the agreed one: the agreed outline is kept as an example only
305
+ if (chosen && brief.agreed_outline.length) { brief.earlier_outline = brief.agreed_outline; brief.agreed_outline = []; }
306
+ }
288
307
  }
289
308
  brief.rules = rulesOf(abs, id);
290
309
  for (const r of brief.rules) if (r.kind === 'voice' || r.kind === 'wording') brief.voice.push(r.text);
@@ -778,7 +797,7 @@ function make(root, id, { slot, ask, readers, sources, template, add, gaps, addF
778
797
  const old = readJson(P.brief.abs);
779
798
  // a re-read keeps the brief's chat question and named sources unless new ones are given
780
799
  const b = briefOf(abs, id, { slot: day, now, ask: ask || (old && old.from && old.from.includes('chat') ? old.question : undefined), readers: readers || (old && old.from && old.from.includes('readers') ? old.readers : undefined),
781
- template: template || (old && old.template ? old.template.id : undefined),
800
+ template: template || (old && old.template ? (!old.template.chosen && require('./report-library.js').templateOf(old.template.id) ? old.template.id : old.template) : undefined),
782
801
  sources: [...(old ? old.sources.filter((s) => s.kind === 'pack' || s.kind === 'connector' || s.kind === 'document') : []), ...(sources || [])] });
783
802
  if (!b.ok) return b;
784
803
  const rd = readingsOf(abs, b.brief, { add, gaps, keep: readJson(P.readings.abs), now });
@@ -790,6 +809,8 @@ function make(root, id, { slot, ask, readers, sources, template, add, gaps, addF
790
809
  const fd = findingsOf(b.brief, rd, { prior, add: keepFiled.concat(Array.isArray(addFindings) ? addFindings : []) });
791
810
  if (fd.ok === false) return fd;
792
811
  writeJson(P.brief.abs, b.brief);
812
+ // the order chosen for the next cycle is used once: this brief now carries it, so the record goes
813
+ if (b.brief.template && b.brief.template.chosen) { const nf = path.join(P.dir, NEXT_ORDER); const nx = readJson(nf); if (nx && isObj(nx.order) && nx.order.id === b.brief.template.id && day > nx.from) { try { fs.unlinkSync(nf); } catch { /* already gone */ } } }
793
814
  writeJson(P.readings.abs, { at: rd.at, readings: rd.readings, gaps: rd.gaps });
794
815
  writeJson(P.findings.abs, fd);
795
816
  return { ok: true, brief: b.brief, readings: { readings: rd.readings, gaps: rd.gaps }, findings: fd, paths: { brief: P.brief.rel, readings: P.readings.rel, findings: P.findings.rel, report: P.report.rel, md: P.md.rel } };
@@ -834,7 +855,7 @@ function engineDeckOf(absMd, look) {
834
855
  }
835
856
 
836
857
  module.exports = {
837
- ID_RE, BEYOND, BEYOND_KEYS, VOICE, RULE_KINDS, BLOCK_KINDS, LIMITS,
838
- paths, briefOf, readingsOf, findingsOf, resolve, tokensIn, bareFigures, checkReport, shapeError, renderMarkdown, deckOf, make, write,
858
+ ID_RE, NEXT_ORDER, BEYOND, BEYOND_KEYS, VOICE, RULE_KINDS, BLOCK_KINDS, LIMITS,
859
+ paths, briefOf, orderError, readingsOf, findingsOf, resolve, tokensIn, bareFigures, checkReport, shapeError, renderMarkdown, deckOf, make, write,
839
860
  engineReportOf, engineDeckOf, rulesOf, addRule, removeRule, closeAsk, parseSource, sourcesFromText, full, valueText,
840
861
  };
@@ -131,4 +131,4 @@ function templateDeck(id, o = {}) {
131
131
  /** The library page: every layout, or one template (o.template). */
132
132
  const libraryHtml = (o = {}) => RS.deckHtml(o.template ? templateDeck(o.template, o) : sampleDeck(o));
133
133
 
134
- module.exports = { sampleDeck, libraryHtml, TEMPLATES, templateOf, templateCard, templateDeck };
134
+ module.exports = { sampleDeck, libraryHtml, TEMPLATES, templateOf, templateCard, templateDeck, NAMES };
@@ -0,0 +1,218 @@
1
+ 'use strict';
2
+ /* report-orders.js — apply an order to a report draft, and keep my own orders (batch 27 item 16, agreed by Wawan 2026-11-05 00:53 "Build the
3
+ * recommendation"; main canvas boards TpA, TpD, TpE; brief /mnt/project-files/feedback-batch-27/item16/build-brief.md).
4
+ *
5
+ * The app says "order", never "template": T<n> already names a report's saved settings (lib/report-templates.js). An ORDER is a default order of
6
+ * slide layouts, { id, name, outline: [{ part, layout }] }: the seven of lib/report-library.js TEMPLATES, or one the PM saved ("Mine"). It is never
7
+ * a fixed structure: the writer retitles each part as a finding and adds or drops a part when the findings say so.
8
+ *
9
+ * partsOf(report, brief) → [{ part, layout }] the draft's order: one per section, named by its order's part or its layout (pure)
10
+ * diff(now, order) → { adds: [part], drops: [part] } by layout; the title and sources slides every deck has are left out (pure)
11
+ * nowOf(brief, report) → { kind: 'agreed'|'order'|'standard', id, name, parts, mine, edited, changes } the draft's order (pure)
12
+ * pick(brief, report) → { id, name, adds, drops } | null the ✦: the order that fits the report's question, by plain rules (pure)
13
+ * orderOf(id, mine) → an order by id: a TEMPLATES id or a saved "mine-<n>" (mine = fold(...).orders)
14
+ * previewDeck(order) → the order drawn with placeholder words, one slide per part (lib/report-slides.js deckHtml takes it)
15
+ * snapshot / undoOf / undo the draft as it was before a redraft, kept one day (Undo)
16
+ * setNext / nextOf an order chosen on an agreed outline, used from the next cycle (lib/report-engine.js briefOf reads it)
17
+ * MY ORDERS (person level, private): <runtimeDir>/report-orders.jsonl, append only
18
+ * { ev: 'save', id: 'mine-<n>', kind: 'order'|'slide', name, outline, from: { report, slot }, at } { ev: 'rename', id, name, at } { ev: 'remove', id, at }
19
+ * fold(lines) → { orders, slides } (newest first) · readLines(dir) · save(dir, o) · rename(dir, id, name) · remove(dir, id)
20
+ *
21
+ * WHAT IS SAVED. Parts and slide layouts only. Never a number, a sentence or a reading: those belong to one cycle and would be stale. A part's
22
+ * name is its order's part name, else its layout's name ("Ledger"); never the section's title, which is a finding.
23
+ */
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const crypto = require('crypto');
27
+ const RL = require('./report-library.js');
28
+
29
+ const DAY = 86400000;
30
+ const UNDO_FOR = DAY; // the previous draft is kept one day (TpA 3)
31
+ const DRAFTING_FOR = 30 * 60000; // a redraft that has not landed after 30 minutes is no longer "redrafting"
32
+ const ALWAYS = new Set(['overview', 'sources']); // the engine draws the title and the sources slides on every deck
33
+ const LEDGER = 'report-orders.jsonl';
34
+ const MINE_RE = /^mine-[1-9][0-9]{0,5}$/;
35
+ const FILES = ['.md', '.report.json', '.brief.json', '.checks.json', '.findings.json'];
36
+ const str = (v) => (typeof v === 'string' ? v : v == null ? '' : String(v));
37
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
38
+ const nameOfLayout = (l) => (RL.NAMES[l] || (l === 'component' ? 'PMOS component' : 'Text'));
39
+ const RE = () => require('./report-engine.js');
40
+
41
+ /* ── the draft's order (pure) ── */
42
+ /** One part per section of the engine's report, in order. The name comes from the order the draft began with (the first unused part of the same
43
+ * layout), else from the layout. A section's own title is a finding, so it is never a part name. */
44
+ function partsOf(report, brief) {
45
+ const secs = isObj(report) && Array.isArray(report.sections) ? report.sections : [];
46
+ const pool = (brief && brief.template && Array.isArray(brief.template.outline) ? brief.template.outline : []).map((p) => ({ ...p, used: false }));
47
+ return secs.map((s) => {
48
+ const layout = str(s.layout) || 'blocks';
49
+ const m = pool.find((p) => !p.used && p.layout === layout);
50
+ if (m) m.used = true;
51
+ return { part: m ? m.part : nameOfLayout(layout), layout };
52
+ });
53
+ }
54
+ const core = (parts) => (parts || []).filter((p) => !ALWAYS.has(p.layout));
55
+ /** What an order adds to the draft and what it drops, matched by layout (a layout used twice counts twice). */
56
+ function diff(now, order) {
57
+ const left = core(now).map((p) => ({ ...p, hit: false }));
58
+ const adds = [];
59
+ for (const p of core(order)) { const m = left.find((x) => !x.hit && x.layout === p.layout); if (m) m.hit = true; else adds.push(p.part); }
60
+ return { adds, drops: left.filter((x) => !x.hit).map((x) => x.part) };
61
+ }
62
+ const same = (d) => !d.adds.length && !d.drops.length;
63
+ const isMine = (id) => MINE_RE.test(str(id));
64
+ /** The draft's order: agreed with its readers, an order (edited or not), or the standard one the writer chose. */
65
+ function nowOf(brief, report) {
66
+ const parts = partsOf(report, brief);
67
+ const b = brief || {};
68
+ if (Array.isArray(b.agreed_outline) && b.agreed_outline.length) return { kind: 'agreed', id: 'agreed', name: 'Agreed order', parts, mine: false, edited: false, changes: { adds: [], drops: [] }, since: b.last_issue || null };
69
+ if (b.template && b.template.id) {
70
+ const ch = report ? diff(b.template.outline, parts) : { adds: [], drops: [] }; // what the draft added to its order (adds) and dropped (drops)
71
+ return { kind: 'order', id: str(b.template.id), name: str(b.template.name), parts, mine: isMine(b.template.id), edited: !!report && !same(ch), changes: { adds: ch.adds, drops: ch.drops } };
72
+ }
73
+ return { kind: 'standard', id: 'standard', name: 'Standard order', parts, mine: false, edited: false, changes: { adds: [], drops: [] } };
74
+ }
75
+
76
+ /* ── the ✦ pick (pure). Plain rules over the brief's words, first match wins; no model. Silent (null) when the outline is agreed, when the draft
77
+ * already began from an order, or when the order would change nothing. Claude's own pick while drafting is not proven yet (owed: 5 real drafts). */
78
+ const RULES = [
79
+ ['incident-review', /\b(incident|outage|post-?mortem|downtime|sev ?[0-4]|root cause)\b/],
80
+ ['meeting-recap', /\b(meeting|recap|minutes|stand-?up|retro(spective)?|workshop)\b/],
81
+ ['decision-brief', /\b(decide|decision|should we|go\/no-go|approve|approval)\b/],
82
+ ['program-kickoff', /\b(kick-?off|charter|new program(me)?|program(me)? start)\b/],
83
+ ['portfolio-snapshot', /\b(portfolio|programs|programmes|across products)\b/],
84
+ ['quarterly-review', /\b(quarter(ly)?|q[1-4]|okr review|qbr)\b/],
85
+ ['weekly-review', /\b(weekly|this week|week in review|last week)\b/],
86
+ ];
87
+ function pick(brief, report) {
88
+ const b = brief || {};
89
+ if ((Array.isArray(b.agreed_outline) && b.agreed_outline.length) || (b.template && b.template.id)) return null;
90
+ const words = [b.title, b.question, ...(Array.isArray(b.questions) ? b.questions : []), b.readers].map(str).join(' \n ').toLowerCase();
91
+ let id = (RULES.find(([, re]) => re.test(words)) || [])[0] || null;
92
+ if (!id && b.recurring && b.cycle_days === 7) id = 'weekly-review';
93
+ if (!id && b.recurring && b.cycle_days >= 80) id = 'quarterly-review';
94
+ const t = id ? RL.templateOf(id) : null;
95
+ if (!t) return null;
96
+ const d = diff(partsOf(report, b), t.outline);
97
+ return report && same(d) ? null : { id: t.id, name: t.name, ...d };
98
+ }
99
+
100
+ /* ── orders by id, and their pictures ── */
101
+ function asOrder(o) { return { id: o.id, name: o.name, outline: o.outline.map((p) => ({ part: p.part, layout: p.layout })) }; }
102
+ function orderOf(id, mine = []) {
103
+ const t = RL.templateOf(str(id));
104
+ if (t) return asOrder(t);
105
+ const m = isMine(id) ? (mine || []).find((x) => x.id === id) : null;
106
+ return m ? asOrder(m) : null;
107
+ }
108
+ /** One line under a card: what the order is for (a PMOS order) or when it was saved (mine). */
109
+ function subOf(id, mine) { const t = RL.templateOf(id); if (t) return t.use; const m = (mine || []).find((x) => x.id === id); return m ? 'Saved ' + str(m.at).slice(0, 10) : ''; }
110
+ /** The order with placeholder words: one slide per part, in its layout (the library's sample of that layout, titled by the part). */
111
+ function previewDeck(order, o = {}) {
112
+ if (!order) return null;
113
+ const lib = RL.sampleDeck(o).slides;
114
+ const slides = order.outline.map((p) => {
115
+ const s = lib.find((x) => x.layout === p.layout) || { ...lib.find((x) => x.layout === 'statement'), layout: 'statement' };
116
+ return { ...s, eyebrow: `${order.name} · ${p.part}`, title: p.part, sub: s.title, source: 'Order · placeholder words' };
117
+ });
118
+ slides.forEach((s, i) => { s.n = i + 1; });
119
+ return { name: order.name, today: o.today || '2026-11-04', slides };
120
+ }
121
+
122
+ /* ── Undo: the draft before a redraft, kept one day ── */
123
+ const sha = (t) => crypto.createHash('sha256').update(String(t)).digest('hex').slice(0, 16);
124
+ const undoDir = (P, slot) => path.join(P.dir, '.undo', slot);
125
+ function readJson(f) { try { const o = JSON.parse(fs.readFileSync(f, 'utf8')); return isObj(o) ? o : null; } catch { return null; } }
126
+ /** Copy the draft's files aside before a redraft. One snapshot per slot: Undo gives back the draft as it was before the NEWEST redraft. */
127
+ function snapshot(root, id, slot, { order = null, now = Date.now() } = {}) {
128
+ const P = RE().paths(root, id, slot);
129
+ const dir = undoDir(P, slot);
130
+ fs.rmSync(dir, { recursive: true, force: true });
131
+ fs.mkdirSync(dir, { recursive: true });
132
+ const had = [];
133
+ for (const ext of FILES) { const f = path.join(P.dir, slot + ext); if (fs.existsSync(f)) { fs.copyFileSync(f, path.join(dir, slot + ext)); had.push(ext); } }
134
+ let md = ''; try { md = fs.readFileSync(P.md.abs, 'utf8'); } catch { /* no document yet */ }
135
+ const meta = { at: new Date(now).toISOString(), order: order ? { id: order.id, name: order.name } : null, had, md: sha(md) };
136
+ fs.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify(meta) + '\n');
137
+ return meta;
138
+ }
139
+ /** The Undo of a slot: { at, until, order, state: 'drafting'|'done'|'late' } while its day lasts and the slot is not issued, else null.
140
+ * drafting = the document has not changed since the press (the session still writes); late = it has not changed after 30 minutes. */
141
+ function undoOf(root, id, slot, { now = Date.now(), issued = false } = {}) {
142
+ if (issued) return null;
143
+ let P; try { P = RE().paths(root, id, slot); } catch { return null; }
144
+ const meta = readJson(path.join(undoDir(P, slot), 'meta.json'));
145
+ if (!meta || !Number.isFinite(Date.parse(meta.at))) return null;
146
+ const at = Date.parse(meta.at);
147
+ if (now - at > UNDO_FOR) return null;
148
+ let md = ''; try { md = fs.readFileSync(P.md.abs, 'utf8'); } catch { /* gone */ }
149
+ const changed = sha(md) !== meta.md;
150
+ return { at: meta.at, until: new Date(at + UNDO_FOR).toISOString(), order: meta.order, state: changed ? 'done' : now - at > DRAFTING_FOR ? 'late' : 'drafting' };
151
+ }
152
+ /** Put the draft back as it was before the redraft: the files it had come back, the files it did not have go. */
153
+ function undo(root, id, slot, o = {}) {
154
+ const u = undoOf(root, id, slot, o);
155
+ if (!u) return { ok: false, status: 409, error: 'there is nothing to undo on this draft' };
156
+ const P = RE().paths(root, id, slot); const dir = undoDir(P, slot);
157
+ const meta = readJson(path.join(dir, 'meta.json'));
158
+ for (const ext of FILES) {
159
+ const to = path.join(P.dir, slot + ext);
160
+ if (meta.had.includes(ext)) fs.copyFileSync(path.join(dir, slot + ext), to);
161
+ else fs.rmSync(to, { force: true });
162
+ }
163
+ fs.rmSync(dir, { recursive: true, force: true });
164
+ return { ok: true, order: u.order };
165
+ }
166
+ const dropUndo = (root, id, slot) => { try { const P = RE().paths(root, id, slot); fs.rmSync(undoDir(P, slot), { recursive: true, force: true }); } catch { /* none */ } };
167
+
168
+ /* ── an order for the next cycle (an agreed outline: today's draft stays as agreed) ── */
169
+ function setNext(root, id, order, from, { now = Date.now(), by = 'pm' } = {}) {
170
+ const P = RE().paths(root, id);
171
+ fs.mkdirSync(P.dir, { recursive: true });
172
+ fs.writeFileSync(path.join(P.dir, RE().NEXT_ORDER), JSON.stringify({ order: asOrder(order), from, at: new Date(now).toISOString(), by }) + '\n');
173
+ return { ok: true };
174
+ }
175
+ function nextOf(root, id) { try { const x = readJson(path.join(RE().paths(root, id).dir, RE().NEXT_ORDER)); return x && isObj(x.order) ? { id: x.order.id, name: x.order.name, from: x.from } : null; } catch { return null; } }
176
+
177
+ /* ── my orders: person level, private ── */
178
+ const ledger = (dir) => path.join(path.resolve(String(dir)), LEDGER);
179
+ function readLines(dir) {
180
+ let t; try { t = fs.readFileSync(ledger(dir), 'utf8'); } catch { return []; }
181
+ return t.split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(isObj);
182
+ }
183
+ function fold(lines) {
184
+ const m = new Map();
185
+ for (const e of lines || []) {
186
+ if (e.ev === 'save' && isMine(e.id) && Array.isArray(e.outline)) m.set(e.id, { id: e.id, kind: e.kind === 'slide' ? 'slide' : 'order', name: str(e.name), outline: e.outline, from: e.from || null, at: str(e.at) });
187
+ else if (e.ev === 'rename' && m.has(e.id)) m.get(e.id).name = str(e.name);
188
+ else if (e.ev === 'remove') m.delete(e.id);
189
+ }
190
+ const all = [...m.values()].reverse();
191
+ return { orders: all.filter((x) => x.kind === 'order'), slides: all.filter((x) => x.kind === 'slide') };
192
+ }
193
+ const cleanName = (n) => str(n).replace(/\{\{r:[^}]*\}\}/g, '').replace(/\s+/g, ' ').trim().slice(0, 80);
194
+ /** Save an order (kind 'order', the whole deck's parts) or one slide (kind 'slide', one part). Only parts and layouts are kept. */
195
+ function save(dir, { kind = 'order', name, outline, from = null }, { now = Date.now() } = {}) {
196
+ const nm = cleanName(name);
197
+ if (!nm) return { ok: false, status: 400, error: 'say its name' };
198
+ const parts = (Array.isArray(outline) ? outline : []).map((p) => ({ part: cleanName(p && p.part).slice(0, 60) || nameOfLayout(p && p.layout), layout: str(p && p.layout) }));
199
+ const err = RE().orderError({ id: 'mine-1', name: nm, outline: parts });
200
+ if (err) return { ok: false, status: 400, error: err };
201
+ if (kind === 'slide' && parts.length !== 1) return { ok: false, status: 400, error: 'a slide is one part' };
202
+ const lines = readLines(dir);
203
+ const n = lines.reduce((x, e) => Math.max(x, +(/^mine-(\d+)$/.exec(str(e.id)) || [0, 0])[1]), 0) + 1;
204
+ const e = { ev: 'save', id: 'mine-' + n, kind: kind === 'slide' ? 'slide' : 'order', name: nm, outline: parts, from: isObj(from) ? { report: str(from.report), slot: str(from.slot) } : null, at: new Date(now).toISOString() };
205
+ fs.mkdirSync(path.resolve(String(dir)), { recursive: true });
206
+ fs.appendFileSync(ledger(dir), JSON.stringify(e) + '\n');
207
+ return { ok: true, id: e.id, saved: fold(lines.concat(e)) };
208
+ }
209
+ function change(dir, ev, id, extra, now) {
210
+ if (!isMine(id) || ![...fold(readLines(dir)).orders, ...fold(readLines(dir)).slides].some((x) => x.id === id)) return { ok: false, status: 404, error: 'there is no saved order ' + id };
211
+ fs.appendFileSync(ledger(dir), JSON.stringify({ ev, id, ...extra, at: new Date(now).toISOString() }) + '\n');
212
+ return { ok: true };
213
+ }
214
+ function rename(dir, id, name, { now = Date.now() } = {}) { const nm = cleanName(name); if (!nm) return { ok: false, status: 400, error: 'say its name' }; return change(dir, 'rename', id, { name: nm }, now); }
215
+ function remove(dir, id, { now = Date.now() } = {}) { return change(dir, 'remove', id, {}, now); }
216
+
217
+ module.exports = { ALWAYS, LEDGER, UNDO_FOR, partsOf, diff, nowOf, pick, orderOf, subOf, previewDeck, isMine, nameOfLayout,
218
+ snapshot, undoOf, undo, dropUndo, setNext, nextOf, readLines, fold, save, rename, remove };