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 +33 -27
- 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/board-programs-test.js +3 -0
- package/workspace/changelog.json +32 -0
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/outcomes.md +39 -0
- package/workspace/guide/programs.md +2 -1
- package/workspace/guide/settings.md +2 -2
- package/workspace/lib/initiative-cli.js +3 -1
- package/workspace/lib/initiative-writer.js +52 -1
- package/workspace/lib/kit-update.js +16 -4
- 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/bd-prog.js +10 -5
- package/workspace/public/pm/cozy-board.css +8 -2
- package/workspace/public/pm/ini-props.js +5 -2
- package/workspace/public/pm/premium-board.js +4 -0
- package/workspace/public/pm/readonly.js +3 -1
- package/workspace/public/pm/upd-weeks.js +6 -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
|
@@ -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.510.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.510.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/sdk": "^1.3.0",
|
package/workspace/package.json
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* readyHtml(m, whole) a milestone's readiness as small diamonds, one per initiative it waits on
|
|
11
11
|
* mergeBoards(list) All products: one Board model from every product's model, each row keeping its own product
|
|
12
12
|
* bandHtml(g, st, o) the stage track's program row: tile, title, products, your count, its spread, its next milestones
|
|
13
|
-
* prodHtml(g, o) · subHtml(name) a product group's head row (tile
|
|
14
|
-
* prodCell(g, dots) the head's fold
|
|
13
|
+
* prodHtml(g, o) · subHtml(name) a product group's head row (tile and name open the product, goal dots, chevron folds) and a program's product sub-row
|
|
14
|
+
* prodCell(g, dots) the head's open (tile, name) and fold buttons alone (the timeline's product lane, batch 11 item 17); dots replace the goal dots
|
|
15
15
|
* goalsFrom(fn) · goalsHtml(name) the goal dots of a product head, from Home's source (pm/home-list.js goalsOf); fn(name) gives its rows
|
|
16
16
|
*
|
|
17
17
|
* A program has no stage of its own (PM, 2026-10-23): the band shows only what its initiatives say. With one product picked, the program shows
|
|
@@ -173,9 +173,14 @@ export function prodHtml(g, cls = 'bt-prd') {
|
|
|
173
173
|
/** The fold button of a product head alone: the timeline's product lane (batch 11 item 17, PlmA1Board) puts it in the name cell, its track beside it. */
|
|
174
174
|
export function prodCell(g, dots) {
|
|
175
175
|
// the calm timeline (batch 13 item 4) passes its own dots: none while open, one per initiative off track while folded
|
|
176
|
+
// the tile and name open the product's page in the Viewer (batch 26 item 3, as Home's header and a program's title do); the rest folds
|
|
176
177
|
const open = !folded(g.key), gl = dots !== undefined ? dots : open ? goalsHtml(g.name) : '';
|
|
177
|
-
return `<button type="button" class="bd-
|
|
178
|
+
return `<span class="bd-prdb">${prodOpen(g, 'bd-prdo')}<button type="button" class="bd-prdf" data-fold="${esc(g.key)}" aria-expanded="${open}" aria-label="${esc(g.name)}, ${g.rows.length} ${plural(g.rows.length, 'initiative', 'initiatives')}. ${open ? 'Fold' : 'Show'}" title="${open ? 'Fold' : 'Show'}">${gl}${open || dots !== undefined ? '' : `<span class="n">${g.rows.length}</span>`}${SV(open ? CHEV_D : CHEV_R)}</button></span>`;
|
|
178
179
|
}
|
|
180
|
+
/** A product's workspace, from its rows (All products: each row keeps its own); '' when the rows carry none (the page uses its own board's). */
|
|
181
|
+
export const prodWs = (g) => ((g.rows || []).find((r) => r && r.ws) || {}).ws || '';
|
|
182
|
+
/** A product head's tile and name as one button: it opens that product's page in the Viewer (pm/premium-board.js data-prodopen). */
|
|
183
|
+
export const prodOpen = (g, cls) => `<button type="button" class="${cls}" data-prodopen="${esc(g.name)}" data-prodws="${esc(prodWs(g))}" aria-label="Open ${esc(g.name)}, a product">${tileHtml(g.name)}<span>${esc(g.name)}</span></button>`;
|
|
179
184
|
/** The goal dots of a product's head (PgBoard .gd), from the same source as Home's product header (pm/home-list.js goalsOf): one per KR or
|
|
180
185
|
* budget its open initiatives anchor to, green on track, amber at risk. With no goal anchored, nothing is drawn. The page sets the rows. */
|
|
181
186
|
let ROWS_OF = null;
|
|
@@ -191,11 +196,11 @@ export const subHtml = (name, cls = 'bt-psb') => `<div class="${cls}" role="row"
|
|
|
191
196
|
export const memberIcon = (programs, id) => { const p = (programs || []).find((x) => x.id === id); return p ? `<span class="bd-pgi" role="img" aria-label="In ${esc(p.title)}" title="In ${esc(p.title)}">${SV(PROG)}</span>` : ''; };
|
|
192
197
|
|
|
193
198
|
/** A Kanban swimlane's head (PgKanban .lh): a program's tile, title, your count and its next milestones merged into one counted diamond with
|
|
194
|
-
* the readiness of the first; a product's tile and name. Both fold with the chevron; the
|
|
199
|
+
* the readiness of the first; a product's tile and name. Both fold with the chevron; the title opens the program or the product. */
|
|
195
200
|
export function laneHtml(g, st) {
|
|
196
201
|
const open = !folded(g.key);
|
|
197
202
|
const fb = (label) => `<button type="button" class="bd-ib sm bd-fold" data-fold="${esc(g.key)}" aria-expanded="${open}" aria-label="${open ? 'Fold' : 'Show'} ${esc(label)}" title="${open ? 'Fold' : 'Show'}">${SV(open ? CHEV_D : CHEV_R)}</button>`;
|
|
198
|
-
if (g.kind !== 'program') return
|
|
203
|
+
if (g.kind !== 'program') return `${prodOpen(g, 'bd-lho')}<span class="bd-sp"></span>${open ? goalsHtml(g.name) : ''}${fb(g.name)}`;
|
|
199
204
|
const p = g.program, c = nextCluster(st);
|
|
200
205
|
const ms = c ? `<span class="bd-pm2${c.late ? ' late' : ''}" title="${esc(c.items.map((m) => m.name + (m.day ? ' ' + m.day : '')).join(' · '))}"><i class="bd-dm"></i><b>${esc(c.label)}</b>${esc(c.range)}${readyHtml(c.first, whole(st))}</span>` : '';
|
|
201
206
|
return `<button type="button" class="bd-lho" data-prog="${esc(p.id)}" aria-haspopup="dialog" aria-label="${esc(p.title)}, a program. Open the program">${progTile()}<span>${esc(p.title)}</span></button>${yoursHtml(st)}<span class="bd-sp"></span>${ms}${fb(p.title)}`;
|