create-pmos 0.506.0 → 0.508.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.506.0",
3
+ "version": "0.508.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.508.0",
37
+ "at": "2026-11-03T12:33:41.832Z",
38
+ "f": 3,
39
+ "prs": [
40
+ {
41
+ "n": 1196,
42
+ "t": "Decision memory keeps the guard's audit in the per-user folder",
43
+ "s": "Decision memory keeps the guard's audit in the per-user folder",
44
+ "k": "fix"
45
+ }
46
+ ]
47
+ },
48
+ {
49
+ "v": "0.507.0",
50
+ "at": "2026-11-03T12:15:29.445Z",
51
+ "f": 3,
52
+ "prs": [
53
+ {
54
+ "n": 1194,
55
+ "t": "Rehearse a plan before you approve it (pmos rehearse)",
56
+ "s": "Rehearse a plan before you approve it (pmos rehearse)",
57
+ "k": "new"
58
+ }
59
+ ]
60
+ },
35
61
  {
36
62
  "v": "0.506.0",
37
63
  "at": "2026-11-03T12:09:23.758Z",
@@ -427,32 +453,6 @@
427
453
  "k": "fix"
428
454
  }
429
455
  ]
430
- },
431
- {
432
- "v": "0.488.0",
433
- "at": "2026-11-02T06:16:07.264Z",
434
- "f": 3,
435
- "prs": [
436
- {
437
- "n": 1148,
438
- "t": "Report Viewer follows the agreed design RfH2 (batch 23 item 3)",
439
- "s": "Report Viewer follows the agreed design RfH2 (batch 23 item 3)",
440
- "k": "new"
441
- }
442
- ]
443
- },
444
- {
445
- "v": "0.487.0",
446
- "at": "2026-11-02T05:32:30.382Z",
447
- "f": 3,
448
- "prs": [
449
- {
450
- "n": 1147,
451
- "t": "Batch 23 feedback",
452
- "s": "Batch 23 feedback",
453
- "k": "new"
454
- }
455
- ]
456
456
  }
457
457
  ]
458
458
  }
package/payload/AGENTS.md CHANGED
@@ -54,6 +54,8 @@ State files are **append-only**: never rewrite or delete an existing line or ini
54
54
 
55
55
  **Read current state through `pmos`, never from a ledger line.** A `.jsonl` ledger is a log of changes: a later line replaces an earlier one, so its first line for a task can hold dates that were changed long ago. Read an initiative's tasks, with the state and the start and due day the Board timeline shows, with `pmos task list <initiative> --all` (add `--json` for fields). Read the Board with `pmos board`, and initiatives with `pmos initiative list`. Never quote a date, owner or state that you read from `boards.jsonl` or another ledger by hand. When `pmos` cannot answer, say so; do not fall back to the raw file.
56
56
 
57
+ **Rehearse a change before you make it.** When the PM asks you to move a task's dates, change an initiative's end date, or cut a task or an initiative, run `pmos rehearse move|end|cut …` first and show the PM what it prints: what the change breaks and what it helps in other initiatives, goals and program milestones, each with its source. Then wait. Make the change with `pmos task set` or `pmos task state` only after the PM says yes in their own words; a rehearsal is never approval. When it says nothing else moves, say so in one line and go on. When it says rehearsals are off, make the change as asked.
58
+
57
59
  ## Work-type lanes (D56)
58
60
 
59
61
  Every initiative carries a `type` — the lane picks what the gate requires **before** work; the kernel (anchor — a KR **or** `health:<id>` from `planning/okrs/health-budgets.md` (D59) — eval-before-work in lane-appropriate form, human Acceptance, append-only state) holds in every lane. Unknown/missing type = `feature`, the strictest lane. The enum is closed — never invent a lane; a work shape with no lane escalates to the PM.
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@d606c914
1
+ kernel-source: pmos@c4e0e4ba
2
2
  assembled: 2026-11-03
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-03T12:09:20Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-03T12:33:39Z"}
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-03T12:09:20Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-03T12:33:39Z"}
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-03T12:09:20Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-03T12:33:39Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -1,4 +1,30 @@
1
1
  [
2
+ {
3
+ "v": "0.508.0",
4
+ "at": "2026-11-03T12:33:41.832Z",
5
+ "f": 3,
6
+ "prs": [
7
+ {
8
+ "n": 1196,
9
+ "t": "Decision memory keeps the guard's audit in the per-user folder",
10
+ "s": "Decision memory keeps the guard's audit in the per-user folder",
11
+ "k": "fix"
12
+ }
13
+ ]
14
+ },
15
+ {
16
+ "v": "0.507.0",
17
+ "at": "2026-11-03T12:15:29.445Z",
18
+ "f": 3,
19
+ "prs": [
20
+ {
21
+ "n": 1194,
22
+ "t": "Rehearse a plan before you approve it (pmos rehearse)",
23
+ "s": "Rehearse a plan before you approve it (pmos rehearse)",
24
+ "k": "new"
25
+ }
26
+ ]
27
+ },
2
28
  {
3
29
  "v": "0.506.0",
4
30
  "at": "2026-11-03T12:09:23.758Z",
@@ -0,0 +1,159 @@
1
+ {
2
+ "_comment": "A recorded snapshot for the rehearse replay cases (lib/rehearse-guard.js). `contents` are the ledger and record files its sources cite; the guard writes them to a temporary folder and computes every source from them, so each effect cites a line that is really there.",
3
+ "today": "2026-11-03",
4
+ "tasks": [
5
+ {
6
+ "uid": "a1",
7
+ "title": "Design checkout",
8
+ "ini": "checkout",
9
+ "status": "To do",
10
+ "start": "2026-11-05",
11
+ "end": "2026-11-10"
12
+ },
13
+ {
14
+ "uid": "a2",
15
+ "title": "Build checkout",
16
+ "ini": "checkout",
17
+ "status": "To do",
18
+ "start": "2026-11-11",
19
+ "end": "2026-11-20"
20
+ },
21
+ {
22
+ "uid": "b1",
23
+ "title": "Launch promo",
24
+ "ini": "promo",
25
+ "status": "To do",
26
+ "start": "2026-11-21",
27
+ "end": "2026-11-25"
28
+ },
29
+ {
30
+ "uid": "b2",
31
+ "title": "Promo live",
32
+ "ini": "promo",
33
+ "status": "To do",
34
+ "start": "2026-11-28",
35
+ "end": "2026-11-28",
36
+ "milestone": true
37
+ },
38
+ {
39
+ "uid": "J-1",
40
+ "title": "API change",
41
+ "ini": "promo",
42
+ "status": "To do",
43
+ "start": "2026-11-21",
44
+ "end": "2026-11-23",
45
+ "src": "jira"
46
+ }
47
+ ],
48
+ "links": [
49
+ {
50
+ "from": "a1",
51
+ "to": "a2",
52
+ "type": "fs",
53
+ "lag": 0
54
+ },
55
+ {
56
+ "from": "a2",
57
+ "to": "b1",
58
+ "type": "fs",
59
+ "lag": 0
60
+ },
61
+ {
62
+ "from": "b1",
63
+ "to": "b2",
64
+ "type": "fs",
65
+ "lag": 0
66
+ },
67
+ {
68
+ "from": "a2",
69
+ "to": "J-1",
70
+ "type": "fs",
71
+ "lag": 0
72
+ }
73
+ ],
74
+ "rows": [
75
+ {
76
+ "id": "checkout",
77
+ "title": "Checkout",
78
+ "end": "2026-11-22",
79
+ "anchor": "conv"
80
+ },
81
+ {
82
+ "id": "promo",
83
+ "title": "Promo",
84
+ "end": "2026-11-30",
85
+ "anchor": "conv"
86
+ }
87
+ ],
88
+ "goals": [
89
+ {
90
+ "id": "conv",
91
+ "title": "Checkout conversion",
92
+ "byAt": "2026-11-29",
93
+ "moves": [
94
+ "checkout",
95
+ "promo"
96
+ ],
97
+ "forecast": {
98
+ "state": "at-risk",
99
+ "likely": 2.6
100
+ }
101
+ }
102
+ ],
103
+ "programs": [
104
+ {
105
+ "id": "q4",
106
+ "title": "Q4 holiday",
107
+ "milestones": [
108
+ {
109
+ "id": "m",
110
+ "name": "Holiday ready",
111
+ "date": "2026-11-30",
112
+ "waits": [
113
+ "promo"
114
+ ],
115
+ "done": false
116
+ }
117
+ ]
118
+ }
119
+ ],
120
+ "files": {
121
+ "links": "rehearse-links.jsonl",
122
+ "krs": "rehearse-krs.jsonl",
123
+ "programs": "rehearse-programs.jsonl",
124
+ "records": {
125
+ "checkout": "rehearse-checkout.txt",
126
+ "promo": "rehearse-promo.txt"
127
+ }
128
+ },
129
+ "contents": {
130
+ "rehearse-links.jsonl": [
131
+ "{\"at\":\"2026-10-20T09:00:00Z\",\"by\":\"pm\",\"kind\":\"link\",\"board\":\"b\",\"ini\":\"checkout\",\"from\":\"a1\",\"to\":\"a2\",\"type\":\"fs\",\"lag\":0,\"source\":\"page\"}",
132
+ "{\"at\":\"2026-10-20T09:01:00Z\",\"by\":\"pm\",\"kind\":\"link\",\"board\":\"b\",\"ini\":\"promo\",\"from\":\"a2\",\"to\":\"b1\",\"type\":\"fs\",\"lag\":0,\"source\":\"page\"}",
133
+ "{\"at\":\"2026-10-20T09:02:00Z\",\"by\":\"pm\",\"kind\":\"link\",\"board\":\"b\",\"ini\":\"promo\",\"from\":\"b1\",\"to\":\"b2\",\"type\":\"fs\",\"lag\":0,\"source\":\"page\"}",
134
+ "{\"at\":\"2026-10-20T09:03:00Z\",\"by\":\"pm\",\"kind\":\"link\",\"board\":\"b\",\"ini\":\"promo\",\"from\":\"a2\",\"to\":\"J-1\",\"type\":\"fs\",\"lag\":0,\"source\":\"chat\"}"
135
+ ],
136
+ "rehearse-krs.jsonl": [
137
+ "{\"rid\":\"conv\",\"metric\":\"Checkout conversion\",\"unit\":\"%\",\"baseline\":2.1,\"target\":3,\"by\":\"2026-11-29\"}"
138
+ ],
139
+ "rehearse-programs.jsonl": [
140
+ "{\"at\":\"2026-10-01T00:00:00Z\",\"kind\":\"program\",\"id\":\"q4\",\"title\":\"Q4 holiday\",\"milestones\":[{\"id\":\"m\",\"name\":\"Holiday ready\",\"date\":\"2026-11-30\",\"waits\":[\"promo\"]}]}"
141
+ ],
142
+ "rehearse-checkout.txt": [
143
+ "---",
144
+ "type: Initiative",
145
+ "title: \"Checkout\"",
146
+ "id: checkout",
147
+ "parent_kr: conv",
148
+ "---"
149
+ ],
150
+ "rehearse-promo.txt": [
151
+ "---",
152
+ "type: Initiative",
153
+ "title: \"Promo\"",
154
+ "id: promo",
155
+ "parent_kr: conv",
156
+ "---"
157
+ ]
158
+ }
159
+ }
@@ -80,6 +80,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
80
80
  - [Breakdown › Timeline](timeline.md): an initiative's items on a time axis, split by stage, with milestones, today, and the at-risk flag when an item starts before its predecessor ends.
81
81
  - [Initiatives › Timeline](initiatives-timeline.md): when each initiative runs, one row per initiative, and how to reorder rows by their grip.
82
82
  - [Workflows](workflows.md): Settings › Workflows, the stages each kind of initiative goes through, New workflow in the chat, and `pmos workflow`.
83
+ - [Rehearse a plan](rehearse.md): before you approve a date change or a cut, Claude shows what it breaks or helps in other initiatives, goals and program milestones, with the record each effect comes from, and `pmos rehearse`.
83
84
  - [Forecasts](forecast.md): how a goal, a delivery, a program and a health budget are forecast, the states, the kept baseline, and `pmos forecast`.
84
85
  - [Programs on the Board](programs.md): All products, programs first then products, By product, the program panel and its milestones, and the 60 % side panels.
85
86
  - [Board](board.md): one Kanban for the items of every initiative, its filters, Release, and `pmos board`.
@@ -0,0 +1,52 @@
1
+ ---
2
+ type: Guide
3
+ title: "Rehearse a plan: what a change breaks or helps, before you approve it"
4
+ description: "How Claude rehearses a date change or a cut before you approve it, what each effect means, where its source comes from, the guardrails, and the pmos rehearse commands."
5
+ timestamp: 2026-11-03
6
+ ---
7
+
8
+ # Rehearse a plan
9
+
10
+ When you ask Claude in your session to move a task, change an initiative's end date, or cut a task or a whole initiative, Claude first rehearses the change. It shows you what the change breaks and what it helps, and it waits for your yes. The rehearsal changes nothing. There is no button: you approve in your own words, and only then does Claude make the change.
11
+
12
+ ## What a rehearsal shows
13
+
14
+ Each line is one effect, with the record it comes from under it.
15
+
16
+ - **✗ Breaks** — something gets worse because of the change:
17
+ - a task in another initiative moves later, because it waits on the changed task through a task link;
18
+ - a ◇ milestone moves;
19
+ - a Jira ticket would have to start later. PMOS cannot move a Jira ticket, so its assignee would have to;
20
+ - a task would start before a task it waits on allows (a task moved earlier than its link allows);
21
+ - an initiative's last task now ends after the initiative's end date;
22
+ - a goal's date falls before the new end of an initiative that moves the goal, or the goal loses an initiative (a cut). The line adds the goal's forecast as it is today ([Forecasts](forecast.md));
23
+ - a program milestone that waits on the initiative comes after its date, or waits on an initiative you would cut;
24
+ - a task that waited on a cut task loses what it waited on.
25
+ - **✓ Helps** — something gets better: an initiative or a goal is back in time, a program milestone clears, or a task held by a task you move earlier could start earlier.
26
+ - **· Moves** — the tasks of the same initiative that move with the change, in one line.
27
+
28
+ When nothing else changes, the rehearsal says so in one line.
29
+
30
+ A moved task's line gives the date it moves from. When late work before it already pushes it past the Board's date, the line gives both. A task that is already late gets a note with where its due day goes. When a task link names a task no board has any more (it was removed or renamed), the rehearsal leaves that link out and says how many it left out.
31
+
32
+ ## How it decides
33
+
34
+ The effects are the difference between the plan as it is and the plan with the change. Both are first settled through your approved [task links](workflows.md), so a link that is already broken today is not blamed on the change. Only what a record and the date arithmetic prove is said. A goal effect gives the date fact and today's forecast state; it never estimates a new number. An initiative's work lands when its last open task ends. Its end date counts only when no open task has a date, so moving an end date later does not flag a goal or a program milestone that the work still meets. An effect for which PMOS finds no record line to cite is not shown, and the rehearsal says how many it left out.
35
+
36
+ ## The guardrails
37
+
38
+ - **Propose-only.** The rehearsal writes nothing except one audit line.
39
+ - **Every effect cites its source**: the line of the task link, the initiative record, the goal's KR metric record or the program charter. Before an effect is shown, PMOS checks that the quoted text is really at that line. An effect whose source cannot be found is not shown, and the rehearsal says how many were left out.
40
+ - **Replay-tested.** A new version of the rehearsal shows nothing until its recorded cases pass (one of them checks that a change nothing waits on says nothing).
41
+ - **Kill switch.** `pmos ai off rehearse` turns rehearsals off, and `pmos ai on rehearse` turns them on again. `PMOS_AI_REHEARSE=off` or `PMOS_AI=off` in the environment also turns them off.
42
+ - **Audit.** Each rehearsal adds lines to the AI audit log: `pmos rehearse log`, or `pmos ai log rehearse` for every line.
43
+
44
+ ## The commands
45
+
46
+ - `pmos rehearse move <task> --by <days>` — the task moves later by whole days, or earlier with a minus (`--by -2`).
47
+ - `pmos rehearse move <task> --start YYYY-MM-DD [--due YYYY-MM-DD]`, or `--due YYYY-MM-DD` alone — the task gets new dates. A new start alone keeps the task's length.
48
+ - `pmos rehearse end <initiative> --to YYYY-MM-DD` — the initiative's end date changes.
49
+ - `pmos rehearse cut <task|initiative>` — what if we cut this. An initiative id cuts the whole initiative; a task id cuts one task.
50
+ - `pmos rehearse log [--n N]` — the last rehearsals.
51
+
52
+ Every command takes `--json` and `--root <repository>`. Exit codes: 0 done, 1 off or refused, 2 usage.
@@ -199,14 +199,15 @@ const versionOf = (L) => VERSION + '-' + hash(JSON.stringify(pairsOf(L)));
199
199
  * `pmos grill find decision`: check each finding, then hand the good ones to lib/ai-guard.js, which holds them until the replay of this
200
200
  * version passed (the replay runs here when it has not), checks the evidence again, audits, and calls deliver. deliver appends the grill
201
201
  * proposal to the initiative's ✦ ledger, as `pmos grill find` does for the other angles. → exit code: 0 filed or nothing to file, 1 refused.
202
- * o: { root, file (proposals.jsonl), run, ini, board, env, now, say, P (the proposals fold), settingsDir (Settings, for the kill switch) }
202
+ * o: { root, file (proposals.jsonl), run, ini, board, env, now, say, P (the proposals fold), settingsDir (the per-user state folder: Settings for the kill switch, and the guard's audit and replay record) }
203
203
  */
204
204
  async function fileRun(o, bodies) {
205
205
  const say = o.say || (() => {}), env = o.env || process.env;
206
206
  const PR = require('./proposals.js'), AG = require('./ai-guard.js');
207
207
  const off = offWhy(env); if (off) { say('✗ ' + off); return 1; }
208
208
  const L = DL.load(o.root);
209
- const stateDir = path.dirname(o.file), version = versionOf(L);
209
+ // the guard's audit and replay record live in the per-user state folder, never in the repository's state (Sync would commit them)
210
+ const stateDir = o.settingsDir || require('./state-root.js').defaultRoot({ env }), version = versionOf(L);
210
211
  const props = []; let bad = 0;
211
212
  for (const b of bodies) {
212
213
  const c = checkFinding(L, b, { root: o.root, ini: o.ini });
@@ -84,6 +84,9 @@ const VERBS = Object.freeze({
84
84
  // Not a pm-os-absorb verb (run: null): the AI-ideas round (2026-11-03) added it through the same table: the kill switch and audit of lib/ai-guard.js.
85
85
  ai: Object.freeze({ module: 'ai-cli.js', run: null, guide: 'claude-proposes.md',
86
86
  help: 'pmos ai status|off|on|log … — the AI jobs: their kill switch, replay state and audit (pmos ai help)' }),
87
+ // Not a pm-os-absorb verb (run: null): the AI-ideas round (2026-11-03, feature 5, plan-rehearsal) added it through the same table.
88
+ rehearse: Object.freeze({ module: 'rehearse-cli.js', run: null, guide: 'rehearse.md',
89
+ help: 'pmos rehearse move|end|cut|log … — what a change would break or help in other initiatives, goals and dates, before the PM approves it (pmos rehearse help)' }),
87
90
  // Not a pm-os-absorb verb (run: null): Grill and Forecast part G-A (Claude grills an initiative by itself) added it through the same table.
88
91
  grill: Object.freeze({ module: 'grill-cli.js', run: null, guide: 'claude-proposes.md',
89
92
  help: 'pmos grill find|voices|status|mode|cap|spend … — what Claude\'s grill run files under an initiative\'s ✦, and when it grills and what it may spend (pmos grill help)' }),
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+ /* rehearse-cli.js — `pmos rehearse`: what a change would break or help, before the PM approves it (initiative plan-rehearsal).
3
+ *
4
+ * pmos rehearse move <task> --by <days> | --start YYYY-MM-DD [--due YYYY-MM-DD] | --due YYYY-MM-DD a task's dates change
5
+ * pmos rehearse end <initiative> --to YYYY-MM-DD an initiative's end changes
6
+ * pmos rehearse cut <task|initiative> what if we cut this
7
+ * pmos rehearse log [--n N] the last rehearsals (the audit)
8
+ * every command takes --json and --root <repository>
9
+ *
10
+ * It reads through lib/rehearse-read.js, runs lib/rehearse.js and shows only the effects lib/ai-guard.js lets through (lib/rehearse-guard.js).
11
+ * It changes nothing: the one write is the audit line in <runtime>/ai-audit.jsonl. The PM approves the change in their own words; only
12
+ * then does the session make it, with `pmos task set`, `pmos task state` or the PM's own act. Exit codes: 0 done, 1 off or refused,
13
+ * 2 usage. Nothing is sent. */
14
+ const path = require('path');
15
+ const { execFileSync } = require('child_process');
16
+ const RH = require('./rehearse.js');
17
+
18
+ const COMMANDS = { rehearse: ['move', 'end', 'cut', 'log'] };
19
+ const USAGE = [
20
+ 'usage: pmos rehearse move|end|cut|log — what a change would break or help, before the PM approves it. Nothing changes.',
21
+ ' pmos rehearse move <task> --by <days> the task moves later (or earlier, with a minus) by whole days',
22
+ ' pmos rehearse move <task> --start YYYY-MM-DD [--due YYYY-MM-DD] | --due YYYY-MM-DD',
23
+ ' pmos rehearse end <initiative> --to YYYY-MM-DD the initiative\'s end date changes',
24
+ ' pmos rehearse cut <task|initiative> what if we cut this',
25
+ ' pmos rehearse log [--n N] the last rehearsals',
26
+ 'Each effect names the record it comes from. Show it to the PM, and make the change only after the PM says yes.',
27
+ ];
28
+ const VALUE = new Set(['by', 'start', 'due', 'to', 'root', 'today', 'n']);
29
+ const BOOL = new Set(['json']);
30
+
31
+ function parse(args) {
32
+ const pos = [], flags = {};
33
+ for (let i = 0; i < args.length; i++) {
34
+ const m = /^--([a-z-]+)(?:=(.*))?$/.exec(args[i]);
35
+ if (!m) { pos.push(args[i]); continue; }
36
+ if (m[2] !== undefined) flags[m[1]] = m[2];
37
+ else if (VALUE.has(m[1]) && args[i + 1] !== undefined && !/^--[a-z]/.test(args[i + 1])) flags[m[1]] = args[++i];
38
+ else flags[m[1]] = true;
39
+ }
40
+ return { pos, flags };
41
+ }
42
+ function repoRoot(cwd) {
43
+ try { return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd; }
44
+ catch { return cwd; }
45
+ }
46
+ const runtimeOf = (env) => require('./state-root.js').defaultRoot({ env });
47
+
48
+ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), env = process.env, stateDir = null, snapshot = null } = {}) {
49
+ const args = argv[0] === 'rehearse' ? argv.slice(1) : argv.slice();
50
+ const verb = args[0];
51
+ if (!verb || ['help', '--help', '-h'].includes(verb)) { for (const l of USAGE) say(l); return verb ? 0 : 1; }
52
+ if (!COMMANDS.rehearse.includes(verb)) { say(`unknown rehearse command "${verb}" — one of ${COMMANDS.rehearse.join(', ')}`); return 2; }
53
+ const { pos, flags } = parse(args.slice(1));
54
+ for (const k of Object.keys(flags)) if (!VALUE.has(k) && !BOOL.has(k)) { say(`unknown option --${k}`); return 2; }
55
+ for (const k of Object.keys(flags)) if (VALUE.has(k) && flags[k] === true) { say(`--${k} needs a value`); return 2; }
56
+ const json = !!flags.json, runtime = stateDir || runtimeOf(env);
57
+ const G = require('./rehearse-guard.js'), AG = require('./ai-guard.js');
58
+
59
+ if (verb === 'log') {
60
+ const n = Math.min(200, Math.max(1, Number(flags.n) || 10));
61
+ const lines = AG.readAudit(runtime, { job: G.JOB, limit: 2000 }).filter((l) => l.event === 'start' || l.event === 'end' || l.event === 'off' || l.event === 'held').slice(-n * 2);
62
+ if (json) { say(JSON.stringify({ ok: true, lines })); return 0; }
63
+ if (!lines.length) { say('No rehearsal yet.'); return 0; }
64
+ for (const l of lines) say(`${String(l.at).slice(0, 16).replace('T', ' ')} ${l.event}${l.change ? ' ' + l.change.kind + ' ' + (l.change.task || l.change.ini || '') : ''}${l.filed != null ? ' · ' + l.filed + ' shown, ' + l.dropped + ' dropped' : ''}${l.why ? ' · ' + l.why : ''}`);
65
+ return 0;
66
+ }
67
+
68
+ const usage = { move: 'usage: pmos rehearse move <task> --by <days> | --start YYYY-MM-DD [--due YYYY-MM-DD] | --due YYYY-MM-DD', end: 'usage: pmos rehearse end <initiative> --to YYYY-MM-DD', cut: 'usage: pmos rehearse cut <task|initiative>' }[verb];
69
+ if (pos.length !== 1) { say(usage); return 2; }
70
+ const allowed = { move: ['by', 'start', 'due'], end: ['to'], cut: [] }[verb];
71
+ for (const k of ['by', 'start', 'due', 'to']) if (flags[k] !== undefined && !allowed.includes(k)) { say(`--${k} is not an option of ${verb}`); return 2; }
72
+ const fail = (error, code = 1) => { if (json) say(JSON.stringify({ ok: false, error })); else say('✗ ' + error); return code; };
73
+
74
+ // the kill switch first: an off rehearsal reads nothing
75
+ const off = AG.off(G.JOB, { env, settingsDir: runtime });
76
+ if (off) { AG.audit(runtime, { job: G.JOB, version: G.VERSION, event: 'off', why: off }); return fail('rehearsals are off: ' + off + '. `pmos ai on rehearse` turns them on.'); }
77
+
78
+ const root = flags.root ? path.resolve(cwd, flags.root) : repoRoot(cwd);
79
+ const snap = snapshot || require('./rehearse-read.js').snapshotOf(root, { today: typeof flags.today === 'string' ? flags.today : undefined });
80
+ const id = pos[0];
81
+ let change;
82
+ if (verb === 'move') {
83
+ if (flags.by !== undefined && !/^-?\d+$/.test(String(flags.by))) return fail('--by takes whole days, e.g. --by 3 or --by -2', 2);
84
+ change = { kind: 'move', task: id, ...(flags.by !== undefined ? { days: Number(flags.by) } : {}), ...(flags.start ? { start: flags.start } : {}), ...(flags.due ? { due: flags.due } : {}) };
85
+ } else if (verb === 'end') change = { kind: 'end', ini: id, to: flags.to };
86
+ else change = (snap.rows || []).some((r) => r.id === id) ? { kind: 'cut', ini: id } : { kind: 'cut', task: id };
87
+
88
+ const r = RH.rehearse(snap, change);
89
+ if (!r.ok) return fail(r.error, /^(say how|--)/.test(r.error) ? 2 : 1);
90
+ const g = await G.guarded(r, { root, stateDir: runtime, env });
91
+ if (g.off) return fail('rehearsals are off: ' + g.off);
92
+ if (g.held) return fail('the rehearsal replay did not pass, so nothing is shown: ' + (g.failed || []).map((f) => f.name + ' (' + f.why + ')').join('; ') + '. `pmos ai status` shows it.');
93
+ const shown = { ...r, effects: g.effects, counts: { breaks: 0, helps: 0, moves: 0 } };
94
+ for (const e of shown.effects) shown.counts[e.tone]++;
95
+ if (json) { say(JSON.stringify({ ok: true, change, effects: shown.effects, counts: shown.counts, dropped: g.dropped.length, missing: r.missing, changed: false })); return 0; }
96
+ for (const l of RH.textOf(shown).slice(0, -1)) say(l);
97
+ if (g.dropped.length) say(`${g.dropped.length === 1 ? '1 effect is' : g.dropped.length + ' effects are'} not shown: PMOS could not find its source at the line it cites.`);
98
+ say(RH.textOf(shown).slice(-1)[0]);
99
+ return 0;
100
+ }
101
+
102
+ module.exports = { run, COMMANDS, USAGE };
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+ /* rehearse-guard.js — a rehearsal's effects through the shared door, lib/ai-guard.js (initiative ai-job-guard): the kill switch, the
3
+ * replay gate, the citation check and the audit. Nothing here duplicates the guard; this file only says what a rehearsal files and
4
+ * which recorded cases it must pass.
5
+ *
6
+ * JOB · VERSION 'rehearse', and the version of the engine's output (bump it when lib/rehearse.js changes)
7
+ * proposalsOf(result) → [proposal] one ai-guard proposal per effect: its text, its sources, its tone in data
8
+ * cases(fixture?) → [replay case] the recorded snapshot fixtures/rehearse-replay.json, replayed through the engine
9
+ * fixture(file?) → { root, snap } that snapshot, its cited files written to a temporary folder (root), its sources computed from them
10
+ * guarded(result, o) → Promise<{ off?, held?, effects, dropped }>
11
+ * o: { root, stateDir (the runtime folder), env, run }. Runs the replay first when this version has not passed it, then files
12
+ * the effects. An effect whose source the guard cannot find at its line is dropped and never shown (proven only).
13
+ *
14
+ * The audit lines go to <runtime>/ai-audit.jsonl with the job `rehearse`; `pmos ai log rehearse` reads them, and `pmos ai off rehearse`
15
+ * (or PMOS_AI_REHEARSE=off, or PMOS_AI=off) turns rehearsals off. */
16
+ const fs = require('fs');
17
+ const os = require('os');
18
+ const path = require('path');
19
+ const crypto = require('crypto');
20
+ const AG = require('./ai-guard.js');
21
+ const RH = require('./rehearse.js');
22
+ const RR = require('./rehearse-read.js');
23
+
24
+ const JOB = 'rehearse';
25
+ const VERSION = '2';
26
+ const MAX = 40;
27
+ const FIXTURE = path.join(__dirname, '..', 'fixtures', 'rehearse-replay.json');
28
+ const TITLE = { breaks: 'Breaks', helps: 'Helps', moves: 'Moves with it' };
29
+
30
+ const idOf = (e) => 'rh:' + e.kind + ':' + crypto.createHash('sha1').update(e.text).digest('hex').slice(0, 12);
31
+ function proposalsOf(r) {
32
+ if (!r || !r.ok) return [];
33
+ return r.effects.map((e) => ({
34
+ id: idOf(e), title: TITLE[e.tone] + ': ' + e.kind, say: e.text,
35
+ confidence: new Set(e.evidence.map((s) => s.ref)).size >= 2 ? 'high' : 'medium',
36
+ claims: [{ text: e.text, sources: e.evidence.map((s) => ({ label: s.label, ref: s.ref, quote: s.quote || '' })) }],
37
+ data: { tone: e.tone, kind: e.kind, ini: e.ini || null, task: e.task || null, goal: e.goal || null, program: e.program || null },
38
+ }));
39
+ }
40
+
41
+ function fixture(file = FIXTURE) {
42
+ const snap = JSON.parse(fs.readFileSync(file, 'utf8'));
43
+ // the files its sources cite, written where the guard can read them at their lines
44
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pmos-rehearse-replay-'));
45
+ for (const [name, lines] of Object.entries(snap.contents || {})) fs.writeFileSync(path.join(root, path.basename(name)), lines.join('\n') + '\n');
46
+ const f = snap.files || {}, abs = (x) => path.join(root, x);
47
+ delete snap.contents;
48
+ snap.refs = RR.refsOf(root, snap, { links: abs(f.links), krs: abs(f.krs), programs: abs(f.programs), records: Object.fromEntries(Object.entries(f.records || {}).map(([k, v]) => [k, abs(v)])) });
49
+ return { root, snap };
50
+ }
51
+ /** The recorded cases: the engine on a fixed snapshot, never a model. */
52
+ function cases(F = fixture()) {
53
+ const { snap } = F;
54
+ const run = (change, edit) => async () => proposalsOf(RH.rehearse(edit ? edit(JSON.parse(JSON.stringify(snap))) : snap, change));
55
+ return [
56
+ { name: 'a change nothing waits on says nothing', run: run({ kind: 'move', task: 'b2', days: 1 }), expect: { none: true } },
57
+ { name: 'a later task moves another initiative, a milestone and a Jira ticket, with the link lines', run: run({ kind: 'move', task: 'a1', days: 5 }), expect: { min: 5, cites: ['rehearse-links.jsonl', 'rehearse-programs.jsonl'] } },
58
+ { name: 'cutting an initiative names the goal it moves and what waited on it', run: run({ kind: 'cut', ini: 'checkout' }), expect: { min: 3, cites: ['rehearse-krs.jsonl', 'rehearse-checkout.txt', 'rehearse-links.jsonl'] } },
59
+ { name: 'a link broken before the change is not blamed on it', run: run({ kind: 'end', ini: 'checkout', to: '2026-11-25' }, (s) => { s.tasks[2].start = '2026-11-18'; return s; }), expect: { none: true } },
60
+ ];
61
+ }
62
+
63
+ async function guarded(result, o = {}) {
64
+ const env = o.env || process.env, stateDir = o.stateDir, run = o.run || 'rh-' + crypto.randomBytes(4).toString('hex');
65
+ const off = AG.off(JOB, { env, settingsDir: stateDir });
66
+ if (off) { AG.audit(stateDir, { job: JOB, version: VERSION, run, event: 'off', why: off }); return { off, effects: [], dropped: [] }; }
67
+ if (!AG.ready(stateDir, JOB, VERSION)) {
68
+ const F = fixture(), rp = await AG.replay(JOB, VERSION, cases(F), { root: F.root, stateDir, max: MAX });
69
+ try { fs.rmSync(F.root, { recursive: true, force: true }); } catch { /* a temporary folder */ }
70
+ if (!rp.ok) return { held: true, failed: rp.failed, effects: [], dropped: [] };
71
+ }
72
+ AG.audit(stateDir, { job: JOB, version: VERSION, run, event: 'start', change: result.change, effects: result.effects.length });
73
+ const P = proposalsOf(result), byId = new Map(P.map((p, i) => [p.id, result.effects[i]]));
74
+ let shown = [];
75
+ const got = await AG.file({ job: JOB, version: VERSION, run, root: o.root, stateDir, settingsDir: stateDir, env, max: MAX }, P, async (filed) => { shown = filed.map((p) => byId.get(p.id)).filter(Boolean); });
76
+ if (got.off) return { off: got.off, effects: [], dropped: [] };
77
+ if (got.held) return { held: true, effects: [], dropped: [] };
78
+ return { effects: shown, dropped: got.dropped || [] };
79
+ }
80
+
81
+ module.exports = { JOB, VERSION, MAX, FIXTURE, proposalsOf, fixture, cases, guarded };
@@ -0,0 +1,159 @@
1
+ 'use strict';
2
+ /* rehearse-read.js — the snapshot a rehearsal reads (initiative plan-rehearsal; lib/rehearse.js is the pure engine). READ ONLY: it writes
3
+ * nothing, and the server need not run. Every value comes through the readers the pmos verbs use, never from a ledger line by hand:
4
+ *
5
+ * tasks lib/routes-task-links.js frame (the Board's items of every board, Jira tickets and breakdown items too)
6
+ * links lib/task-links.js read (the approved links, folded)
7
+ * rows lib/routes-boards.js baseRows (each initiative's end, the Board's) and lib/initiative-rows.js (its anchor and record)
8
+ * goals the KR metric records (scripts/records) → lib/routes-c3-strategy.js outcomeOf → lib/insights-premium.js goalsOf, each with
9
+ * lib/forecast.js goalForecast and the PM's kept baseline. The server's Insights route also files fixes and records turns;
10
+ * this reads only, so it builds the goals itself
11
+ * programs lib/programs.js fold of this workspace's <state>/programs.jsonl
12
+ *
13
+ * THE SOURCES. A ledger is read twice: once folded (the values), once as lines, only to find the line number and a verbatim quote that
14
+ * an effect cites (`<path>:<line>`, relative to the repository root, as lib/ai-guard.js checks it). The value is never read from that line.
15
+ *
16
+ * snapshotOf(root, { today?, stateDir? }) → snapshot (lib/rehearse.js)
17
+ * refsOf(root, snap, files) → snap.refs · lineRef(root, file, test, label) → src | null
18
+ */
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const D = require('./driver.js');
22
+ const TL = require('./task-links.js');
23
+ const FC = require('./forecast.js');
24
+ const { repoScript } = require('./repo-script.js');
25
+
26
+ const rel = (root, f) => path.relative(root, f).split(path.sep).join('/');
27
+ const QUOTE = 100, QUOTE_MIN = 12;
28
+
29
+ /** The last line of `file` that passes `test`, as a source: { label, ref: '<path>:<line>', quote }. */
30
+ function lineRef(root, file, test, label) {
31
+ let text = '';
32
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return null; }
33
+ const lines = text.split('\n');
34
+ for (let i = lines.length - 1; i >= 0; i--) {
35
+ const m = test(lines[i]);
36
+ if (!m) continue;
37
+ let quote = (typeof m === 'string' ? m : lines[i]).trim().slice(0, QUOTE);
38
+ // a short line ("id: promo") is quoted with the line above it, as lib/ai-guard.js reads a quote across nearby lines
39
+ if (quote.length < QUOTE_MIN && i > 0) quote = (lines[i - 1].trim() + ' ' + quote).slice(0, QUOTE);
40
+ return { label, ref: rel(root, file) + ':' + (i + 1), quote };
41
+ }
42
+ return null;
43
+ }
44
+ const jsonHas = (k, v) => '"' + k + '":' + JSON.stringify(v);
45
+
46
+ function ctxOf(root, stateDir) {
47
+ const w = { id: D.wsIdOf(root), name: path.basename(root), root };
48
+ return { w, ctx: { getWs: () => w, workspaceList: () => [w], stateDirFor: () => stateDir, selfRoot: path.resolve(__dirname, '..', '..') } };
49
+ }
50
+
51
+ function snapshotOf(root, o = {}) {
52
+ const today = o.today || new Date().toISOString().slice(0, 10);
53
+ const stateDir = o.stateDir || D.stateDirOf(root);
54
+ const missing = [], snap = { today, tasks: [], links: [], rows: [], goals: [], programs: [], refs: { links: {}, rows: {}, goals: {}, programs: {}, tasks: {} }, missing };
55
+ if (!stateDir) { missing.push('the PMOS state folder'); return snap; }
56
+ const RB = require('./routes-boards.js'), TLR = require('./routes-task-links.js'), rowsLib = require('./initiative-rows.js');
57
+ const { w, ctx } = ctxOf(root, stateDir);
58
+ const files = { records: {} };
59
+
60
+ // the initiatives: the Board's end, the record's anchor
61
+ let base = RB.baseRows(w);
62
+ if (base.pending) { try { rowsLib.deriveNow(root); } catch { /* answered below */ } base = RB.baseRows(w); }
63
+ const recOf = new Map();
64
+ try { const got = rowsLib.rowsFor(root); if (got && got.ok) for (const r of got.rows) recOf.set(r.id, r.record || null); } catch { /* no record paths */ }
65
+ if (base.ok) {
66
+ for (const r of base.rows) {
67
+ snap.rows.push({ id: r.id, title: r.title || r.id, end: r.end || null, anchor: r.anchor || null });
68
+ if (recOf.get(r.id)) files.records[r.id] = path.resolve(root, recOf.get(r.id));
69
+ }
70
+ } else missing.push('initiatives');
71
+
72
+ // the tasks of every board, and the approved links
73
+ try {
74
+ const fr = RB.frameFor(ctx, w), seen = new Set();
75
+ for (const b of fr.boards || []) {
76
+ const f = TLR.frame(ctx, w, b, { derive: true });
77
+ if (!f) { missing.push('board ' + b.id); continue; }
78
+ for (const [uid, x] of f.byUid) { if (seen.has(uid)) continue; seen.add(uid); snap.tasks.push(x); }
79
+ }
80
+ } catch { missing.push('tasks'); }
81
+ files.links = TL.ledgerFile(stateDir);
82
+ files.board = safe(() => RB.ledgerFile(ctx, w));
83
+ files.items = safe(() => require('./plan-ledgers.js').ledgerPath(root, 'plan-items'));
84
+ snap.links = TL.linksOf(TL.read(stateDir));
85
+
86
+ // the goals, each with today's forecast
87
+ try {
88
+ const R = require(repoScript('records/index.js')), dir = require('./records-dir.js').recordsDirFor(ctx, w.id);
89
+ const krs = R.list(dir, 'kr-metrics').filter((k) => k.retired !== true);
90
+ const strategy = require('./routes-c3-strategy.js'), Insights = require('./insights-premium.js');
91
+ const outcomes = krs.map((k) => strategy.outcomeOf(k, today)), meta = Object.fromEntries(krs.map((k) => [k.rid, k]));
92
+ const goals = Insights.goalsOf(outcomes, [], base.ok ? base.rows : [], Date.parse(today + 'T00:00:00Z'), meta, null);
93
+ const L = FC.read(stateDir);
94
+ files.krs = R.fileOf(dir, 'kr-metrics');
95
+ for (const g of goals) {
96
+ const forecast = FC.goalForecast(g, { today, baseline: L.baselines.get(g.id) || null });
97
+ snap.goals.push({ id: g.id, title: g.title, byAt: g.byAt, moves: (g.moves || []).map((m) => m.id), forecast: forecast ? { state: forecast.state, likely: forecast.likely } : null });
98
+ }
99
+ } catch { missing.push('goals'); }
100
+
101
+ // the programs whose milestones wait on these initiatives
102
+ try {
103
+ const P = require('./programs.js');
104
+ files.programs = path.join(stateDir, 'programs.jsonl');
105
+ for (const p of P.merge([{ ws: w.id, lines: readJsonl(files.programs) }])) {
106
+ if (p.archived) continue;
107
+ snap.programs.push({ id: p.id, title: p.title || p.id, milestones: (p.milestones || []).map((m) => ({ id: m.id, name: m.name, date: m.date || null, waits: m.waits || [], done: !!m.done, risk: m.risk === true })) });
108
+ }
109
+ } catch { missing.push('programs'); }
110
+ snap.refs = refsOf(root, snap, files);
111
+ return snap;
112
+ }
113
+
114
+ /** The sources an effect cites: the line of each link, initiative record, goal record, program charter and ◇ milestone flag.
115
+ * files: { links, board?, krs?, programs?, records: { [initiative]: file } } (absolute paths). */
116
+ function refsOf(root, snap, files) {
117
+ const R = { links: {}, rows: {}, goals: {}, programs: {}, tasks: {} }, F = files || {};
118
+ const after = (k) => (line) => (line.includes(k) ? line.slice(line.indexOf(k)) : null);
119
+ for (const l of snap.links || []) {
120
+ const k = jsonHas('from', l.from) + ',' + jsonHas('to', l.to);
121
+ const s = F.links && lineRef(root, F.links, (line) => (line.includes('"kind":"link"') ? after(k)(line) : null), 'link ' + l.from + ' → ' + l.to);
122
+ if (s) R.links[TL.keyOf(l.from, l.to)] = s;
123
+ }
124
+ for (const [ini, file] of Object.entries(F.records || {})) {
125
+ const a = lineRef(root, file, (l) => /^(parent_kr|anchor):/.test(l), ini + ' anchor');
126
+ if (a) R.rows[ini + ':anchor'] = a;
127
+ const idl = lineRef(root, file, (l) => /^id:/.test(l), ini + ' record (its end is the Board\'s, from its tasks)');
128
+ if (idl) R.rows[ini + ':end'] = idl;
129
+ }
130
+ for (const g of snap.goals || []) { const s = F.krs && lineRef(root, F.krs, after(jsonHas('rid', g.id)), 'goal ' + g.id); if (s) R.goals[g.id] = s; }
131
+ for (const p of snap.programs || []) { const s = F.programs && lineRef(root, F.programs, (line) => (line.includes('"kind":"program"') ? after(jsonHas('id', p.id))(line) : null), 'program ' + p.id); if (s) R.programs[p.id] = s; }
132
+ // each task's date: the last line of the Board or breakdown ledger that names the task and its due day (a ◇ flag line for a milestone
133
+ // with no date line). A ledger outside the repository (a worktree's main tree) is not cited: the guard reads only inside the root.
134
+ const inside = (f) => !!f && !path.relative(root, f).startsWith('..') && !path.isAbsolute(path.relative(root, f));
135
+ const ledgers = [F.board, F.items].filter(inside).map((f) => ({ f, lines: safe(() => fs.readFileSync(f, 'utf8').split('\n')) || [] }));
136
+ for (const t of snap.tasks || []) {
137
+ const uid = t.uid || t.key, due = String(t.end || t.due || '').slice(0, 10), id = JSON.stringify(uid);
138
+ if (!uid) continue;
139
+ for (const { f, lines } of ledgers) {
140
+ let i = lines.length - 1;
141
+ for (; i >= 0; i--) if (lines[i].includes(id) && (due ? lines[i].includes(due) : /milestone/.test(lines[i]))) break;
142
+ if (i < 0) continue;
143
+ const quote = lines[i].trim().slice(0, QUOTE);
144
+ R.tasks[uid] = { label: uid + (due ? ' due ' + due : ' milestone'), ref: rel(root, f) + ':' + (i + 1), quote };
145
+ break;
146
+ }
147
+ }
148
+ return R;
149
+ }
150
+ function safe(fn) { try { return fn(); } catch { return null; } }
151
+ function readJsonl(file) {
152
+ let text = '';
153
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
154
+ const out = [];
155
+ for (const raw of text.split('\n')) { if (!raw.trim()) continue; try { out.push(JSON.parse(raw)); } catch { /* a torn line is skipped */ } }
156
+ return out;
157
+ }
158
+
159
+ module.exports = { snapshotOf, refsOf, lineRef };
@@ -0,0 +1,279 @@
1
+ 'use strict';
2
+ /* rehearse.js — rehearse a plan (initiative plan-rehearsal, PRD planning/prd/plan-rehearsal-prd.md). PURE: no file, no clock, no network.
3
+ * lib/rehearse-read.js reads the snapshot through the pmos readers; lib/rehearse-cli.js is `pmos rehearse`; lib/rehearse-guard.js is the
4
+ * kill switch, the evidence check and the audit line.
5
+ *
6
+ * Before the PM approves a change, Claude runs it here and shows what it breaks and what it helps: tasks in other initiatives, ◇ milestones,
7
+ * Jira tickets, committed ends, goals and program milestones. Nothing is changed. The PM approves in their own words.
8
+ *
9
+ * rehearse(snap, change) → { ok, change, effects: [effect], counts: { breaks, helps, moves }, checked, missing } | { ok: false, error }
10
+ * CHANGES: { kind: 'move', task, days? , start?, due? } · { kind: 'end', ini, to } · { kind: 'cut', task } · { kind: 'cut', ini }
11
+ * effect: { tone: 'breaks' | 'helps' | 'moves', kind, ini, text, evidence: [{ label, ref, quote? }] }
12
+ *
13
+ * SNAPSHOT. { today: 'YYYY-MM-DD',
14
+ * tasks: [{ uid, title, ini, status, start, end, milestone, src }] the shape lib/task-links.js taskOf reads
15
+ * links: [{ from, to, type, lag }] the approved links
16
+ * rows: [{ id, title, end, anchor }] the initiatives (end = the committed end)
17
+ * goals: [{ id, title, byAt, moves: [initiative id], forecast: { state, likely } | null }] lib/forecast.js goalForecast
18
+ * programs: [{ id, title, milestones: [{ id, name, date, waits: [initiative id], done }] }]
19
+ * refs: { links: { 'from>to': src }, rows: { 'ini:end' | 'ini:anchor': src }, goals: { id: src }, programs: { id: src }, tasks: { uid: src } }
20
+ * missing: ['goals', …] what the reader could not read, said as it is }
21
+ *
22
+ * THE RULES.
23
+ * - The effects are the DIFFERENCE between the plan as it is and the plan with the change. Both are first settled through the links
24
+ * (lib/task-links.js settle, with every task allowed to move so the whole chain shows), so a link already broken today is not blamed
25
+ * on the change.
26
+ * - PROVEN ONLY. An effect is made from a link, a date and a record only. A goal effect says the date fact and the goal's forecast state
27
+ * as it is today; it never estimates a new number. An effect with no source is dropped.
28
+ * - QUIET. The tasks of the changed initiative that move with it are one `moves` line, not one line each. No effect: `effects` is empty.
29
+ */
30
+ const TL = require('./task-links.js');
31
+ const FC = require('./forecast.js');
32
+
33
+ const KINDS = Object.freeze(['move', 'end', 'cut']);
34
+ const MAX_LIST = 8;
35
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
36
+ const str = (v) => (typeof v === 'string' ? v : '');
37
+ const days = (n) => (Math.abs(n) === 1 ? 'a day' : Math.abs(n) + ' days');
38
+ const clip = (s, n) => { s = str(s).replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s; };
39
+ const q = (s) => '"' + clip(s, 60) + '"';
40
+ const has = (src) => !!(src && str(src.ref));
41
+
42
+ /** The tasks as the engine reads them. */
43
+ function tasksOf(list) {
44
+ const T = new Map();
45
+ for (const x of list || []) { if (!x || !(x.uid || x.key)) continue; const t = TL.taskOf(x); T.set(t.uid, t); }
46
+ return T;
47
+ }
48
+ const copy = (T) => { const C = new Map(); for (const [k, v] of T) C.set(k, { ...v }); return C; };
49
+ const ALL = { has: () => true };
50
+ /** Settle the links over T, every task allowed to move: answers the settled copy and what could not move. */
51
+ function project(T, links, o) {
52
+ const C = copy(T);
53
+ const S = TL.settle(C, links, { seeds: o.seeds, today: o.today, ini: '', mode: 'quiet', allow: ALL });
54
+ for (const m of S.moves) { const t = C.get(m.uid); C.set(m.uid, { ...t, s: m.to.s, e: m.to.e }); }
55
+ return { T: C, moves: S.moves, clashes: S.clashes };
56
+ }
57
+ /** An initiative's last open day: its latest open task's end, or null. */
58
+ function finishOf(T, ini) {
59
+ let f = null;
60
+ for (const t of T.values()) if (t.ini === ini && !t.closed && t.e != null && (f == null || t.e > f)) f = t.e;
61
+ return f;
62
+ }
63
+ /** When an initiative's work lands: its last open task's end; its committed end only when no open task has a date. A later deadline alone
64
+ * does not move when the work lands (Wawan's Mac, 2026-11-03: extending an end flagged a milestone the work still met). */
65
+ function landOf(T, row, ini) {
66
+ const f = finishOf(T, ini);
67
+ return f != null ? f : row && row.end ? TL.dn(row.end) : null;
68
+ }
69
+ /** The open task that ends an initiative's work, or null. */
70
+ function lastOf(T, ini) {
71
+ let t = null;
72
+ for (const x of T.values()) if (x.ini === ini && !x.closed && x.e != null && (!t || x.e > t.e)) t = x;
73
+ return t;
74
+ }
75
+
76
+ function validate(snap, c) {
77
+ if (!c || !KINDS.includes(c.kind)) return 'the change is one of ' + KINDS.join(', ');
78
+ const T = (snap && snap.tasks) || [], rows = (snap && snap.rows) || [];
79
+ const isTask = (u) => T.some((t) => (t.uid || t.key) === u), isIni = (i) => rows.some((r) => r.id === i);
80
+ if (c.kind === 'move') {
81
+ if (!isTask(c.task)) return ((snap && snap.links) || []).some((l) => l && (l.from === c.task || l.to === c.task)) ? 'task ' + c.task + ' is named in a task link, but no board has it any more (removed or renamed)' : 'no task ' + str(c.task) + ' is on a board';
82
+ const n = [c.days != null, !!c.start, !!c.due].filter(Boolean).length;
83
+ if (!n) return 'say how it moves: --by <days>, --start or --due';
84
+ if (c.days != null && (!Number.isInteger(c.days) || c.days === 0 || Math.abs(c.days) > 730)) return '--by takes whole days, not 0, up to 730';
85
+ if (c.days != null && n > 1) return '--by goes alone; --start and --due go together';
86
+ for (const k of ['start', 'due']) if (c[k] && !DATE_RE.test(c[k])) return '--' + k + ' takes YYYY-MM-DD';
87
+ }
88
+ if (c.kind === 'end') { if (!isIni(c.ini)) return 'no initiative ' + str(c.ini); if (!DATE_RE.test(str(c.to))) return '--to takes YYYY-MM-DD'; }
89
+ if (c.kind === 'cut' && !(c.task ? isTask(c.task) : isIni(c.ini))) return c.task ? 'no task ' + c.task + ' is on a board' : 'no initiative ' + str(c.ini);
90
+ return '';
91
+ }
92
+
93
+ function rehearse(snap, change) {
94
+ const err = validate(snap, change);
95
+ if (err) return { ok: false, error: err };
96
+ const S = snap || {}, R = S.refs || {}, today = TL.dn(S.today);
97
+ const refL = (l) => (R.links || {})[TL.keyOf(l.from, l.to)] || null, refRow = (ini, k) => (R.rows || {})[ini + ':' + k] || null;
98
+ const rows = new Map((S.rows || []).map((r) => [r.id, r]));
99
+ const T0 = tasksOf(S.tasks);
100
+ const links0 = (S.links || []).filter((l) => l && T0.has(l.from) && T0.has(l.to));
101
+ const stale = (S.links || []).filter((l) => l && !(T0.has(l.from) && T0.has(l.to))).length;
102
+ const base = project(T0, links0, { today }); // the plan as it is
103
+ const titleOf = (u) => (base.T.get(u) || {}).title || u, iniTitle = (i) => (rows.get(i) || {}).title || i;
104
+ const c = change, effects = [];
105
+ let unsourced = 0;
106
+ const add = (e) => { const ev = (e.evidence || []).filter(has); if (ev.length) effects.push({ ...e, evidence: dedupe(ev) }); else unsourced++; };
107
+ const refT = (uid) => (R.tasks || {})[uid] || null;
108
+
109
+ // the change, applied to the plan as it is
110
+ let note = '';
111
+ let Ta = copy(base.T), links = links0, seeds = [], home = '', rowsA = new Map(rows), cutT = new Set(), cutIni = '';
112
+ if (c.kind === 'move') {
113
+ const t = Ta.get(c.task); home = t.ini; seeds = [t.uid];
114
+ let s = t.s, e = t.e;
115
+ if (c.days != null) { s = s == null ? null : s + c.days; e = e == null ? null : e + c.days; }
116
+ else {
117
+ if (c.start) { const ns = TL.dn(c.start); if (!c.due && e != null && s != null) e = e + (ns - s); s = ns; }
118
+ if (c.due) e = TL.dn(c.due);
119
+ if (s != null && e != null && e < s) return { ok: false, error: 'the due day would come before the start day' };
120
+ }
121
+ Ta.set(t.uid, { ...t, s, e });
122
+ if (c.days != null && t.e != null && today != null && t.e < today && !t.closed) note = q(t.title) + ' is late: due ' + TL.md(t.e) + '. --by ' + c.days + ' moves its due to ' + TL.md(t.e + c.days) + (t.e + c.days > today ? ', ' + days(t.e + c.days - today) + ' after today' : ', still before today') + '.';
123
+ } else if (c.kind === 'end') {
124
+ home = c.ini; rowsA.set(c.ini, { ...rows.get(c.ini), end: c.to });
125
+ } else if (c.task) {
126
+ home = Ta.get(c.task).ini; cutT.add(c.task);
127
+ } else {
128
+ home = cutIni = c.ini; for (const t of Ta.values()) if (t.ini === c.ini) cutT.add(t.uid);
129
+ }
130
+ if (cutT.size) { for (const u of cutT) Ta.delete(u); links = links0.filter((l) => !cutT.has(l.from) && !cutT.has(l.to)); }
131
+ const after = seeds.length ? project(Ta, links, { today, seeds }) : { T: Ta, moves: [], clashes: [] };
132
+ const TA = after.T;
133
+
134
+ // tasks that move: the changed initiative's in one line; another initiative's and every ◇ milestone each
135
+ const mine = [];
136
+ for (const m of after.moves) {
137
+ const b = base.T.get(m.uid), a = TA.get(m.uid);
138
+ if (!b || !a || a.e === b.e && a.s === b.s) continue;
139
+ const d = (a.e != null && b.e != null ? a.e - b.e : a.s - b.s), via = refL(m.link), why = q(titleOf(m.link.from));
140
+ const o = T0.get(m.uid), key = b.ms || a.s == null ? 'e' : 's';
141
+ const from = ' from ' + TL.md(b[key]) + (o && o[key] !== b[key] ? ' (' + TL.md(o[key]) + ' on the Board; late work before it already pushes it to ' + TL.md(b[key]) + ')' : '');
142
+ if (b.ms) add({ tone: 'breaks', kind: 'milestone', ini: b.ini, task: b.uid, text: '◇ ' + q(b.title) + (b.ini !== home ? ' in ' + q(iniTitle(b.ini)) : '') + ' moves ' + days(d) + ' later,' + from + ' to ' + TL.md(a.e) + '. It waits on ' + why + '.', evidence: [via, refT(b.uid)] });
143
+ else if (b.ini !== home) add({ tone: 'breaks', kind: 'task', ini: b.ini, task: b.uid, text: q(b.title) + ' in ' + q(iniTitle(b.ini)) + ' moves ' + days(d) + ' later,' + from + ' to ' + TL.md(a[key]) + '. It waits on ' + why + '.', evidence: [via] });
144
+ else mine.push({ m, d, via });
145
+ }
146
+ if (mine.length) {
147
+ const names = mine.slice(0, MAX_LIST).map((x) => q(titleOf(x.m.uid)) + ' +' + x.d + 'd').join(', ') + (mine.length > MAX_LIST ? ' and ' + (mine.length - MAX_LIST) + ' more' : '');
148
+ add({ tone: 'moves', kind: 'tasks', ini: home, text: (mine.length === 1 ? '1 task' : mine.length + ' tasks') + ' in ' + q(iniTitle(home)) + ' move with it: ' + names + '.', evidence: mine.map((x) => x.via) });
149
+ }
150
+ // a Jira ticket the links say must start later: PMOS cannot move it
151
+ const clashKey = (x) => x.uid + '<' + x.after;
152
+ const was = new Set(base.clashes.map(clashKey));
153
+ for (const x of after.clashes) {
154
+ if (x.kind !== 'jira' || was.has(clashKey(x))) continue;
155
+ add({ tone: 'breaks', kind: 'jira', ini: (TA.get(x.uid) || {}).ini || '', task: x.uid, text: x.uid + ' ' + q(titleOf(x.uid)) + ' would have to start ' + days(x.days) + ' later. It is a Jira ticket, so PMOS cannot move it; its assignee would.', evidence: [refL(x.link)] });
156
+ }
157
+ // what waited on a cut task: it loses what it waited on, and may start earlier
158
+ if (cutT.size) {
159
+ for (const l of links0) {
160
+ if (!cutT.has(l.from) || cutT.has(l.to)) continue;
161
+ const b = base.T.get(l.to); if (!b || b.closed) continue;
162
+ add({ tone: 'breaks', kind: 'loses', ini: b.ini, task: b.uid, text: q(b.title) + (b.ini !== home ? ' in ' + q(iniTitle(b.ini)) : '') + ' waits on ' + q(titleOf(l.from)) + ', which would be cut. Whatever it needed from it has to come from somewhere else.', evidence: [refL(l)] });
163
+ }
164
+ }
165
+ // a task moved earlier than a link into it allows: it would start before what it waits on is done
166
+ if (c.kind === 'move') {
167
+ const t1 = TA.get(c.task);
168
+ for (const l of links0) {
169
+ if (l.to !== c.task) continue;
170
+ const a = base.T.get(l.from); if (!a || a.closed) continue;
171
+ const short1 = TL.shortOf(TL.needOf(l, TA.get(l.from) || a, today), t1), short0 = TL.shortOf(TL.needOf(l, a, today), base.T.get(c.task));
172
+ if (short1 > 0 && short0 <= 0) add({ tone: 'breaks', kind: 'order', ini: t1.ini, task: t1.uid, text: q(t1.title) + ' would start ' + days(short1) + ' before ' + q(a.title) + (a.ini !== t1.ini ? ' in ' + q(iniTitle(a.ini)) : '') + ' allows. It waits on it (' + TL.TYPE_WORDS[l.type] + ').', evidence: [refL(l)] });
173
+ }
174
+ }
175
+ // what can start earlier: a task held tight by a task moved earlier now has room (a task that lost a cut task is said above)
176
+ if (c.kind === 'move' && moveDelta(base.T, Ta, c.task) < 0) {
177
+ const seen = new Set();
178
+ for (const l of links0) {
179
+ if (seen.has(l.to) || l.from !== c.task) continue;
180
+ const b = TA.get(l.to); if (!b || b.closed || b.jira || b.s == null || b.status !== 'To do') continue;
181
+ const roomB = roomOf(base.T, links0, b.uid, today), roomA = roomOf(TA, links, b.uid, today);
182
+ if (roomB > 0 || roomA <= 0) continue;
183
+ seen.add(b.uid);
184
+ add({ tone: 'helps', kind: 'earlier', ini: b.ini, task: b.uid, text: q(b.title) + (b.ini !== home ? ' in ' + q(iniTitle(b.ini)) : '') + ' could start up to ' + days(roomA) + ' earlier. Nothing moves it until you do.', evidence: [refL(l)] });
185
+ }
186
+ }
187
+ // committed ends: an initiative whose last task now ends after its committed end, or no longer does
188
+ const inis = new Set([...base.T.values()].map((t) => t.ini).concat(c.kind === 'end' ? [c.ini] : []));
189
+ for (const ini of inis) {
190
+ if (!ini || ini === cutIni) continue;
191
+ const r0 = rows.get(ini), r1 = rowsA.get(ini);
192
+ const end0 = r0 && r0.end ? TL.dn(r0.end) : null, end1 = r1 && r1.end ? TL.dn(r1.end) : null;
193
+ if (end1 == null) continue;
194
+ const f0 = finishOf(base.T, ini), f1 = finishOf(TA, ini);
195
+ const late0 = f0 != null && end0 != null && f0 > end0, late1 = f1 != null && f1 > end1;
196
+ const lt = lastOf(TA, ini);
197
+ const ev = [refRow(ini, 'end'), lt ? refT(lt.uid) : null].concat(after.moves.filter((m) => (TA.get(m.uid) || {}).ini === ini).slice(0, 2).map((m) => refL(m.link)));
198
+ if (!late0 && late1) add({ tone: 'breaks', kind: 'end', ini, text: q(iniTitle(ini)) + "'s last task would end " + TL.md(f1) + ', ' + days(f1 - end1) + ' after its committed end, ' + TL.md(end1) + '.', evidence: ev });
199
+ else if (late0 && !late1 && f1 != null) add({ tone: 'helps', kind: 'end', ini, text: q(iniTitle(ini)) + "'s last task would end " + TL.md(f1) + ', back inside its committed end, ' + TL.md(end1) + '.', evidence: ev });
200
+ }
201
+ // goals and program milestones: when each initiative's work lands, before and after
202
+ const land0 = new Map(), land1 = new Map();
203
+ for (const ini of new Set([...rows.keys(), ...inis])) { land0.set(ini, landOf(base.T, rows.get(ini), ini)); if (ini !== cutIni) land1.set(ini, landOf(TA, rowsA.get(ini), ini)); }
204
+ const lastRef = (ini) => { const t = lastOf(TA, ini) || lastOf(base.T, ini); return t ? refT(t.uid) : null; };
205
+ const movedIni = [...land0.keys()].filter((i) => i === cutIni || land0.get(i) !== land1.get(i));
206
+ for (const g of S.goals || []) {
207
+ const by = TL.dn(g.byAt); if (by == null) continue;
208
+ const fc = g.forecast && g.forecast.state ? ' Its forecast today: ' + FORECAST_WORDS(g.forecast) + '.' : '';
209
+ for (const ini of movedIni) {
210
+ if (!(g.moves || []).includes(ini)) continue;
211
+ const ev = [(R.goals || {})[g.id], refRow(ini, 'anchor')];
212
+ if (ini === cutIni) { add({ tone: 'breaks', kind: 'goal', ini, goal: g.id, text: 'Goal ' + q(g.title) + ' loses ' + q(iniTitle(ini)) + ', ' + ((g.moves || []).length === 1 ? 'the only initiative that moves it' : 'one of the ' + g.moves.length + ' initiatives that move it') + '.' + fc, evidence: ev }); continue; }
213
+ const a = land0.get(ini), b = land1.get(ini); if (a == null || b == null) continue;
214
+ if (a <= by && b > by) add({ tone: 'breaks', kind: 'goal', ini, goal: g.id, text: 'Goal ' + q(g.title) + ' is due ' + TL.md(by) + '. ' + q(iniTitle(ini)) + ', which moves it, would land ' + TL.md(b) + ', after that.' + fc, evidence: ev.concat([refRow(ini, 'end'), lastRef(ini)]) });
215
+ else if (a > by && b <= by) add({ tone: 'helps', kind: 'goal', ini, goal: g.id, text: 'Goal ' + q(g.title) + ' is due ' + TL.md(by) + '. ' + q(iniTitle(ini)) + ', which moves it, would land ' + TL.md(b) + ', in time.' + fc, evidence: ev.concat([refRow(ini, 'end'), lastRef(ini)]) });
216
+ }
217
+ }
218
+ const byIni = (L) => new Map([...L.entries()].filter(([, v]) => v != null).map(([k, v]) => [k, { state: null, high: TL.isoN(v) }]));
219
+ const B0 = byIni(land0), B1 = byIni(land1);
220
+ for (const p of S.programs || []) {
221
+ const ev0 = (R.programs || {})[p.id];
222
+ for (const m of (p.milestones || []).filter((x) => x && !x.done && x.name)) {
223
+ for (const ini of movedIni) {
224
+ if (!(m.waits || []).includes(ini)) continue;
225
+ const md = TL.dn(m.date), a = land0.get(ini), b = land1.get(ini);
226
+ const ctx = ' Program ' + q(p.title || p.id) + ': ' + atRiskWords(p, B0, B1, cutIni) + '.';
227
+ if (ini === cutIni) add({ tone: 'breaks', kind: 'program', ini, program: p.id, text: 'Milestone ' + q(m.name) + ' waits on ' + q(iniTitle(ini)) + ', which would be cut.' + ctx, evidence: [ev0] });
228
+ else if (md != null && a != null && b != null && a <= md && b > md) add({ tone: 'breaks', kind: 'program', ini, program: p.id, text: 'Milestone ' + q(m.name) + ' (' + TL.md(md) + ') waits on ' + q(iniTitle(ini)) + ', which would land ' + TL.md(b) + ', after it.' + ctx, evidence: [ev0, refRow(ini, 'end'), lastRef(ini)] });
229
+ else if (md != null && a != null && b != null && a > md && b <= md) add({ tone: 'helps', kind: 'program', ini, program: p.id, text: 'Milestone ' + q(m.name) + ' (' + TL.md(md) + ') waits on ' + q(iniTitle(ini)) + ', which would land ' + TL.md(b) + ', in time.' + ctx, evidence: [ev0, refRow(ini, 'end'), lastRef(ini)] });
230
+ }
231
+ }
232
+ }
233
+ const order = { breaks: 0, helps: 1, moves: 2 };
234
+ effects.sort((x, y) => order[x.tone] - order[y.tone]);
235
+ const counts = { breaks: 0, helps: 0, moves: 0 }; for (const e of effects) counts[e.tone]++;
236
+ return { ok: true, change: c, effects, counts, checked: { tasks: T0.size, links: links0.length, goals: (S.goals || []).length, programs: (S.programs || []).length }, missing: S.missing || [], unsourced, stale, note };
237
+ }
238
+
239
+ function moveDelta(T0, T1, uid) { const a = T0.get(uid), b = T1.get(uid); return a && b && a.e != null && b.e != null ? b.e - a.e : a && b && a.s != null && b.s != null ? b.s - a.s : 0; }
240
+ /** How many days earlier `uid` could start under the links into it (and not before tomorrow). */
241
+ function roomOf(T, links, uid, today) {
242
+ const b = T.get(uid); if (!b || b.s == null) return 0;
243
+ let need = today == null ? -Infinity : today + 1;
244
+ for (const l of links) {
245
+ if (l.to !== uid) continue;
246
+ const a = T.get(l.from); if (!a) continue;
247
+ const n = TL.needOf(l, a, today);
248
+ if (n.start != null) need = Math.max(need, n.start);
249
+ if (n.due != null && b.e != null) need = Math.max(need, b.s + (n.due - b.e));
250
+ }
251
+ return need === -Infinity ? 0 : Math.max(0, b.s - need);
252
+ }
253
+ const FORECAST_WORDS = (f) => ({ 'on-track': 'on track', 'at-risk': 'at risk', 'will-miss': 'will miss', 'too-early': 'too early to say', held: 'held, not read for over a week' }[f.state] || f.state) + (f.likely != null ? ', likely ' + f.likely : '');
254
+ function atRiskWords(p, B0, B1, cutIni) {
255
+ const P1 = { ...p, milestones: (p.milestones || []).map((m) => (cutIni && (m.waits || []).includes(cutIni) ? { ...m, risk: true } : m)) };
256
+ const a = FC.programForecast(p, B0), b = FC.programForecast(P1, B1);
257
+ if (!a || !b) return 'no milestone forecast';
258
+ return b.atRisk + ' of ' + b.total + ' milestones at risk' + (b.atRisk !== a.atRisk ? ' (was ' + a.atRisk + ')' : '');
259
+ }
260
+ function dedupe(ev) { const seen = new Set(); return ev.filter((s) => { const k = s.ref + '|' + (s.quote || ''); if (seen.has(k)) return false; seen.add(k); return true; }); }
261
+
262
+ /** The rehearsal in words, for the chat: breaks first, then helps, then what moves with it; each with its sources. */
263
+ function textOf(r) {
264
+ if (!r.ok) return ['✗ ' + r.error];
265
+ const out = [];
266
+ if (!r.effects.length) out.push('Nothing else moves: no other task, initiative, goal or program milestone changes.');
267
+ for (const e of r.effects) {
268
+ out.push((e.tone === 'breaks' ? '✗ Breaks: ' : e.tone === 'helps' ? '✓ Helps: ' : '· Moves: ') + e.text);
269
+ out.push(' source: ' + e.evidence.map((s) => s.label + ' (' + s.ref + ')').join('; '));
270
+ }
271
+ if (r.note) out.push('Note: ' + r.note);
272
+ if (r.stale) out.push((r.stale === 1 ? '1 task link names' : r.stale + ' task links name') + ' a task no board has any more, so ' + (r.stale === 1 ? 'it is' : 'they are') + ' left out.');
273
+ if (r.unsourced) out.push((r.unsourced === 1 ? '1 effect is' : r.unsourced + ' effects are') + ' not shown: PMOS found no record line to cite for it.');
274
+ if (r.missing && r.missing.length) out.push('Not read: ' + r.missing.join(', ') + '. What depends on them is not shown.');
275
+ out.push('This was a rehearsal. Nothing changed. The PM approves the change in their own words before anyone makes it.');
276
+ return out;
277
+ }
278
+
279
+ module.exports = { KINDS, rehearse, textOf, validate, roomOf, finishOf, landOf };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.506.0",
3
+ "version": "0.508.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.506.0",
9
+ "version": "0.508.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@anthropic-ai/sdk": "^1.3.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.506.0",
3
+ "version": "0.508.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "Rehearse a plan (initiative plan-rehearsal): the pure engine on a recorded snapshot, the shared guard's replay and citation check over its effects, the kill switch, the audit and `pmos rehearse`.",
3
+ "modules": ["rehearse.test.js"],
4
+ "seconds": { "rehearse.test.js": 2 }
5
+ }