create-pmos 0.508.0 → 0.510.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.508.0",
3
+ "version": "0.510.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,38 @@
32
32
  "audit"
33
33
  ],
34
34
  "pmosChanges": [
35
+ {
36
+ "v": "0.510.0",
37
+ "at": "2026-11-03T14:29:03.666Z",
38
+ "f": 3,
39
+ "prs": [
40
+ {
41
+ "n": 1197,
42
+ "t": "Clean Updates lines: no, one PR number, no repeated title, right group",
43
+ "s": "Clean Updates lines: no, one PR number, no repeated title, right group",
44
+ "k": "fix"
45
+ },
46
+ {
47
+ "n": 1199,
48
+ "t": "A Board product head's name opens its page in the Viewer",
49
+ "s": "A Board product head's name opens its page in the Viewer",
50
+ "k": "new"
51
+ }
52
+ ]
53
+ },
54
+ {
55
+ "v": "0.509.0",
56
+ "at": "2026-11-03T14:11:23.227Z",
57
+ "f": 3,
58
+ "prs": [
59
+ {
60
+ "n": 1198,
61
+ "t": "No initiative without an expected outcome",
62
+ "s": "No initiative without an expected outcome",
63
+ "k": "new"
64
+ }
65
+ ]
66
+ },
35
67
  {
36
68
  "v": "0.508.0",
37
69
  "at": "2026-11-03T12:33:41.832Z",
@@ -427,32 +459,6 @@
427
459
  "k": "fix"
428
460
  }
429
461
  ]
430
- },
431
- {
432
- "v": "0.490.0",
433
- "at": "2026-11-02T08:52:24.978Z",
434
- "f": 3,
435
- "prs": [
436
- {
437
- "n": 1153,
438
- "t": "Sign-in session takes keys; login check names the login",
439
- "s": "Sign-in session takes keys; login check names the login",
440
- "k": "fix"
441
- }
442
- ]
443
- },
444
- {
445
- "v": "0.489.0",
446
- "at": "2026-11-02T07:07:12.466Z",
447
- "f": 3,
448
- "prs": [
449
- {
450
- "n": 1151,
451
- "t": "Background runs can read through connectors; stop on a repeated denied tool",
452
- "s": "Background runs can read through connectors; stop on a repeated denied tool",
453
- "k": "fix"
454
- }
455
- ]
456
462
  }
457
463
  ]
458
464
  }
package/payload/AGENTS.md CHANGED
@@ -33,7 +33,7 @@ It refuses frontmatter, fenced and indented code, table rows, headings, delibera
33
33
 
34
34
  | Hosted tool (as skills name them) | File-mode equivalent |
35
35
  |---|---|
36
- | `create_initiative` | write `pmos/state/initiatives/<id>.md` (frontmatter: id, title, parent_kr, stage, status, **type** — the D56 lane, default `feature` — plus `intent` for the chore lane, prd_path, rubric_path, created; optionally `outcome_metric` + `outcome_horizon` — the D57 declaration for post-ship success criteria) |
36
+ | `create_initiative` | write `pmos/state/initiatives/<id>.md` (frontmatter: id, title, parent_kr, stage, status, **type** — the D56 lane, default `feature` — plus `intent` for the chore lane, prd_path, rubric_path, created; plus `outcome_metric` + `outcome_horizon` — the D57 declaration for post-ship success criteria. No initiative goes without one: after you create it, and once its PRD exists, run `pmos outcomes propose <id>` and show the cited draft; write it with `pmos outcomes declare` only after the PM's yes) |
37
37
  | `save_spec` | the PRD file itself at `pmos/planning/prd/<id>-prd.md` (set `prd_path` on the initiative file) |
38
38
  | `get_spec` / `get_planning_artifact` | read `pmos/planning/…` |
39
39
  | `get_skill` | read `pmos/skills/<name>.skill` |
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@c4e0e4ba
1
+ kernel-source: pmos@c10daf2f
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:33:39Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-03T14:29:00Z"}
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:33:39Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-03T14:29:00Z"}
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:33:39Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-03T14:29:00Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -99,6 +99,9 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
99
99
  ok('O pressing the band dispatches bd:program with the program id and opens no initiative panel', (await events(p)).join() === 'project-ascent' && await p.evaluate(() => !/Initiative/.test(((window.czViewer && window.czViewer.current()) || {}).kind || '')), JSON.stringify(await events(p)));
100
100
  await p.keyboard.press('Escape'); await p.waitForFunction(() => { const v = document.querySelector('#czViewer'); return !v || v.hidden; });
101
101
 
102
+ // batch 26 item 3: a product head's tile and name open its page in Home's Viewer; the chevron is its own button and folds
103
+ const ph = await p.evaluate(() => { const o = document.querySelector('#ibBody .bt-prd [data-prodopen]'), f = document.querySelector('#ibBody .bt-prd [data-fold]'); return { open: !!o && !o.contains(f) && /^Open IDPF, a product$/.test(o.getAttribute('aria-label')) && !!o.querySelector('.bd-ti'), fold: !!f && f.hasAttribute('aria-expanded') }; });
104
+ ok('P a product head: the tile and name are one button that opens the product, the fold is a separate button', ph.open && ph.fold, JSON.stringify(ph));
102
105
  await p.click('#ibBody .bt-pg [data-fold]');
103
106
  const f1 = await p.evaluate(() => ({ kids: document.querySelectorAll('#ibBody .bt-kid').length, band: !!document.querySelector('#ibBody .bt-pg'), exp: document.querySelector('#ibBody .bt-pg [data-fold]').getAttribute('aria-expanded'), st: localStorage.getItem('pmos.board.fold') }));
104
107
  ok('F the program group folds to its band row, and the fold is stored', f1.kids === 0 && f1.band && f1.exp === 'false' && /g:project-ascent/.test(f1.st), JSON.stringify(f1));
@@ -1,4 +1,36 @@
1
1
  [
2
+ {
3
+ "v": "0.510.0",
4
+ "at": "2026-11-03T14:29:03.666Z",
5
+ "f": 3,
6
+ "prs": [
7
+ {
8
+ "n": 1197,
9
+ "t": "Clean Updates lines: no, one PR number, no repeated title, right group",
10
+ "s": "Clean Updates lines: no, one PR number, no repeated title, right group",
11
+ "k": "fix"
12
+ },
13
+ {
14
+ "n": 1199,
15
+ "t": "A Board product head's name opens its page in the Viewer",
16
+ "s": "A Board product head's name opens its page in the Viewer",
17
+ "k": "new"
18
+ }
19
+ ]
20
+ },
21
+ {
22
+ "v": "0.509.0",
23
+ "at": "2026-11-03T14:11:23.227Z",
24
+ "f": 3,
25
+ "prs": [
26
+ {
27
+ "n": 1198,
28
+ "t": "No initiative without an expected outcome",
29
+ "s": "No initiative without an expected outcome",
30
+ "k": "new"
31
+ }
32
+ ]
33
+ },
2
34
  {
3
35
  "v": "0.508.0",
4
36
  "at": "2026-11-03T12:33:41.832Z",
@@ -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
+ - [Expected outcomes](outcomes.md): every initiative says what should change once it ships and when to check it. Claude drafts each one from the PRD or the KR, you keep it with ✓, and `pmos outcomes`.
83
84
  - [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`.
84
85
  - [Forecasts](forecast.md): how a goal, a delivery, a program and a health budget are forecast, the states, the kept baseline, and `pmos forecast`.
85
86
  - [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.
@@ -0,0 +1,39 @@
1
+ ---
2
+ type: Guide
3
+ title: "Expected outcomes"
4
+ description: "Every initiative says what should change once it ships and the date to check it. How Claude drafts them, how you keep one, how a due check is recorded, and the pmos outcomes commands."
5
+ timestamp: 2026-11-03
6
+ ---
7
+
8
+ # Expected outcomes
9
+
10
+ Every initiative has an expected outcome: what should change once it ships, and the date to check it. Shipping is not the end. The check date is when you look at whether it worked.
11
+
12
+ ## Claude drafts it
13
+
14
+ You do not type it. Once a day Claude reads each product and drafts the outcome of every initiative that has none, and a check date for an outcome that has no date.
15
+
16
+ - **The outcome** comes from the PRD's success criteria: the first item under a heading that names success, outcome, metric, goal or signal. When there is no such PRD, it comes from the KR the initiative is anchored to.
17
+ - **The check date** is 30 days after the last task of the initiative is due. When no task has a due date yet, it is 90 days from today.
18
+ - **Nothing to cite, no draft.** When there is no PRD success item and no KR, Claude does not guess. It asks you in the chat what should change.
19
+
20
+ Each draft waits under the initiative's ✦ in the Viewer, in its own group, Expected outcome. It shows the lines it comes from. ✓ keeps it: the outcome and the date are written to the initiative's record. ✕ drops it, and it does not come back unless what it rests on changes. Undo puts back what was there.
21
+
22
+ A new initiative gets its draft at the next daily read after its PRD or its tasks give Claude something to cite. At most 3 drafts a day per product show, so a backfill stays quiet.
23
+
24
+ ## When a check is due
25
+
26
+ When the check date passes, Today shows the check. Tell Claude whether it worked, did not work, or you cannot tell yet, and what shows it: a number, a link or what you saw. Claude records your verdict with the evidence. A verdict is always yours. Claude never records one from a metric alone.
27
+
28
+ ## Switch it off
29
+
30
+ `pmos ai off outcomes` stops the drafts, and `pmos ai on outcomes` starts them again. `PMOS_OUTCOMES=off` stops the daily read.
31
+
32
+ ## The commands
33
+
34
+ Run them in the product's repository, or pass `--root <dir>`.
35
+
36
+ - `pmos outcomes list` shows the checks that are due, the outcomes with no date and the initiatives with no outcome. It changes nothing.
37
+ - `pmos outcomes propose [<id>]` shows Claude's drafts, each with the lines it cites. Before the first draft of a repository it replays them on that repository's records, and shows nothing unless the replay passes.
38
+ - `pmos outcomes declare <id> --metric "<text>" --horizon <YYYY-MM-DD>` writes the outcome and its check date, after you said yes.
39
+ - `pmos outcomes record <id> <worked|didnt-work|cant-tell> --evidence "<text>"` records your verdict on a due check.
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Programs on the Board: All products, groupings and the program panel"
4
4
  description: "A program groups initiatives across products. How it shows on the Board (stage track, Kanban, timeline and List), the All products mode, the By product grouping, the program panel and the side panel width."
5
- timestamp: 2026-11-01
5
+ timestamp: 2026-11-03
6
6
  ---
7
7
 
8
8
  # Programs on the Board
@@ -18,6 +18,7 @@ A cancelled or archived initiative leaves its program's views: the band, the pro
18
18
  - The product picker is the same chip and list as on Home (see [Home](home.md)). It shows the tiles of the ticked products, or **All**. When the list closes, the Board shows the ticked products, merged as on All products. The pick is the saved setting that Home and Insights share, so a reload keeps it, and a product that you untick here goes from Home too.
19
19
  - An address with `?ws=`, `?board=`, `?all=1` or `?pick=` is a one-off view: the Board shows what the address names and does not change the saved pick. A change in the list saves the pick and opens the Board without those parts.
20
20
  - A program comes first. Its band shows the stage spread of its initiatives, the next milestone and how many initiatives are ready for it. Under the band, the initiatives sit under a small heading for each product. Then each product has its own group.
21
+ - A product's group head works like a program's: press the product's tile or name to open its page in Home's Viewer. Press the chevron to fold the group. This works in every view: the stage track, Kanban, the timeline and List. On a shared board, the product's name is plain text.
21
22
  - To group by product only, open **Filter** and choose **By product**. A program member then shows a small layers icon and stays under its product in every view, the timeline included. A chip shows while By product is on.
22
23
  - **Kanban** shows programs as swimlanes, then products. Press a column header to fold the column to a strip with its count. An empty column starts folded. The Board remembers each fold.
23
24
  - The **timeline** draws a program row that brackets its initiatives' bars, on every span. Program milestones are diamonds. Milestones that are close together merge into one diamond with a count.
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Settings in console3: one home for each setting"
4
4
  description: "How to open Settings in console3, what each section holds, what the scope tags and the environment notes mean, and where the settings are kept."
5
- timestamp: 2026-11-01
5
+ timestamp: 2026-11-03
6
6
  ---
7
7
 
8
8
  # Settings in console3: one home for each setting
@@ -70,7 +70,7 @@ Open **Updates**. PMOS asks the npm registry for the latest version at once, and
70
70
  Under the update card, **Updates** shows what changed in the published kits. **This week** is open: it is the one you need. A week runs Monday to Sunday. Every earlier week, last week included, is folded into **Archive**. Press **Archive** to list the earlier weeks (**Week of** a date), each with its counts, and press a week to read it. When there is nothing for this week yet, the newest week with changes is the open one.
71
71
 
72
72
  - Each week has up to three groups: **New** (a feature), **Improved** (a faster or better thing that already existed) and **Fixed** (a problem that is gone). A group with nothing in it is not shown, and a week with nothing to show is not listed.
73
- - Each line is a bold name and one sentence, copied from the pull request that made the change, with its number at the end. Work that you cannot see in PMOS (notes, tests, run logs) is left out.
73
+ - Each line is a bold name and one sentence, copied from the pull request that made the change, with its number at the end. When the name and the sentence say the same thing, the line shows it once. Work that you cannot see in PMOS (notes, tests, run logs) is left out, and so are notes for the people who build PMOS, such as "[skip publish]" or "(batch 25 item 11)".
74
74
  - A week shows **Ready to install** when one of its kits is newer than the version you run.
75
75
  - A kit published before PMOS kept the kind of each change has no kind. Its title decides: a word such as "fix" or "no longer" puts it under **Fixed**, and anything else under **Improved**.
76
76
  - **What's new** is not a section of its own any more. The button on the update card in **General** opens **Updates**.
@@ -149,7 +149,9 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
149
149
  const r = writer.createInitiative(root, input, { actor, now });
150
150
  if (!r.ok) return fail(r.error);
151
151
  startOnDefault(root, r, actor);
152
- return done({ id: r.id, path: r.path }, `✓ ${r.id} added at the end of the Roadmap (${r.path}). Commit the record to keep it.`);
152
+ // no initiative without an expected outcome (outcome-for-every-initiative): say what Claude drafts next, or that the PM names it
153
+ return done({ id: r.id, path: r.path }, `✓ ${r.id} added at the end of the Roadmap (${r.path}). Commit the record to keep it.\n`
154
+ + `✦ It needs an expected outcome and a date to check it. Claude drafts them from the PRD's success criteria once it is written (pmos outcomes propose ${r.id}); until then, ask the PM what should change once it ships.`);
153
155
  }
154
156
 
155
157
  if (verb === 'list') {
@@ -247,6 +247,57 @@ function writeAnchor(root, id, text0) {
247
247
  return { ok: true, id, from, anchor: text0 };
248
248
  }
249
249
 
250
+ /** D57's two lines on a record: `outcome_metric` (what should move) and `outcome_horizon` (the date to check it). The PM's yes to a ✦
251
+ * from `pmos outcomes propose` reaches here through `pmos outcomes declare` (lib/outcomes-cli.js). Only those two lines change; a line
252
+ * that is not there yet is added at the end of the front matter. → { ok: true, id, from: { metric, horizon }, metric, horizon } | refusal */
253
+ function setOutcome(root, id, { metric, horizon } = {}, { actor, today } = {}) {
254
+ if (!actorLib.valid(actor)) return refuse(500, 'initiative-writer: the caller must pass the actor from pm-guard.js or actor.js');
255
+ const m = typeof metric === 'string' ? metric.replace(/\s+/g, ' ').trim() : '';
256
+ if (!m || !ONE_LINE(m, 300) || m.length < 8) return refuse(400, 'say what should move once it ships, in one line of 8 to 300 characters', 'metric');
257
+ const h = typeof horizon === 'string' ? horizon.trim() : '';
258
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(h) || Number.isNaN(Date.parse(h + 'T00:00:00Z'))) return refuse(400, 'the check date is YYYY-MM-DD', 'horizon');
259
+ if (h < (today || new Date().toISOString().slice(0, 10))) return refuse(400, `the check date ${h} has passed already: pick a date from today on`, 'horizon');
260
+ const f = recordFile(root, id);
261
+ if (!f.ok) return f;
262
+ const text = fs.readFileSync(f.file, 'utf8');
263
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/.exec(text);
264
+ if (!fm) return refuse(409, `the record of ${id} has no front matter — nothing was saved`, 'id');
265
+ const lines = fm[1].split(/\r?\n/);
266
+ const from = {};
267
+ for (const [key, val, name] of [['outcome_metric', m, 'metric'], ['outcome_horizon', h, 'horizon']]) {
268
+ const at = lines.findIndex((l) => l.startsWith(key + ':'));
269
+ from[name] = at >= 0 ? unquote(lines[at].slice(key.length + 1)) : '';
270
+ const line = `${key}: ${name === 'horizon' ? val : q(val)}`;
271
+ if (at >= 0) lines[at] = line; else lines.push(line);
272
+ }
273
+ if (from.metric === m && from.horizon === h) return { ok: true, id, from, metric: m, horizon: h, same: true };
274
+ try { fs.writeFileSync(f.file, '---\n' + lines.join('\n') + '\n---' + fm[2] + text.slice(fm[0].length), { flag: 'w' }); } catch { return refuse(500, 'the record could not be written — nothing was saved'); }
275
+ return { ok: true, id, from, metric: m, horizon: h };
276
+ }
277
+
278
+ /** The Undo of a ✓ on an outcome ✦: the two lines back to `from` (a line that was not there is removed), only while they still say `to`.
279
+ * A record the PM edited since keeps its edit (→ { ok: true, kept: true }). */
280
+ function restoreOutcome(root, id, from = {}, to = {}, { actor } = {}) {
281
+ if (!actorLib.valid(actor)) return refuse(500, 'initiative-writer: the caller must pass the actor from pm-guard.js or actor.js');
282
+ const f = recordFile(root, id);
283
+ if (!f.ok) return f;
284
+ const text = fs.readFileSync(f.file, 'utf8');
285
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/.exec(text);
286
+ if (!fm) return refuse(409, `the record of ${id} has no front matter — nothing was saved`, 'id');
287
+ let lines = fm[1].split(/\r?\n/);
288
+ const val = (key) => { const l = lines.find((x) => x.startsWith(key + ':')); return l ? unquote(l.slice(key.length + 1)) : ''; };
289
+ if (val('outcome_metric') !== String(to.metric || '') || val('outcome_horizon') !== String(to.horizon || '')) return { ok: true, id, kept: true };
290
+ for (const [key, v, name] of [['outcome_metric', from.metric, 'metric'], ['outcome_horizon', from.horizon, 'horizon']]) {
291
+ const at = lines.findIndex((l) => l.startsWith(key + ':'));
292
+ if (!v) { if (at >= 0) lines.splice(at, 1); continue; }
293
+ const line = `${key}: ${name === 'horizon' ? v : q(v)}`;
294
+ if (at >= 0) lines[at] = line; else lines.push(line);
295
+ }
296
+ lines = lines.filter((l, i) => l !== '' || i < lines.length - 1);
297
+ try { fs.writeFileSync(f.file, '---\n' + lines.join('\n') + '\n---' + fm[2] + text.slice(fm[0].length), { flag: 'w' }); } catch { return refuse(500, 'the record could not be written — nothing was saved'); }
298
+ return { ok: true, id };
299
+ }
300
+
250
301
  function withdrawSplit(root, id, want, { actor } = {}) {
251
302
  if (!actorLib.valid(actor)) return refuse(500, 'initiative-writer: the caller must pass the actor from pm-guard.js or actor.js');
252
303
  const f = recordFile(root, id);
@@ -258,4 +309,4 @@ function withdrawSplit(root, id, want, { actor } = {}) {
258
309
  return { ok: true, removed: true };
259
310
  }
260
311
 
261
- module.exports = { createInitiative, renameInitiative, setAnchor, restoreAnchor, withdrawSplit, recordSha, slugOf, ID_RE, INPUT_KEYS };
312
+ module.exports = { setOutcome, restoreOutcome, createInitiative, renameInitiative, setAnchor, restoreAnchor, withdrawSplit, recordSha, slugOf, ID_RE, INPUT_KEYS };
@@ -122,12 +122,24 @@ const npxArgs = (src, version, rest) => (src && src.kind === 'local'
122
122
  ? ['-y', `--package=${src.path}`, '--', PKG_NAME, ...rest]
123
123
  : ['-y', `${PKG_NAME}@${version}`, ...rest]);
124
124
 
125
- /** One changelog entry as the page may show it, or null: a version, a time, and PRs with a number, a summary and, from
126
- * kit-changelog format 2, a title. */
125
+ /** One changelog entry as the page may show it, or null: a version, a time, and PRs with a number, a summary, from
126
+ * kit-changelog format 2 a title, and from format 3 a kind (k: new, imp, fix or x), which decides New, Improved or Fixed.
127
+ * Kits published before kit-changelog dropped "[skip publish]" kept it in the text and lost the PR number to an inline
128
+ * "(#123)"; both are repaired here, so every line ends in one muted number. */
129
+ const KINDS = new Set(['new', 'imp', 'fix', 'x']);
130
+ const SKIP_TAG = /\s*\[(?:skip|no)[ -][a-z]+\]/gi;
131
+ const PR_REF = /\s*\(#(\d+)\)/;
132
+ const WORK_TAG = /\s*\((?:batch \d+|canvas |D\d+\b)[^()]*\)\s*$/i; // "(batch 25 item 11)", "(canvas TplA)", "(D105)": how it was made, not what it does
133
+ const tidy = (t) => t.replace(SKIP_TAG, '').replace(new RegExp(PR_REF.source, 'g'), '').replace(/\s+/g, ' ').trim().replace(WORK_TAG, '').trim();
134
+ function cleanPr(p) {
135
+ const t = typeof p.t === 'string' ? p.t : '', ref = PR_REF.exec(t) || PR_REF.exec(p.s);
136
+ const n = Number.isInteger(p.n) ? p.n : ref ? Number(ref[1]) : null;
137
+ const tt = tidy(t).slice(0, 200), s = tidy(p.s).slice(0, 400);
138
+ return { n, ...(tt ? { t: tt } : {}), s: s || tt, ...(KINDS.has(p.k) ? { k: p.k } : {}) };
139
+ }
127
140
  function cleanEntry(e) {
128
141
  if (!e || !validVersion(e.v)) return null;
129
- const prs = (Array.isArray(e.prs) ? e.prs : []).filter((p) => p && typeof p.s === 'string' && p.s.trim())
130
- .map((p) => ({ n: Number.isInteger(p.n) ? p.n : null, ...(typeof p.t === 'string' && p.t.trim() ? { t: p.t.trim().slice(0, 200) } : {}), s: p.s.trim().slice(0, 400) }));
142
+ const prs = (Array.isArray(e.prs) ? e.prs : []).filter((p) => p && typeof p.s === 'string' && p.s.trim()).map(cleanPr).filter((p) => p.s);
131
143
  return { v: e.v, at: typeof e.at === 'string' && !Number.isNaN(Date.parse(e.at)) ? e.at : null, prs };
132
144
  }
133
145
  const cleanList = (list) => (Array.isArray(list) ? list.map(cleanEntry).filter(Boolean) : []);
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+ /* outcome-props.js — Claude drafts every missing expected outcome by itself (initiative outcome-for-every-initiative; lib/outcomes.js).
3
+ *
4
+ * create(deps) → { tick({ now }), runProduct(w, { now }) }
5
+ * cases(S, drafts) → the replay cases of one product (lib/ai-guard.js replay)
6
+ *
7
+ * Once a day per product, in the hours the PM allows (deps.open), the server reads each product's records (lib/outcomes.js scan) and files
8
+ * what it drafts under the initiative's blue ✦ (lib/proposals.js group `outcome`): an expected outcome and a check date for an initiative
9
+ * that has none, or a check date for an outcome that has no date. A new initiative is drafted at the next tick, so "at creation" means within
10
+ * the tick after its PRD or its tasks give Claude something to cite (the PM chose this on 2026-11-03: Claude drafts at creation, no refusal).
11
+ * No model and no session: the drafts are the PRD's and the KR's own words, each claim cited with path:line and that line's text.
12
+ *
13
+ * THE DOOR. Every draft goes through lib/ai-guard.js file(): the kill switch (PMOS_AI, PMOS_AI_OUTCOMES, Settings), the citation check and the
14
+ * audit. Its replay gate is per product (version `outcomes-1:<ws>`): before a product's first filing the server replays the drafts on that
15
+ * product's real records, with the guard's planted fabrications, and files nothing until that passed. The audit and the replay record live in
16
+ * the per-user runtime folder, never in the repository.
17
+ *
18
+ * WHAT IT WRITES: `propose` lines in the product's proposals.jsonl, at most PER_DAY a day per product (quiet by default; a backfill of 60
19
+ * takes a few weeks of mornings, or `pmos outcomes propose` in a sitting). A draft the PM dropped (✕) or kept is not filed again. When a
20
+ * record has its outcome by another way (`pmos outcomes declare`, an edit), the open draft is dropped. It never writes a record: only the PM's ✓.
21
+ *
22
+ * deps: { workspaces() → [w], isLead(w), stateDirFor(w), boardOf(w, id) → board id | null, runtime() → the per-user state folder, open(now),
23
+ * env, every (ms, its own timer), mayRun(), warn(what, err) }
24
+ */
25
+ const crypto = require('crypto');
26
+ const O = require('./outcomes.js');
27
+ const PR = require('./proposals.js');
28
+
29
+ const PER_DAY = 3;
30
+ const flagOff = (v) => /^(off|0|false|no)$/i.test(String(v == null ? '' : v).trim());
31
+ const off = (env) => flagOff((env || process.env).PMOS_OUTCOMES);
32
+ const localDay = (ms) => { const d = new Date(ms); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); };
33
+ const guardLib = () => { try { return require('./ai-guard.js'); } catch { return null; } };
34
+ const versionOf = (w) => O.VERSION + ':' + w.id;
35
+ const typeOf = (d) => (d.data.kind === 'undated' ? 'date' : 'new');
36
+
37
+ /** The proposals line a draft becomes. */
38
+ function lineOf(d, board, run) {
39
+ const type = typeOf(d), ini = d.data.initiative;
40
+ const sources = d.claims.flatMap((c) => c.sources).map((s) => ({ label: s.label, ref: s.ref }));
41
+ const c = PR.clean('outcome', type, { title: type === 'date' ? 'A date to check the outcome' : 'What should change once it ships', why: d.say, sources,
42
+ items: [{ id: 'outcome', title: d.data.metric, due: d.data.horizon }], data: { metric: d.data.metric, horizon: d.data.horizon } });
43
+ if (c.error) return null;
44
+ return { kind: 'propose', ini, board, run, id: PR.idOf(ini, 'outcome', type, ''), ...c.prop };
45
+ }
46
+
47
+ /** One product's replay cases: its own drafts must all pass, and an initiative with nothing to cite gets no draft. */
48
+ function cases(S, list) {
49
+ const out = [{ name: 'the drafts of ' + S.root.split(/[\\/]/).pop(), run: async () => list.slice(0, PER_DAY), expect: { filed: Math.min(PER_DAY, list.length) } }];
50
+ out.push({ name: 'nothing to cite, no draft', run: async () => O.drafts({ ...S, due: [], undated: [], missing: [{ id: 'zz-no-source', title: 'No source', fm: {}, prd: '', anchor: '' }] }, { tasks: [] }).drafts, expect: { none: true } });
51
+ return out;
52
+ }
53
+
54
+ function create(deps) {
55
+ const warn = deps.warn || (() => {});
56
+ const lastDay = new Map(); // ws id → the local day it ran
57
+ let busy = false;
58
+
59
+ async function runProduct(w, { now = Date.now() } = {}) {
60
+ const dir = deps.stateDirFor(w); if (!dir) return { ws: w.id, filed: 0, why: 'no state folder' };
61
+ const rt = deps.runtime && deps.runtime();
62
+ const S = O.scan(w.root, { today: localDay(now) });
63
+ const F = PR.read(dir), kept = PR.keptIds(F);
64
+ // a record that has its outcome now: its open draft goes (zero is silence)
65
+ const has = new Map(S.subjects.map((it) => [it.id, it]));
66
+ for (const p of F.props.values()) {
67
+ if (p.group !== 'outcome' || kept.has(p.id)) continue;
68
+ const it = has.get(p.ini), sig = PR.sigOf(p);
69
+ if (F.drops.has(sig)) continue;
70
+ if (!it || (it.metric && (p.type === 'new' || it.horizon))) PR.append(dir, { by: 'pmos:outcomes' }, { kind: 'drop', ini: p.ini, sig });
71
+ }
72
+ const D = O.drafts(S).drafts.filter((d) => d.data.kind === 'missing' || d.data.kind === 'undated');
73
+ const fresh = [];
74
+ for (const d of D) {
75
+ const board = deps.boardOf(w, d.data.initiative); if (!board) continue;
76
+ const l = lineOf(d, board, ''); if (!l) continue;
77
+ const prev = F.props.get(l.id);
78
+ if (kept.has(l.id)) continue;
79
+ if (prev && (F.drops.has(PR.sigOf(prev)) || (prev.type === l.type && prev.data && prev.data.metric === l.data.metric))) continue; // dropped for good, or filed already (a default date that moves a day is not news)
80
+ fresh.push({ d, board });
81
+ }
82
+ if (!fresh.length) return { ws: w.id, filed: 0 };
83
+ const AG = guardLib(); if (!AG) return { ws: w.id, filed: 0, why: 'the shared guard is not installed' };
84
+ const version = versionOf(w), run = 'oc' + now.toString(36) + crypto.randomBytes(3).toString('hex');
85
+ if (!AG.off(O.JOB, { env: deps.env || process.env, settingsDir: rt }) && !AG.ready(rt, O.JOB, version)) {
86
+ const rep = await AG.replay(O.JOB, version, cases(S, fresh.map((x) => x.d)), { root: w.root, stateDir: rt, max: PER_DAY });
87
+ if (!rep.ok) { warn('outcomes replay ' + w.id, new Error(rep.failed.map((f) => f.name + ': ' + f.why).join('; '))); return { ws: w.id, filed: 0, replay: rep }; }
88
+ }
89
+ const byId = new Map(fresh.map((x) => [x.d.id, x]));
90
+ const r = await AG.file({ job: O.JOB, version, run, root: w.root, stateDir: rt, settingsDir: rt, env: deps.env || process.env, max: PER_DAY }, fresh.map((x) => x.d),
91
+ async (acc) => { for (const p of acc) { const x = byId.get(p.id); const l = x && lineOf(x.d, x.board, run); if (l) PR.append(dir, { by: 'agent:outcomes' }, l); } });
92
+ return { ws: w.id, filed: (r.filed || []).length, held: !!r.held, off: r.off || null };
93
+ }
94
+
95
+ async function tick({ now = Date.now() } = {}) {
96
+ if (off(deps.env) || busy) return { off: off(deps.env), running: busy };
97
+ let open = true;
98
+ try { open = deps.open ? !!deps.open(now) : true; } catch { open = true; }
99
+ if (!open) return { closed: true };
100
+ busy = true;
101
+ const out = [];
102
+ try {
103
+ const day = localDay(now);
104
+ for (const w of deps.workspaces()) {
105
+ if (lastDay.get(w.id) === day) continue;
106
+ try { if (deps.isLead && !deps.isLead(w)) continue; } catch { continue; }
107
+ lastDay.set(w.id, day);
108
+ try { out.push(await runProduct(w, { now })); } catch (e) { warn('outcomes ' + w.id, e); }
109
+ }
110
+ } finally { busy = false; }
111
+ return { ran: out };
112
+ }
113
+ if (deps.every > 0) {
114
+ const t = setInterval(() => { try { if (deps.mayRun && !deps.mayRun()) return; } catch { /* no lock: run */ } tick({}).catch((e) => warn('outcomes tick', e)); }, deps.every);
115
+ if (t.unref) t.unref();
116
+ }
117
+ return { tick, runProduct };
118
+ }
119
+
120
+ module.exports = { create, cases, lineOf, off, PER_DAY };