create-pmos 0.508.0 → 0.509.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 +14 -14
- package/payload/AGENTS.md +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/changelog.json +13 -0
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/outcomes.md +39 -0
- package/workspace/lib/initiative-cli.js +3 -1
- package/workspace/lib/initiative-writer.js +52 -1
- package/workspace/lib/outcome-props.js +120 -0
- package/workspace/lib/outcomes-cli.js +138 -0
- package/workspace/lib/outcomes.js +312 -0
- package/workspace/lib/pmos-verbs.js +3 -0
- package/workspace/lib/proposals.js +14 -4
- package/workspace/lib/routes-proposals.js +6 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/ini-props.js +5 -2
- package/workspace/run-tests.js +4 -2
- package/workspace/server.js +14 -0
- package/workspace/test-lists/outcome-for-every-initiative.json +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.509.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,19 @@
|
|
|
32
32
|
"audit"
|
|
33
33
|
],
|
|
34
34
|
"pmosChanges": [
|
|
35
|
+
{
|
|
36
|
+
"v": "0.509.0",
|
|
37
|
+
"at": "2026-11-03T14:11:23.227Z",
|
|
38
|
+
"f": 3,
|
|
39
|
+
"prs": [
|
|
40
|
+
{
|
|
41
|
+
"n": 1198,
|
|
42
|
+
"t": "No initiative without an expected outcome",
|
|
43
|
+
"s": "No initiative without an expected outcome",
|
|
44
|
+
"k": "new"
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
},
|
|
35
48
|
{
|
|
36
49
|
"v": "0.508.0",
|
|
37
50
|
"at": "2026-11-03T12:33:41.832Z",
|
|
@@ -440,19 +453,6 @@
|
|
|
440
453
|
"k": "fix"
|
|
441
454
|
}
|
|
442
455
|
]
|
|
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
456
|
}
|
|
457
457
|
]
|
|
458
458
|
}
|
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;
|
|
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` |
|
package/payload/KIT-VERSION
CHANGED
|
@@ -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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-11-03T14:11:20Z"}
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-11-03T14:11:20Z"}
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-11-03T14:11:20Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/changelog.json
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "0.509.0",
|
|
4
|
+
"at": "2026-11-03T14:11:23.227Z",
|
|
5
|
+
"f": 3,
|
|
6
|
+
"prs": [
|
|
7
|
+
{
|
|
8
|
+
"n": 1198,
|
|
9
|
+
"t": "No initiative without an expected outcome",
|
|
10
|
+
"s": "No initiative without an expected outcome",
|
|
11
|
+
"k": "new"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
},
|
|
2
15
|
{
|
|
3
16
|
"v": "0.508.0",
|
|
4
17
|
"at": "2026-11-03T12:33:41.832Z",
|
package/workspace/guide/index.md
CHANGED
|
@@ -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.
|
|
@@ -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
|
-
|
|
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 };
|
|
@@ -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 };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* outcomes-cli.js — `pmos outcomes`: no initiative without an expected outcome (initiative outcome-for-every-initiative; lib/outcomes.js, D57).
|
|
3
|
+
*
|
|
4
|
+
* pmos outcomes list [--json] every initiative's outcome state in this repository: checks due, outcomes with no
|
|
5
|
+
* date, initiatives with no outcome, pending and checked. Reads only.
|
|
6
|
+
* pmos outcomes propose [<id>] [--max <n>] [--json] Claude's ✦ drafts, each claim cited path:line, through the shared guard
|
|
7
|
+
* (lib/ai-guard.js). Before the first draft of a repository it replays them on its real
|
|
8
|
+
* records. Writes only the guard's audit, in the per-user folder.
|
|
9
|
+
* pmos outcomes declare <id> --metric "<text>" --horizon <YYYY-MM-DD>
|
|
10
|
+
* the PM's yes to a draft: writes the record's outcome_metric and outcome_horizon lines
|
|
11
|
+
* pmos outcomes record <id> <worked|didnt-work|cant-tell> --evidence "<text>"
|
|
12
|
+
* the PM's verdict on a check that is due (validated | refuted | inconclusive are the
|
|
13
|
+
* same words), appended to the outcome ledger. Evidence is required.
|
|
14
|
+
* every command: [--root <dir>] (default: this repository)
|
|
15
|
+
*
|
|
16
|
+
* The agent runs declare and record only after the PM said yes in the chat; the line names who wrote it (lib/actor.js). A verdict is the
|
|
17
|
+
* PM's (D57): never record one from a metric alone. Exit codes: 0 done, 1 refused, 2 usage. Nothing is sent.
|
|
18
|
+
*/
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const { execFileSync } = require('child_process');
|
|
21
|
+
const O = require('./outcomes.js');
|
|
22
|
+
|
|
23
|
+
const COMMANDS = { outcomes: ['list', 'propose', 'declare', 'record'] };
|
|
24
|
+
const USAGE = [
|
|
25
|
+
'usage: pmos outcomes list|propose|declare|record — every initiative has an expected outcome and a date to check it',
|
|
26
|
+
' pmos outcomes list [--json] checks due, outcomes with no date, initiatives with no outcome; reads only',
|
|
27
|
+
' pmos outcomes propose [<id>] [--max <n>] [--json] Claude\'s ✦ drafts from the PRD and the KR, each one cited',
|
|
28
|
+
' pmos outcomes declare <id> --metric "<text>" --horizon <YYYY-MM-DD> the PM said yes: write the outcome and its check date',
|
|
29
|
+
' pmos outcomes record <id> <worked|didnt-work|cant-tell> --evidence "<text>" the PM\'s verdict on a due check',
|
|
30
|
+
'Run it in the product\'s repository, or pass --root <dir>. Show each ✦ to the PM and write only after a yes.',
|
|
31
|
+
];
|
|
32
|
+
const VERDICT = { worked: 'validated', validated: 'validated', 'didnt-work': 'refuted', "didn't-work": 'refuted', refuted: 'refuted', 'cant-tell': 'inconclusive', "can't-tell": 'inconclusive', inconclusive: 'inconclusive' };
|
|
33
|
+
|
|
34
|
+
function parse(args) {
|
|
35
|
+
const pos = [], flags = {};
|
|
36
|
+
for (let i = 0; i < args.length; i++) {
|
|
37
|
+
const a = args[i];
|
|
38
|
+
if (!a.startsWith('--')) { pos.push(a); continue; }
|
|
39
|
+
const k = a.slice(2);
|
|
40
|
+
if (k === 'json') flags.json = true;
|
|
41
|
+
else flags[k] = args[i + 1] !== undefined && !String(args[i + 1]).startsWith('--') ? args[++i] : '';
|
|
42
|
+
}
|
|
43
|
+
return { pos, flags };
|
|
44
|
+
}
|
|
45
|
+
function repoRoot(cwd) {
|
|
46
|
+
try { return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd; } catch { return cwd; }
|
|
47
|
+
}
|
|
48
|
+
const wsIdOf = (root) => { try { return require('./driver.js').wsIdOf(root); } catch { return require('crypto').createHash('sha1').update(path.resolve(root)).digest('hex').slice(0, 10); } };
|
|
49
|
+
const draftText = (p) => `✦ ${p.title}\n ${p.say}\n${p.claims.map((c) => ' · ' + c.text + ' [' + c.sources.map((s) => s.ref).join(', ') + ']').join('\n')}`
|
|
50
|
+
+ (p.data && p.data.kind !== 'due' ? `\n Yes → pmos outcomes declare ${p.data.initiative} --metric ${JSON.stringify(p.data.metric)} --horizon ${p.data.horizon}`
|
|
51
|
+
: p.data ? `\n Verdict → pmos outcomes record ${p.data.initiative} worked|didnt-work|cant-tell --evidence "<what shows it>"` : '');
|
|
52
|
+
|
|
53
|
+
async function run(argv, { say = (s) => console.log(s), env = process.env, cwd = process.cwd(), now = Date.now(), isTTY, runtime } = {}) {
|
|
54
|
+
const args = argv[0] === 'outcomes' ? argv.slice(1) : argv.slice();
|
|
55
|
+
const sub = args[0];
|
|
56
|
+
if (!sub || ['help', '--help', '-h'].includes(sub)) { for (const l of USAGE) say(l); return sub ? 0 : 1; }
|
|
57
|
+
if (!COMMANDS.outcomes.includes(sub)) { say(`unknown outcomes command "${sub}". Use one of: ${COMMANDS.outcomes.join(', ')}`); return 2; }
|
|
58
|
+
const { pos, flags } = parse(args.slice(1));
|
|
59
|
+
const allowed = { list: ['json', 'root'], propose: ['json', 'root', 'max'], declare: ['json', 'root', 'metric', 'horizon'], record: ['json', 'root', 'evidence'] }[sub];
|
|
60
|
+
for (const k of Object.keys(flags)) if (!allowed.includes(k)) { say(`✗ --${k} is not an option of pmos outcomes ${sub}`); return 2; }
|
|
61
|
+
const root = path.resolve(flags.root || repoRoot(cwd));
|
|
62
|
+
const today = O.todayOf(now);
|
|
63
|
+
const fail = (error, code = 1) => { say(flags.json ? JSON.stringify({ ok: false, error }) : '✗ ' + error); return code; };
|
|
64
|
+
|
|
65
|
+
if (sub === 'list') {
|
|
66
|
+
if (pos.length) return fail('pmos outcomes list takes no argument', 2);
|
|
67
|
+
const S = O.scan(root, { today });
|
|
68
|
+
const pick = (a) => a.map((it) => ({ id: it.id, title: it.title, lane: it.lane, metric: it.metric || null, horizon: it.horizon || null }));
|
|
69
|
+
if (flags.json) { say(JSON.stringify({ ok: true, total: S.subjects.length, due: pick(S.due), undated: pick(S.undated), missing: pick(S.missing), recent: S.recent, pending: pick(S.pending), disposed: S.disposed.map((d) => ({ id: d.id, disposition: d.disposition })), shippedWithout: S.shippedWithout, notRead: S.notRead })); return 0; }
|
|
70
|
+
const named = S.due.length + S.undated.length + S.pending.length + S.disposed.length;
|
|
71
|
+
say(`${S.subjects.length} initiatives · ${named} name an expected outcome · ${named - S.undated.length} of those have a check date · ${S.disposed.length} checked`);
|
|
72
|
+
if (S.due.length) say(`\n${S.due.length} check${S.due.length === 1 ? '' : 's'} due:\n` + S.due.map((it) => ` ${it.id} — since ${O.short(it.horizon)} · ${it.metric}`).join('\n'));
|
|
73
|
+
if (S.undated.length) say(`\n${S.undated.length} with no check date:\n` + S.undated.map((it) => ` ${it.id} — ${it.metric}`).join('\n'));
|
|
74
|
+
if (S.missing.length) say(`\n${S.missing.length} under way with no expected outcome${S.recent ? ` (${S.recent} edited in the last 30 days)` : ''}:\n` + S.missing.slice(0, 15).map((it) => ` ${it.id} — ${it.title}`).join('\n') + (S.missing.length > 15 ? `\n … and ${S.missing.length - 15} more (--json lists all)` : ''));
|
|
75
|
+
if (S.shippedWithout) say(`\n${S.shippedWithout} accepted with no outcome: too late to declare, counted only.`);
|
|
76
|
+
if (S.notRead.length) say('Not read: ' + S.notRead.join(', '));
|
|
77
|
+
if (S.due.length || S.undated.length || S.missing.length) say('\nClaude drafts each one: pmos outcomes propose');
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (sub === 'propose') {
|
|
82
|
+
if (pos.length > 1) return fail('pmos outcomes propose takes at most one initiative id', 2);
|
|
83
|
+
const max = flags.max === undefined ? 3 : Number(flags.max);
|
|
84
|
+
if (!Number.isInteger(max) || max < 1 || max > 10) return fail('--max is a whole number from 1 to 10', 2);
|
|
85
|
+
const S = O.scan(root, { today });
|
|
86
|
+
if (pos[0] && !S.subjects.some((it) => it.id === pos[0])) return fail(`no initiative ${pos[0]} here (or it is stopped, or on a document lane)`);
|
|
87
|
+
const { drafts, ask } = O.drafts(S, { only: pos[0] || null });
|
|
88
|
+
const askText = ask.length ? `${ask.length} with nothing to draft from (no success criteria in a PRD, no KR to cite): ask the PM what should change once each ships — ${ask.slice(0, 8).map((a) => a.id).join(', ')}${ask.length > 8 ? ', …' : ''}` : '';
|
|
89
|
+
if (!drafts.length) { if (flags.json) say(JSON.stringify({ ok: true, drafts: [], ask })); else say(askText || 'Nothing to draft: every initiative here has its expected outcome and check date.'); return 0; }
|
|
90
|
+
let AG = null; try { AG = require('./ai-guard.js'); } catch { AG = null; }
|
|
91
|
+
if (!AG) return fail('the shared guard (lib/ai-guard.js) is not installed, so nothing can show');
|
|
92
|
+
const rt = runtime || require('./state-root.js').defaultRoot({ env });
|
|
93
|
+
const version = O.VERSION + ':' + wsIdOf(root);
|
|
94
|
+
const isOff = AG.off(O.JOB, { env, settingsDir: rt });
|
|
95
|
+
if (isOff) return fail(isOff + ': nothing drafted. pmos ai on outcomes turns it back on.');
|
|
96
|
+
let replayed = null;
|
|
97
|
+
if (!AG.ready(rt, O.JOB, version)) {
|
|
98
|
+
const cases = require('./outcome-props.js').cases(S, drafts).map((c, i) => (i === 0 ? { ...c, run: async () => drafts.slice(0, max), expect: { filed: Math.min(max, drafts.length) } } : c));
|
|
99
|
+
replayed = await AG.replay(O.JOB, version, cases, { root, stateDir: rt, max });
|
|
100
|
+
if (!replayed.ok) return fail('the replay on this repository\'s records did not pass, so nothing shows:\n' + replayed.failed.map((f) => ' - ' + f.name + ': ' + f.why).join('\n'));
|
|
101
|
+
}
|
|
102
|
+
let shown = [];
|
|
103
|
+
const r = await AG.file({ job: O.JOB, version, run: 'cli' + now.toString(36), root, stateDir: rt, settingsDir: rt, env, max }, drafts.slice(0, max), async (acc) => { shown = acc; });
|
|
104
|
+
if (r.error) return fail(r.error);
|
|
105
|
+
if (flags.json) { say(JSON.stringify({ ok: true, drafts: shown, dropped: r.dropped, more: Math.max(0, drafts.length - max), ask, replayed: replayed ? replayed.ok : null })); return 0; }
|
|
106
|
+
if (replayed) say('✓ replayed on this repository\'s records first: every draft cites a line that says it.');
|
|
107
|
+
for (const p of shown) say(draftText(p));
|
|
108
|
+
if (r.dropped.length) say(`(${r.dropped.length} dropped by the guard: ${r.dropped.map((d) => d.id).join(', ')})`);
|
|
109
|
+
const more = drafts.length - Math.min(max, drafts.length);
|
|
110
|
+
if (more > 0) say(`${more} more to draft: run pmos outcomes propose again after these.`);
|
|
111
|
+
if (askText) say(askText);
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const id = pos[0];
|
|
116
|
+
const actor = require('./actor.js').fromCli({ isTTY: isTTY === undefined ? !!(process.stdin.isTTY && process.stdout.isTTY) : isTTY, env });
|
|
117
|
+
|
|
118
|
+
if (sub === 'declare') {
|
|
119
|
+
if (!id || pos.length > 1) return fail('usage: pmos outcomes declare <id> --metric "<text>" --horizon <YYYY-MM-DD>', 2);
|
|
120
|
+
const r = require('./initiative-writer.js').setOutcome(root, id, { metric: flags.metric, horizon: flags.horizon }, { actor, today });
|
|
121
|
+
if (!r.ok) return fail(r.error, r.status === 400 ? 2 : 1);
|
|
122
|
+
if (flags.json) { say(JSON.stringify({ ok: true, id, metric: r.metric, horizon: r.horizon, from: r.from, same: !!r.same })); return 0; }
|
|
123
|
+
say(r.same ? `${id} already expects that, checked on ${O.short(r.horizon)}.` : `✓ ${id} expects: ${r.metric} · check on ${O.short(r.horizon)}. Commit the record to keep it.`);
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// record
|
|
128
|
+
if (!id || pos.length !== 2) return fail('usage: pmos outcomes record <id> <worked|didnt-work|cant-tell> --evidence "<text>"', 2);
|
|
129
|
+
const disposition = VERDICT[String(pos[1]).toLowerCase()];
|
|
130
|
+
if (!disposition) return fail('the verdict is worked, didnt-work or cant-tell (or validated, refuted, inconclusive)', 2);
|
|
131
|
+
const r = O.recordCheck(root, { initiative: id, disposition, evidence: flags.evidence, by: actor.by, via: actor.via }, { now });
|
|
132
|
+
if (!r.ok) return fail(r.error);
|
|
133
|
+
if (flags.json) { say(JSON.stringify({ ok: true, file: r.file, line: r.line })); return 0; }
|
|
134
|
+
say(`✓ ${id}: ${disposition} — ${r.line.evidence} (${r.file}). Commit it to keep it.`);
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = { run, COMMANDS, USAGE, VERDICT };
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* outcomes.js — no initiative without an outcome (initiative outcome-for-every-initiative; D57). THE PURE-ISH CORE of `pmos outcomes`:
|
|
3
|
+
* it reads the records and drafts the ✦ proposals; lib/outcomes-cli.js prints them, and every proposal passes lib/ai-guard.js first.
|
|
4
|
+
*
|
|
5
|
+
* D57 lets an initiative declare `outcome_metric` (what should move once it ships) and `outcome_horizon` (the date to check it), and the
|
|
6
|
+
* outcome ledger takes the PM's verdict. On real data almost nothing used it (2026-11-03: 10 of 62 product initiatives named an outcome,
|
|
7
|
+
* 8 had a date, 0 were checked; 3 checks were overdue). The field was optional and nothing asked for it. This module makes Claude ask:
|
|
8
|
+
*
|
|
9
|
+
* scan(root, { today }) → { subjects, missing (newest first), recent (how many of them were edited in the last 30 days), undated, due, pending, disposed, shippedWithout, notRead }
|
|
10
|
+
* every WORK initiative of the repository (document lanes are files, D86) that is not stopped. `missing` is a live one (not accepted)
|
|
11
|
+
* with no outcome_metric; `shippedWithout` counts the accepted ones with none (too late to declare, only counted).
|
|
12
|
+
* drafts(root, S, { today, only? }) → [proposal] ranked: due checks, then undated outcomes, then missing outcomes
|
|
13
|
+
* due ✦ "Check the outcome": the record's metric and date lines cited; the PM says validated | refuted | inconclusive.
|
|
14
|
+
* undated ✦ a check date for a declared metric.
|
|
15
|
+
* missing ✦ an outcome AND a check date. The metric comes from the PRD's success criteria (the first item under a heading that
|
|
16
|
+
* names success, outcome, metric, goal or signal), else from the anchor's line in the OKR files or the health budgets.
|
|
17
|
+
* With neither, there is nothing to cite, so there is no proposal: the item is listed under `ask` for the PM to name.
|
|
18
|
+
* The date is DAYS_AFTER days after the latest due date of the initiative's tasks (that task line cited), else DEFAULT_DAYS from today.
|
|
19
|
+
* Every proposal is the ai-guard shape { id, title, say, confidence, claims: [{ text, sources: [{ label, ref, quote }] }], data }, where
|
|
20
|
+
* ref is path:line from the repository root and quote is that line's text. data = { kind, initiative, metric?, horizon? }: what
|
|
21
|
+
* `pmos outcomes declare` / `record` write once the PM says yes. Nothing here writes.
|
|
22
|
+
*
|
|
23
|
+
* setOutcome lives in lib/initiative-writer.js (the one writer of a record); recordCheck below is the one writer of an outcome line
|
|
24
|
+
* from `pmos outcomes record` (the same ledger kit/scripts/pmos-log.sh outcome and scripts/record-outcome.js append to).
|
|
25
|
+
*/
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
|
|
29
|
+
const VERSION = 'outcomes-1';
|
|
30
|
+
const JOB = 'outcomes';
|
|
31
|
+
const DAY = 86400000;
|
|
32
|
+
const DAYS_AFTER = 30;
|
|
33
|
+
const DEFAULT_DAYS = 90;
|
|
34
|
+
const RECENT_DAYS = 30;
|
|
35
|
+
const DISPOSITIONS = Object.freeze(['validated', 'refuted', 'inconclusive']);
|
|
36
|
+
const HEADING = /^#{2,3}\s+.*\b(success|outcome|metric|goal|signal)/i;
|
|
37
|
+
const ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
38
|
+
|
|
39
|
+
const rel = (root, f) => path.relative(root, f).split(path.sep).join('/');
|
|
40
|
+
const cut = (s, n = 400) => { const t = String(s == null ? '' : s).replace(/\s+$/, ''); return t.length > n ? t.slice(0, n) : t; };
|
|
41
|
+
const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); if (t.length <= n) return t; const c = t.slice(0, n - 1); return (c.slice(0, c.lastIndexOf(' ')) || c).replace(/[,;:\s]+$/, '') + '…'; };
|
|
42
|
+
const isDate = (s) => /^\d{4}-\d{2}-\d{2}$/.test(String(s || ''));
|
|
43
|
+
const addDays = (day, n) => new Date(Date.parse(day + 'T00:00:00Z') + n * DAY).toISOString().slice(0, 10);
|
|
44
|
+
const todayOf = (now = Date.now()) => new Date(now).toISOString().slice(0, 10);
|
|
45
|
+
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
46
|
+
const short = (d) => { const t = new Date(Date.parse(d + 'T00:00:00Z')); return Number.isFinite(t.getTime()) ? t.getUTCDate() + ' ' + MON[t.getUTCMonth()] + ' ' + t.getUTCFullYear() : String(d); };
|
|
47
|
+
const unquote = (v) => { const t = String(v || '').trim(); return /^".*"$/.test(t) ? t.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\') : /^'.*'$/.test(t) ? t.slice(1, -1) : t; };
|
|
48
|
+
|
|
49
|
+
function layoutOf(root) {
|
|
50
|
+
try { const l = require('./console-state').loadEngine(); if (l.ok) return l.engine.layout(root); } catch { /* fall through */ }
|
|
51
|
+
const kit = fs.existsSync(path.join(root, 'pmos', 'state'));
|
|
52
|
+
return { root, kit, initiatives: kit ? path.join(root, 'pmos', 'state', 'initiatives') : path.join(root, 'planning', 'initiatives'), runsDir: kit ? path.join(root, 'pmos', 'state') : path.join(root, 'planning', 'runs') };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function linesOf(file) { try { return fs.readFileSync(file, 'utf8').split('\n'); } catch { return null; } }
|
|
56
|
+
|
|
57
|
+
/** The leading frontmatter as { key: { value, n (1-based line), text } }. */
|
|
58
|
+
function frontOf(L) {
|
|
59
|
+
const out = {};
|
|
60
|
+
if (!L || !/^---\s*$/.test(L[0] || '')) return out;
|
|
61
|
+
for (let i = 1; i < L.length && i < 200; i++) {
|
|
62
|
+
if (/^---\s*$/.test(L[i])) break;
|
|
63
|
+
const m = /^([A-Za-z_][A-Za-z0-9_]*):[^\S\n]*(.*)$/.exec(L[i]);
|
|
64
|
+
if (m && !(m[1] in out)) out[m[1]] = { value: unquote(m[2]), n: i + 1, text: L[i] };
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function jsonRows(file) {
|
|
70
|
+
const L = linesOf(file); if (!L) return [];
|
|
71
|
+
const out = [];
|
|
72
|
+
for (const s of L) { if (!s.trim()) continue; try { const o = JSON.parse(s); if (o && typeof o === 'object') out.push(o); } catch { /* torn */ } }
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
/** A ledger from the runs folder and, in a kit tree, the state folder too (as lib/today.js readLedger does), each file once. */
|
|
76
|
+
function ledger(root, L, name) {
|
|
77
|
+
const files = [...new Set([path.join(L.runsDir, name), path.join(root, 'pmos', 'state', name), path.join(root, 'planning', 'runs', name)])];
|
|
78
|
+
return files.flatMap(jsonRows);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const STOPPED = new Set(['paused', 'cancelled', 'canceled', 'stopped', 'dropped', 'archived', 'killed']);
|
|
82
|
+
function workGroup(root) { try { return require('./initiative-rows.js').workGroupOf(root) || {}; } catch { return {}; } }
|
|
83
|
+
|
|
84
|
+
/** Every subject of the outcome rule in this repository, read from the records and ledgers (no derivation, no git). */
|
|
85
|
+
function scan(root, { today = todayOf() } = {}) {
|
|
86
|
+
root = path.resolve(root);
|
|
87
|
+
const L = layoutOf(root);
|
|
88
|
+
const notRead = [];
|
|
89
|
+
let files = [];
|
|
90
|
+
try { files = fs.readdirSync(L.initiatives).filter((f) => f.endsWith('.md')).sort(); } catch { notRead.push('the initiative records'); }
|
|
91
|
+
const groups = workGroup(root);
|
|
92
|
+
const accepted = new Set();
|
|
93
|
+
for (const a of ledger(root, L, 'acceptances.jsonl')) {
|
|
94
|
+
if (!a || !a.initiative_id) continue;
|
|
95
|
+
const act = String(a.action || '').toLowerCase();
|
|
96
|
+
if (a.accepted === true || ['accept', 'approve', 'merge', 'publish'].includes(act)) accepted.add(String(a.initiative_id));
|
|
97
|
+
}
|
|
98
|
+
const life = new Map();
|
|
99
|
+
for (const r of ledger(root, L, 'lifecycle.jsonl')) if (r && typeof r.initiative_id === 'string' && r.state) life.set(r.initiative_id, String(r.state).toLowerCase());
|
|
100
|
+
let deleted = new Set();
|
|
101
|
+
try { const D = require('./ini-deleted.js'); if (typeof D.idsOf === 'function') deleted = new Set(D.idsOf(root)); } catch { /* none */ }
|
|
102
|
+
const disposedBy = new Map();
|
|
103
|
+
for (const c of ledger(root, L, 'outcome_checks.jsonl')) if (c && c.initiative_id && DISPOSITIONS.includes(c.disposition)) disposedBy.set(String(c.initiative_id), c);
|
|
104
|
+
|
|
105
|
+
const subjects = [], missing = [], undated = [], due = [], pending = [], disposed = [];
|
|
106
|
+
let shippedWithout = 0;
|
|
107
|
+
for (const f of files) {
|
|
108
|
+
const file = path.join(L.initiatives, f);
|
|
109
|
+
const Ls = linesOf(file); if (!Ls) { notRead.push(rel(root, file)); continue; }
|
|
110
|
+
const fm = frontOf(Ls);
|
|
111
|
+
const id = (fm.id && fm.id.value) || f.replace(/\.md$/, '');
|
|
112
|
+
if (!ID_RE.test(id) || deleted.has(id)) continue;
|
|
113
|
+
const lane = (fm.lane && fm.lane.value) || (fm.type && fm.type.value !== 'Initiative' ? fm.type.value : '') || 'feature';
|
|
114
|
+
if (groups[lane] && groups[lane] !== 'work') continue; // a document lane is a file, not a bet (D86)
|
|
115
|
+
const st = life.get(id) || (fm.status && fm.status.value ? '' : '');
|
|
116
|
+
if (STOPPED.has(st)) continue;
|
|
117
|
+
const it = {
|
|
118
|
+
id, lane, title: (fm.title && fm.title.value) || id, record: rel(root, file), fm, accepted: accepted.has(id),
|
|
119
|
+
anchor: fm.parent_kr ? fm.parent_kr.value : '', metric: fm.outcome_metric ? fm.outcome_metric.value : '',
|
|
120
|
+
horizon: fm.outcome_horizon && isDate(fm.outcome_horizon.value) ? fm.outcome_horizon.value : '',
|
|
121
|
+
prd: fm.prd_path ? fm.prd_path.value : '',
|
|
122
|
+
};
|
|
123
|
+
subjects.push(it);
|
|
124
|
+
if (!it.metric) { if (it.accepted) shippedWithout++; else missing.push(it); continue; }
|
|
125
|
+
const d = disposedBy.get(id);
|
|
126
|
+
if (d) disposed.push({ ...it, disposition: d.disposition });
|
|
127
|
+
else if (!it.horizon) undated.push(it);
|
|
128
|
+
else if (it.horizon <= today) due.push(it);
|
|
129
|
+
else pending.push(it);
|
|
130
|
+
}
|
|
131
|
+
due.sort((a, b) => (a.horizon < b.horizon ? -1 : 1));
|
|
132
|
+
// newest first: the record's timestamp is its last edit. `recent` is what was touched in the last RECENT_DAYS days, where a backfill
|
|
133
|
+
// starts (an old record with no acceptance line may well have shipped long ago: D94 merges need none).
|
|
134
|
+
const stamp = (it) => String((it.fm.timestamp || {}).value || '').slice(0, 10);
|
|
135
|
+
missing.sort((a, b) => stamp(b).localeCompare(stamp(a)) || a.id.localeCompare(b.id));
|
|
136
|
+
const since = addDays(today, -RECENT_DAYS);
|
|
137
|
+
const recent = missing.filter((it) => stamp(it) >= since).length;
|
|
138
|
+
return { root, layout: L, today, subjects, recent, missing, undated, due, pending, disposed, shippedWithout, notRead };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* ── sources ──────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
142
|
+
|
|
143
|
+
const src = (root, file, n, text, label) => ({ label, ref: rel(root, file) + ':' + n, quote: cut(String(text).trim()) });
|
|
144
|
+
const recSrc = (S, it, key, label) => { const e = it.fm[key]; return e ? src(S.root, path.join(S.root, it.record), e.n, e.text, label) : null; };
|
|
145
|
+
|
|
146
|
+
function prdFile(root, it) {
|
|
147
|
+
const cands = [];
|
|
148
|
+
if (it.prd) cands.push(path.join(root, it.prd), path.join(root, 'pmos', it.prd));
|
|
149
|
+
cands.push(path.join(root, 'planning', 'prd', it.id + '-prd.md'), path.join(root, 'pmos', 'planning', 'prd', it.id + '-prd.md'));
|
|
150
|
+
for (const f of cands) {
|
|
151
|
+
const abs = path.resolve(f);
|
|
152
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) continue;
|
|
153
|
+
try { if (fs.statSync(abs).isFile()) return abs; } catch { /* next */ }
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A metric line in text: a list item, or a sentence, stripped of its marker and bold. */
|
|
159
|
+
/** The first sentence: a declared outcome is one claim, not the paragraph around it. */
|
|
160
|
+
const firstSentence = (t) => { const m = /^(.{24,}?[.!?])(\s+[A-Z(“"]|\s*$)/.exec(t); return m ? m[1] : t; };
|
|
161
|
+
const metricText = (line) => clip(firstSentence(String(line).replace(/^\s*(?:[-*+]|\d+[.)])\s+/, '').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1').replace(/^R\d+\s*[—–-]\s*/, '').trim()), 220);
|
|
162
|
+
|
|
163
|
+
/** The first item under the PRD's success heading. → { metric, source } | null */
|
|
164
|
+
function prdMetric(root, it) {
|
|
165
|
+
const f = prdFile(root, it); if (!f) return null;
|
|
166
|
+
const Ls = linesOf(f); if (!Ls) return null;
|
|
167
|
+
for (let i = 0; i < Ls.length; i++) {
|
|
168
|
+
if (!HEADING.test(Ls[i])) continue;
|
|
169
|
+
for (let j = i + 1; j < Ls.length && j < i + 40; j++) {
|
|
170
|
+
const t = Ls[j];
|
|
171
|
+
if (/^#{1,3}\s/.test(t)) break;
|
|
172
|
+
if (!t.trim() || /^\s*(>|\||<!--|```)/.test(t) || /^\s*\*[^*].*\*\s*$/.test(t)) continue;
|
|
173
|
+
const m = metricText(t);
|
|
174
|
+
if (m.replace(/[^A-Za-z0-9]/g, '').length < 12) continue;
|
|
175
|
+
return { metric: m, source: src(root, f, j + 1, t, 'PRD: ' + clip(Ls[i].replace(/^#+\s*/, ''), 50)) };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The anchor's own line in the OKR files or the health budgets. → { metric, source } | null */
|
|
182
|
+
function anchorMetric(root, it) {
|
|
183
|
+
const a = String(it.anchor || '').trim();
|
|
184
|
+
// a KR names what should move; a health budget (D59) names only a cap and a review trigger, and a domain names a topic, so neither is cited
|
|
185
|
+
if (!a || /^(domain|health):/.test(a)) return null;
|
|
186
|
+
const token = a;
|
|
187
|
+
const dirs = [path.join(root, 'planning', 'okrs'), path.join(root, 'pmos', 'planning', 'okrs')];
|
|
188
|
+
for (const d of dirs) {
|
|
189
|
+
let fl = []; try { fl = fs.readdirSync(d).filter((x) => x.endsWith('.md')).sort(); } catch { continue; }
|
|
190
|
+
for (const x of fl) {
|
|
191
|
+
const f = path.join(d, x), Ls = linesOf(f) || [];
|
|
192
|
+
const i = Ls.findIndex((l) => l.includes(token) && !/^\s*(type|title|timestamp):/.test(l));
|
|
193
|
+
if (i < 0) continue;
|
|
194
|
+
const text = Ls[i];
|
|
195
|
+
const cells = text.split('|').map((c) => c.trim()).filter(Boolean);
|
|
196
|
+
const words = cells.length > 1 ? cells.filter((c) => !c.includes(token)).sort((p, q) => q.length - p.length)[0] : text.replace(token, '');
|
|
197
|
+
const m = metricText(String(words || '').replace(/^[:\s—–-]+/, ''));
|
|
198
|
+
if (m.replace(/[^A-Za-z0-9]/g, '').length < 12) continue;
|
|
199
|
+
return { metric: 'Moves ' + a + ': ' + m, source: src(root, f, i + 1, text, 'Anchor ' + clip(a, 40)) };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The latest due date among the initiative's tasks, with that task line. → { due, source } | null */
|
|
206
|
+
function taskDue(root, it, tasks) {
|
|
207
|
+
let best = null;
|
|
208
|
+
for (const t of tasks || []) {
|
|
209
|
+
if (t.initiative !== it.id) continue;
|
|
210
|
+
for (const l of t.lines || []) if (typeof l.due === 'string' && isDate(l.due.slice(0, 10)) && l.src && (!best || l.due.slice(0, 10) > best.due)) best = { due: l.due.slice(0, 10), source: { label: 'Task: ' + clip(t.title, 50), ref: l.src.ref, quote: l.src.quote } };
|
|
211
|
+
}
|
|
212
|
+
return best;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function tasksOf(root) {
|
|
216
|
+
try {
|
|
217
|
+
const Facts = require('./drift-facts.js');
|
|
218
|
+
const stateDir = require('./driver.js').stateDirOf(root);
|
|
219
|
+
let storeFile = null; try { const s = require('./task-store.js').storeFor(root); storeFile = s && s.file; } catch { storeFile = null; }
|
|
220
|
+
return Facts.tasksOf(root, stateDir ? path.join(stateDir, 'boards.jsonl') : null, storeFile, []).tasks;
|
|
221
|
+
} catch { return []; }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The check date to propose: DAYS_AFTER after the last task's due date, never sooner than a week from today. */
|
|
225
|
+
function horizonFor(S, it, tasks) {
|
|
226
|
+
const t = taskDue(S.root, it, tasks);
|
|
227
|
+
const floor = addDays(S.today, 7);
|
|
228
|
+
if (t) { const h = addDays(t.due, DAYS_AFTER); return { horizon: h < floor ? floor : h, lastDue: t.due, why: `${DAYS_AFTER} days after its last task is due`, source: t.source }; }
|
|
229
|
+
return { horizon: addDays(S.today, DEFAULT_DAYS), lastDue: null, why: `${DEFAULT_DAYS} days from today, as it has no task with a due date yet`, source: null };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* ── drafts ───────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
233
|
+
|
|
234
|
+
const claim = (text, sources) => ({ text: clip(text, 240), sources: sources.filter(Boolean) });
|
|
235
|
+
|
|
236
|
+
function dueDraft(S, it) {
|
|
237
|
+
const p = prdMetric(S.root, it);
|
|
238
|
+
const claims = [claim(`It expects: ${clip(it.metric, 160)}`, [recSrc(S, it, 'outcome_metric', 'Expected outcome')]),
|
|
239
|
+
claim(`Its check date was ${short(it.horizon)}`, [recSrc(S, it, 'outcome_horizon', 'Check date')])];
|
|
240
|
+
if (p) claims.push(claim('The PRD says what success looks like', [p.source]));
|
|
241
|
+
return {
|
|
242
|
+
id: `outcome-due:${it.id}`, title: `Check the outcome of ${clip(it.title, 80)}`, confidence: 'medium', claims,
|
|
243
|
+
say: `Its check date passed on ${short(it.horizon)}. Did "${clip(it.metric, 120)}" happen? Tell me worked, didn't work or can't tell yet, and what shows it, and I'll record it.`,
|
|
244
|
+
data: { kind: 'due', initiative: it.id, metric: it.metric, horizon: it.horizon },
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function undatedDraft(S, it, tasks) {
|
|
249
|
+
const h = horizonFor(S, it, tasks);
|
|
250
|
+
const claims = [claim(`It expects: ${clip(it.metric, 160)}, with no check date`, [recSrc(S, it, 'outcome_metric', 'Expected outcome')])];
|
|
251
|
+
if (h.source) claims.push(claim(`Its last task is due on ${short(h.lastDue)}`, [h.source]));
|
|
252
|
+
return {
|
|
253
|
+
id: `outcome-date:${it.id}`, title: `Set a check date for ${clip(it.title, 80)}`, confidence: h.source ? 'medium' : 'low', claims,
|
|
254
|
+
say: `It names an outcome but no date to check it, so it can never come due. I suggest checking on ${short(h.horizon)}, ${h.why}.`,
|
|
255
|
+
data: { kind: 'undated', initiative: it.id, metric: it.metric, horizon: h.horizon },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function missingDraft(S, it, tasks) {
|
|
260
|
+
const m = prdMetric(S.root, it) || anchorMetric(S.root, it);
|
|
261
|
+
if (!m) return null; // nothing to cite: the PM names it (proven-only)
|
|
262
|
+
const h = horizonFor(S, it, tasks);
|
|
263
|
+
const claims = [claim(`${m.source.label.startsWith('PRD') ? 'Its PRD' : 'Its anchor'} says: ${m.metric}`, [m.source])];
|
|
264
|
+
if (h.source) claims.push(claim(`Its last task is due on ${short(h.lastDue)}`, [h.source]));
|
|
265
|
+
return {
|
|
266
|
+
id: `outcome-new:${it.id}`, title: `Expected outcome for ${clip(it.title, 80)}`, confidence: claims.length > 1 && m.source.label.startsWith('PRD') ? 'medium' : 'low', claims,
|
|
267
|
+
say: `It has no expected outcome yet. I suggest: "${m.metric}", checked on ${short(h.horizon)} (${h.why}).`,
|
|
268
|
+
data: { kind: 'missing', initiative: it.id, metric: m.metric, horizon: h.horizon },
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The ✦ drafts for this repository, ranked; `only` limits them to one initiative. → { drafts, ask: [{ id, title }] } */
|
|
273
|
+
function drafts(S, { only = null, tasks } = {}) {
|
|
274
|
+
const T = tasks || tasksOf(S.root);
|
|
275
|
+
const pick = (list) => (only ? list.filter((it) => it.id === only) : list);
|
|
276
|
+
const out = [], ask = [];
|
|
277
|
+
for (const it of pick(S.due)) out.push(dueDraft(S, it));
|
|
278
|
+
for (const it of pick(S.undated)) out.push(undatedDraft(S, it, T));
|
|
279
|
+
for (const it of pick(S.missing)) {
|
|
280
|
+
const d = missingDraft(S, it, T);
|
|
281
|
+
if (d) out.push(d); else ask.push({ id: it.id, title: it.title });
|
|
282
|
+
}
|
|
283
|
+
return { drafts: out, ask };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/* ── the outcome ledger: the one writer behind `pmos outcomes record` ───────────────────────────────────────── */
|
|
287
|
+
|
|
288
|
+
/** Append the PM's verdict. Evidence is required for all three (an `inconclusive` with no reason is the cheapest lie). */
|
|
289
|
+
function recordCheck(root, { initiative, disposition, evidence, by, via }, { now = Date.now() } = {}) {
|
|
290
|
+
root = path.resolve(root);
|
|
291
|
+
if (typeof initiative !== 'string' || !ID_RE.test(initiative)) return { ok: false, error: 'name the initiative by its id' };
|
|
292
|
+
if (!DISPOSITIONS.includes(disposition)) return { ok: false, error: `the verdict is one of ${DISPOSITIONS.join(', ')}` };
|
|
293
|
+
const ev = String(evidence || '').replace(/\s+/g, ' ').trim();
|
|
294
|
+
if (ev.length < 12) return { ok: false, error: 'say what shows it (--evidence, at least 12 characters): a number, a link or what you saw' };
|
|
295
|
+
if (ev.length > 1000) return { ok: false, error: 'keep the evidence under 1000 characters' };
|
|
296
|
+
const S = scan(root, { today: todayOf(now) });
|
|
297
|
+
const it = S.subjects.find((x) => x.id === initiative);
|
|
298
|
+
if (!it) return { ok: false, error: `no initiative ${initiative} here (or it is stopped)` };
|
|
299
|
+
if (!it.metric) return { ok: false, error: `${initiative} declares no expected outcome yet: declare it first (pmos outcomes declare)` };
|
|
300
|
+
const dir = S.layout.runsDir;
|
|
301
|
+
const file = path.join(dir, 'outcome_checks.jsonl');
|
|
302
|
+
try {
|
|
303
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
304
|
+
const st = (() => { try { return fs.lstatSync(file); } catch { return null; } })();
|
|
305
|
+
if (st && !st.isFile()) return { ok: false, error: 'the outcome ledger is not a plain file — nothing was saved' };
|
|
306
|
+
const line = { initiative_id: initiative, disposition, evidence: ev, date: todayOf(now), at: new Date(now).toISOString(), by: by || 'agent:unknown', via: via || 'cli' };
|
|
307
|
+
fs.appendFileSync(file, JSON.stringify(line) + '\n');
|
|
308
|
+
return { ok: true, file: rel(root, file), line };
|
|
309
|
+
} catch (e) { return { ok: false, error: 'the outcome ledger could not be written: ' + String((e && e.message) || e).slice(0, 160) }; }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = { VERSION, JOB, DAYS_AFTER, DEFAULT_DAYS, DISPOSITIONS, scan, drafts, prdMetric, anchorMetric, horizonFor, recordCheck, todayOf, short, frontOf };
|
|
@@ -99,6 +99,9 @@ const VERBS = Object.freeze({
|
|
|
99
99
|
// Not a pm-os-absorb verb (run: null): initiative weekly-drift-check added it through the same table.
|
|
100
100
|
drift: Object.freeze({ module: 'drift-cli.js', run: null, guide: 'what-you-missed.md',
|
|
101
101
|
help: 'pmos drift show|replay|status — the weekly check for quiet drift: what it would show, its replay on your real records, and its last result (pmos drift help)' }),
|
|
102
|
+
// Not a pm-os-absorb verb (run: null): initiative outcome-for-every-initiative added it through the same table.
|
|
103
|
+
outcomes: Object.freeze({ module: 'outcomes-cli.js', run: null, guide: 'outcomes.md',
|
|
104
|
+
help: 'pmos outcomes list|propose|declare|record — every initiative\'s expected outcome and check date: Claude\'s cited drafts, the PM\'s yes, and the verdict when a check is due (pmos outcomes help)' }),
|
|
102
105
|
driver: Object.freeze({ module: 'driver-cli.js', run: null, guide: 'initiative-driver.md',
|
|
103
106
|
help: 'pmos driver propose|status|keep|return|drop|pause|resume|mode … — the Initiative Driver: drafts for you to keep, send back or drop (pmos driver help)' }),
|
|
104
107
|
// Not a pm-os-absorb verb (run: null): initiative horizon-scan added it through the same table; horizon-scan-portfolio added section, propose, setting and --boards.
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
* dates one proposal per stage: its late and at-risk tasks to new dates and its plan with them, plus what a moved task's links reach
|
|
16
16
|
* status "looks done": the evidence says the work happened
|
|
17
17
|
* names tidy names, before → after; the detail cut moves to the task's notes
|
|
18
|
+
* outcome the expected outcome and its check date (D57; lib/outcomes.js, filed by lib/outcome-props.js): type new (no outcome yet) or date
|
|
19
|
+
* (an outcome with no date). data { metric, horizon }; one item { id: 'outcome', title: the metric, due: the date }. Always section
|
|
20
|
+
* 'check': its own ✓, never Keep all. ✓ writes the record's outcome_metric and outcome_horizon lines (lib/initiative-writer.js)
|
|
18
21
|
* grill what Claude found when it grilled the initiative by itself (lib/grill.js): type = the angle, no items, a next step in data.next;
|
|
19
22
|
* always section 'check' (its own ✓, never Keep all). 3 show, best rank first; the group's `more` counts the rest
|
|
20
23
|
*
|
|
@@ -53,8 +56,8 @@ const crypto = require('crypto');
|
|
|
53
56
|
|
|
54
57
|
const LEDGER = 'proposals.jsonl';
|
|
55
58
|
const KINDS = new Set(['epoch', 'look', 'src', 'end', 'propose', 'drop', 'keep', 'undo']);
|
|
56
|
-
const GROUPS = ['found', 'dates', 'status', 'names'];
|
|
57
|
-
const TYPES = { found: ['stage', 'docs', 'tasks', 'link', 'maybe', 'unplaced'], dates: ['stage', 'task'], status: ['done'], names: ['tidy'] };
|
|
59
|
+
const GROUPS = ['found', 'dates', 'status', 'names', 'outcome'];
|
|
60
|
+
const TYPES = { found: ['stage', 'docs', 'tasks', 'link', 'maybe', 'unplaced'], dates: ['stage', 'task'], status: ['done'], names: ['tidy'], outcome: ['new', 'date'] };
|
|
58
61
|
const SOURCES = ['drive', 'docs', 'sheets', 'slides', 'jira', 'calendar', 'notes', 'mail', 'slack', 'confluence', 'chat', 'repo'];
|
|
59
62
|
const SOURCE_NAMES = { drive: 'Drive and Slides', docs: 'Docs', sheets: 'Sheets', slides: 'Slides', jira: 'Jira', calendar: 'Calendar and meeting notes', notes: 'Meeting notes',
|
|
60
63
|
mail: 'Gmail', slack: 'Slack', confluence: 'Confluence', chat: 'Chat', repo: 'This repository' };
|
|
@@ -145,7 +148,14 @@ function clean(group, type, input) {
|
|
|
145
148
|
if (input.since != null) { if (!DAY_RE.test(String(input.since))) return { error: 'since is YYYY-MM-DD' }; data.since = String(input.since); }
|
|
146
149
|
}
|
|
147
150
|
if (group === 'dates' && str(input.stage)) data.stage = one(input.stage, 60);
|
|
148
|
-
|
|
151
|
+
if (group === 'outcome') {
|
|
152
|
+
const d = isObj(input.data) ? input.data : {};
|
|
153
|
+
const metric = one(d.metric, 300), horizon = str(d.horizon);
|
|
154
|
+
if (metric.length < 8) return { error: 'data.metric is what should move once it ships' };
|
|
155
|
+
if (!DAY_RE.test(horizon)) return { error: 'data.horizon is the check date, YYYY-MM-DD' };
|
|
156
|
+
data.metric = metric; data.horizon = horizon;
|
|
157
|
+
}
|
|
158
|
+
return { prop: { group, type, section: group === 'outcome' ? 'check' : section, title: one(input.title, LIM.title) || null, why, sources: s.v, items, data } };
|
|
149
159
|
}
|
|
150
160
|
/** The proposal's own id: one per initiative, group, type and key, so a newer run files over the older one. */
|
|
151
161
|
const idOf = (ini, group, type, key) => 'pr-' + hash([ini, group, type, key || ''].join('|'));
|
|
@@ -313,7 +323,7 @@ function datesOf(ini, T, o = {}) {
|
|
|
313
323
|
}
|
|
314
324
|
|
|
315
325
|
/* ── what r.props carries ── */
|
|
316
|
-
const ORDER = { found: 0, dates: 1, status: 2, names: 3, links: 4, grill: 5 };
|
|
326
|
+
const ORDER = { outcome: -1, found: 0, dates: 1, status: 2, names: 3, links: 4, grill: 5 };
|
|
317
327
|
const GRILL_SHOWN = 3;
|
|
318
328
|
/**
|
|
319
329
|
* One initiative's proposals, as the ✦ and the Viewer draw them, or null when there is nothing to show.
|
|
@@ -133,6 +133,11 @@ function applyOne(ctx, p, g, f, ini, x, choices, outbox) {
|
|
|
133
133
|
if (was) { TL.append(dir, g.actor, { kind: 'unlink', from: l.from, to: l.to }); add({ op: 'unlink', from: l.from, to: l.to, link: { type: was.type, lag: was.lag, source: was.source } }); }
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
|
+
} else if (x.group === 'outcome') {
|
|
137
|
+
// the expected outcome and its check date (lib/outcomes.js): ✓ writes the record's two D57 lines, Undo puts back what was there
|
|
138
|
+
const d = x.data || {};
|
|
139
|
+
const r = require('./initiative-writer.js').setOutcome(p.w.root, ini, { metric: d.metric, horizon: d.horizon }, { actor: g.actor });
|
|
140
|
+
if (r.ok && !r.same) add({ op: 'outcome', from: r.from, to: { metric: r.metric, horizon: r.horizon } });
|
|
136
141
|
} else if (x.group === 'grill') {
|
|
137
142
|
// a grill finding (lib/grill.js): ✓ adds its next step as a task, with the finding and its sources in the notes
|
|
138
143
|
// ✎ on the finding: the PM's own words for the next step (choices[id]) replace Claude's
|
|
@@ -296,6 +301,7 @@ const WRITES = {
|
|
|
296
301
|
else if (w.op === 'prd') { try { const file = path.resolve(p.w.root, w.file), text = fs.readFileSync(file, 'utf8'); if (text.includes(w.to)) { fs.writeFileSync(file, text.split(w.to).join(w.from)); back++; } else kept++; } catch { kept++; } continue; }
|
|
297
302
|
else if (w.op === 'doc' || w.op === 'linked' || w.op === 'ran') { back++; continue; } // the fold leaves an undone batch out
|
|
298
303
|
else if (w.op === 'outbox') { kept++; continue; } // a drafted comment waits in the outbox, where the PM drops it
|
|
304
|
+
else if (w.op === 'outcome') { const r2 = require('./initiative-writer.js').restoreOutcome(p.w.root, k.ini, w.from || {}, w.to || {}, { actor: g.actor }); if (r2.ok && !r2.kept) back++; else kept++; continue; } // a record edited since keeps its edit
|
|
299
305
|
if (r && r.body && r.body.ok) back++; else kept++;
|
|
300
306
|
}
|
|
301
307
|
PR.append(dir, g.actor, { kind: 'undo', batch: id });
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.509.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.509.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/sdk": "^1.3.0",
|
package/workspace/package.json
CHANGED
|
@@ -27,6 +27,7 @@ const I = {
|
|
|
27
27
|
doc: 'M7 3h7l5 5v13H7zM14 3v5h5', tasks: 'M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01', stage: 'M5 21V4M5 4h12l-2 4 2 4H5',
|
|
28
28
|
link: 'M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1',
|
|
29
29
|
cal: 'M4 5h16v15H4zM8 3v4M16 3v4M4 10h16', done: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M8 12l3 3 5-6', name: 'M4 20h4L19 9l-4-4L4 16zM13 7l4 4',
|
|
30
|
+
goal: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10M12 11a1 1 0 1 0 0 2 1 1 0 0 0 0-2',
|
|
30
31
|
ask: 'M4 5h16v11H9l-5 4z', maybe: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6M12 17h.01', keep: 'M5 12l5 5 9-10', ignore: 'M5 12h14',
|
|
31
32
|
};
|
|
32
33
|
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
@@ -35,7 +36,7 @@ const md = (iso) => { const m = DAY.exec(String(iso || '')); return m ? MON[+m[2
|
|
|
35
36
|
const span = (a, b) => (a && b && a !== b ? md(a) + ' – ' + md(b) : md(a || b));
|
|
36
37
|
const plural = (n, a, b) => n + ' ' + (n === 1 ? a : b);
|
|
37
38
|
const clip = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
|
|
38
|
-
const GROUP = { found: 'Found elsewhere', dates: 'Dates', status: 'Looks done', names: 'Names', links: 'Links', grill: 'Claude grilled it' };
|
|
39
|
+
const GROUP = { outcome: 'Expected outcome', found: 'Found elsewhere', dates: 'Dates', status: 'Looks done', names: 'Names', links: 'Links', grill: 'Claude grilled it' };
|
|
39
40
|
|
|
40
41
|
/* ── the chip and the live look ── */
|
|
41
42
|
export function chipOf(p) {
|
|
@@ -93,9 +94,10 @@ const titleOf = (p) => {
|
|
|
93
94
|
if (p.group === 'found') return p.type === 'stage' ? 'Stage: ' + ((p.data && p.data.stage) || '') : p.type === 'docs' ? plural(n, 'document', 'documents') : p.type === 'tasks' ? plural(n, 'task', 'tasks') : p.type === 'link' ? 'Already in another initiative' : p.type === 'maybe' ? 'Maybe this initiative' : plural(n, 'thing', 'things') + ' not placed';
|
|
94
95
|
if (p.group === 'status') return plural(n, 'task looks', 'tasks look') + ' done';
|
|
95
96
|
if (p.group === 'names') return 'Tidy ' + plural(n, 'name', 'names');
|
|
97
|
+
if (p.group === 'outcome') return p.type === 'date' ? 'A date to check the outcome' : 'What should change once it ships';
|
|
96
98
|
return '';
|
|
97
99
|
};
|
|
98
|
-
const iconOf = (p) => (p.group === 'found' ? (p.type === 'stage' ? I.stage : p.type === 'docs' ? I.doc : p.type === 'tasks' ? I.tasks : p.type === 'link' ? I.link : I.maybe)
|
|
100
|
+
const iconOf = (p) => (p.group === 'outcome' ? I.goal : p.group === 'found' ? (p.type === 'stage' ? I.stage : p.type === 'docs' ? I.doc : p.type === 'tasks' ? I.tasks : p.type === 'link' ? I.link : I.maybe)
|
|
99
101
|
: p.group === 'dates' ? I.cal : p.group === 'status' ? I.done : p.group === 'names' ? I.name : I.link);
|
|
100
102
|
const ib = (attr, label, icon, cls = '') => `<button type="button" class="pp-ib${cls ? ' ' + cls : ''}" ${attr} aria-label="${esc(label)}" title="${esc(label)}">${SVG(icon, 16, 2.2)}</button>`;
|
|
101
103
|
function itemHtml(p, x) {
|
|
@@ -107,6 +109,7 @@ function itemHtml(p, x) {
|
|
|
107
109
|
else { t = x.title; m = x.other ? 'In ' + x.other.title : x.why || ''; }
|
|
108
110
|
} else if (p.group === 'dates') { t = x.title || x.task; m = (x.from && x.from.due ? md(x.from.due) + ' → ' : '') + span(x.to && x.to.start, x.to && x.to.due) + (x.jira ? ' · Jira: a drafted comment' : '') + (x.ms ? ' · ◇ a promise' : ''); }
|
|
109
111
|
else if (p.group === 'status') { t = x.title || taskTitle(p.ini, x.task); m = x.jira ? 'Jira: a drafted comment' : ''; }
|
|
112
|
+
else if (p.group === 'outcome') { t = x.title; m = x.due ? 'Check on ' + md(x.due) : ''; }
|
|
110
113
|
else if (p.group === 'names') { t = x.to; m = 'Was: ' + (x.from || '') + (x.jira ? ' · Jira: a drafted comment' : ''); }
|
|
111
114
|
return `<div class="pp-it"><span class="w"><b>${esc(clip(t, 90))}</b>${m ? `<small>${esc(m)}</small>` : ''}</span>${x.sig ? ib(`data-pp-dropit="${esc(x.sig)}"`, 'Leave this one out', I.x) : ''}</div>`;
|
|
112
115
|
}
|
package/workspace/run-tests.js
CHANGED
|
@@ -445,8 +445,10 @@ function traceChanges(changes, ctx) {
|
|
|
445
445
|
return w;
|
|
446
446
|
};
|
|
447
447
|
const users1 = (f) => {
|
|
448
|
-
// a path or a file name: './x', '/x.js' or 'x.js'. A bare quoted word ('grill') is a string, not a use of lib/grill.js
|
|
449
|
-
|
|
448
|
+
// a path or a file name: './x', '../lib/x', 'lib/x', '/x.js' or 'x.js'. A bare quoted word ('grill') is a string, not a use of lib/grill.js,
|
|
449
|
+
// and neither is a folder path that ends in the same word ('memory/proposals' in lib/headless.js is not lib/proposals.js: it sent every
|
|
450
|
+
// change of proposals.js to every suite).
|
|
451
|
+
const re = new RegExp(`[/'"\`]${reEsc(stemOf(f))}\\.m?js['"\`]|(?:\\.\\.?|\\blib)/(?:[\\w.-]+/)*${reEsc(stemOf(f))}['"\`]`);
|
|
450
452
|
const pathRe = new RegExp(`/${reEsc(stemOf(f))}(?:\\.m?js)?['"\`]`);
|
|
451
453
|
let n = 0;
|
|
452
454
|
for (const g of code) {
|
package/workspace/server.js
CHANGED
|
@@ -3092,6 +3092,20 @@ const driftRuns = require('./lib/drift-runs.js').create({
|
|
|
3092
3092
|
every: TICK_MS, // its own tick, as often as the server's; only the server that owns the schedules runs it
|
|
3093
3093
|
mayRun: () => { try { return scheduleOwner.mayRun(); } catch { return true; } },
|
|
3094
3094
|
warn: (what, e) => warnWriteFail(what, e) });
|
|
3095
|
+
// outcome-for-every-initiative (D57): once a day Claude drafts each missing expected outcome and check date under the ✦ of the initiative,
|
|
3096
|
+
// from the success criteria of the PRD or the KR (lib/outcome-props.js, lib/outcomes.js). No session and no model; through the shared guard,
|
|
3097
|
+
// its audit in the runtime folder. A ✓ of the PM writes the record. Off with PMOS_OUTCOMES=off or pmos ai off outcomes.
|
|
3098
|
+
const outcomeProps = require('./lib/outcome-props.js').create({
|
|
3099
|
+
workspaces: () => [...workspaces.values()],
|
|
3100
|
+
isLead: (w) => isPlanNeedsTree(w),
|
|
3101
|
+
stateDirFor: (w) => stateDirFor(w),
|
|
3102
|
+
boardOf: (w, id) => require('./lib/routes-boards.js').boardIndex(proposalCtx(), w).of(id),
|
|
3103
|
+
runtime: () => STATE,
|
|
3104
|
+
open: (now) => !proactiveLib.inQuietHours(now, proSettings().quiet),
|
|
3105
|
+
env: process.env,
|
|
3106
|
+
every: TICK_MS,
|
|
3107
|
+
mayRun: () => { try { return scheduleOwner.mayRun(); } catch { return true; } },
|
|
3108
|
+
warn: (what, e) => warnWriteFail(what, e) });
|
|
3095
3109
|
// batch 16 item 6 ("Claude runs the OKRs"): Claude reads each goal with its recipe (lib/metric-read-runs.js) when its cadence comes round, files the
|
|
3096
3110
|
// value with its source and time, says honestly what it could not read and drafts the fix. A broken limit opens one alert: a bell line only when
|
|
3097
3111
|
// the goal's notify is push. At most 2 runs at once, one per product. PMOS_METRIC_READ=off.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "outcome-for-every-initiative: every initiative has an expected outcome and a check date. The scan, the cited drafts, the guard and its replay, the daily filer under the ✦, the record writers, pmos outcomes, check-outcomes --root and ✓ / Undo through the routes (lib/outcomes.js, lib/outcome-props.js, lib/outcomes-cli.js, lib/initiative-writer.js setOutcome, lib/proposals.js group outcome, lib/routes-proposals.js).",
|
|
3
|
+
"modules": ["outcomes.test.js"],
|
|
4
|
+
"seconds": { "outcomes.test.js": 8 }
|
|
5
|
+
}
|