create-pmos 0.505.0 → 0.507.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 +45 -27
- package/payload/AGENTS.md +2 -0
- 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 +44 -0
- package/workspace/fixtures/decision-pairs.js +94 -0
- package/workspace/fixtures/rehearse-replay.json +159 -0
- package/workspace/guide/ask-the-past.md +46 -0
- package/workspace/guide/claude-proposes.md +29 -2
- package/workspace/guide/index.md +3 -0
- package/workspace/guide/rehearse.md +52 -0
- package/workspace/guide/what-you-missed.md +42 -0
- package/workspace/lib/ai-cli.js +63 -0
- package/workspace/lib/ai-guard.js +314 -0
- package/workspace/lib/briefing-premium-agent.js +3 -0
- package/workspace/lib/decision-ledger.js +236 -0
- package/workspace/lib/decision-memory.js +237 -0
- package/workspace/lib/drift-cli.js +98 -0
- package/workspace/lib/drift-facts.js +151 -0
- package/workspace/lib/drift-runs.js +149 -0
- package/workspace/lib/drift.js +227 -0
- package/workspace/lib/grill-cli.js +22 -1
- package/workspace/lib/grill-runs.js +20 -3
- package/workspace/lib/grill.js +15 -8
- package/workspace/lib/past-chat.js +32 -0
- package/workspace/lib/past-cli.js +147 -0
- package/workspace/lib/past-repeat.js +126 -0
- package/workspace/lib/past.js +353 -0
- package/workspace/lib/pmos-verbs.js +12 -0
- package/workspace/lib/proposal-runs.js +20 -0
- package/workspace/lib/rehearse-cli.js +102 -0
- package/workspace/lib/rehearse-guard.js +81 -0
- package/workspace/lib/rehearse-read.js +159 -0
- package/workspace/lib/rehearse.js +279 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-drift.js +62 -0
- package/workspace/lib/settings-store.js +6 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/grill-words.js +4 -2
- package/workspace/public/pm/home-app.js +14 -2
- package/workspace/public/pm/home-drift.js +76 -0
- package/workspace/server.js +19 -0
- package/workspace/skills/grill.skill +4 -2
- package/workspace/test-files.json +1 -1
- package/workspace/test-lists/ask-the-past.json +5 -0
- package/workspace/test-lists/decision-memory.json +6 -0
- package/workspace/test-lists/plan-rehearsal.json +5 -0
- package/workspace/test-lists/weekly-drift-check.json +5 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/* grill-runs.js — Claude's runs that grill an initiative by themselves (Grill and Forecast, part G-A; lib/grill.js says what and when).
|
|
3
3
|
*
|
|
4
4
|
* create(deps) → { tick(opts), start({ ws, board, ini, why, key, detail, now }), stop(run), active() }
|
|
5
|
-
* promptOf(skill, o) → the prompt of one run. o: { run, why, detail, ini, title, intent, stage, stages, today, open, dropped, budget }
|
|
5
|
+
* promptOf(skill, o) → the prompt of one run. o: { run, why, detail, ini, title, intent, stage, stages, today, open, dropped, budget, decisions }
|
|
6
6
|
*
|
|
7
7
|
* ONE RUN KIND (headless kind `grill`, origin `grill:<ws>:<initiative>:<run>`). The run reads the repository, the stored reads, the
|
|
8
8
|
* stored research and meeting notes, and files with `pmos grill find`, pinned by the server to this initiative and run (PMOS_GRILL_FILE,
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* deps: { workspaces() → [w], isLead(w), stateDirFor(w), runtimeDir(), rowOf(w, id) → { title, intent, stage, stages } | null,
|
|
13
13
|
* exits(w) → [{ ini, board, stage }] (stages about to end), shapes(w) → [{ ini, board, n, end }] (open tasks, latest due),
|
|
14
14
|
* looks(w) → [{ ini, board, n, end }] (running initiatives with open tasks, for the monthly look),
|
|
15
|
+
* plans(w) → [{ ini, board, path, sig }] (optional: each initiative's PRD and a signature of its text, for the `plan` trigger;
|
|
16
|
+
* without it, lib/decision-memory.js plansIn reads the records of the initiatives looks(w) returns),
|
|
15
17
|
* goalInis(w, goalId) → [{ id, endAt, status }] (the initiatives anchored to that goal), checks(w, day) → [{ ini, board, tasks }] (late or at-risk work), turned(dir, sinceIso) → [forecast `turned` line] (lib/forecast.js),
|
|
16
18
|
* api { available(env), run({ prompt, root, env, budget, signal }) → Promise<{ ok, code, costUsd, note?, stopped? }> } (lib/grill-api.js; optional),
|
|
17
19
|
* setting(key) (lib/settings-store.js get), open(now) → bool, headlessOff() → bool, env, promptFor(skillId), spawn(spec), stop(session), warn }
|
|
@@ -28,6 +30,7 @@ const WHY = {
|
|
|
28
30
|
exit: 'The initiative is about to leave its stage. Grill it before it does: what would make the next stage fail?',
|
|
29
31
|
forecast: 'Its forecast just turned. Grill it: why, and what would bring it back?',
|
|
30
32
|
move: 'Its scope or dates moved a lot. Grill it: is the plan still the right plan?',
|
|
33
|
+
plan: 'Its PRD changed. Check the new plan against the decisions that stand (the decision angle) first, then grill it.',
|
|
31
34
|
monthly: 'The monthly look at running work (late work first, then the initiatives grilled longest ago). Grill it.',
|
|
32
35
|
ask: 'The PM asked for it in the initiative\'s chat. Grill it now: what would make it fail, and what is the next step?',
|
|
33
36
|
};
|
|
@@ -39,6 +42,7 @@ function promptOf(skill, o) {
|
|
|
39
42
|
+ `- Its workflow's stages, in order:\n${list((o.stages || []).map((s) => s.key + ' · ' + s.name), 'not known')}\n`
|
|
40
43
|
+ `- Open findings already under its ✦ (do not file them again):\n${list(o.open, 'none')}\n`
|
|
41
44
|
+ `- Dropped by the PM (do not file these again unless the evidence is new):\n${list(o.dropped, 'none')}\n`
|
|
45
|
+
+ (o.decisions && o.decisions.length ? `- The decisions that stand (for the decision angle; Read the line for a decision's words):\n${list(o.decisions, 'none')}\n` : '')
|
|
42
46
|
+ `- Your budget: $${o.budget}. Stop when you have your findings.\n\n`
|
|
43
47
|
+ 'File with `pmos grill find` only (the server pins this initiative and run). Send nothing.';
|
|
44
48
|
}
|
|
@@ -101,9 +105,12 @@ function create(deps) {
|
|
|
101
105
|
const mine = [...P.props.values()].filter((p) => p.ini === ini && p.group === 'grill');
|
|
102
106
|
const dropped = mine.filter((p) => P.drops.has(PR.sigOf(p))).slice(0, 20).map((p) => one(p.title, 120));
|
|
103
107
|
const open = mine.filter((p) => !P.drops.has(PR.sigOf(p)) && !kept.has(p.id)).slice(0, 20).map((p) => one(p.title, 120));
|
|
104
|
-
|
|
108
|
+
// decision memory: the decisions that stand go in the prompt, so the run checks the plan against all of them, not a search's hits
|
|
109
|
+
let decisions = [];
|
|
110
|
+
try { const DL = require('./decision-ledger.js'), L = w.root ? DL.load(w.root) : null; decisions = L ? DL.index(L) : []; } catch (e) { warn('grill decisions', e); }
|
|
111
|
+
const prompt = promptOf(skill, { run, why, detail, ini, title: row.title || ini, intent: row.intent || '', stage: row.stage || '', stages: row.stages || [], today: localDay(now), open, dropped, budget: b.budget, decisions });
|
|
105
112
|
active.set(run, { session: null });
|
|
106
|
-
const env = { PMOS_GRILL_FILE: PR.ledgerFile(dir), PMOS_GRILL_LOG: G.logFile(rt), PMOS_GRILL_WS: ws, PMOS_GRILL_BOARD: board || '', PMOS_GRILL_INI: ini, PMOS_GRILL_RUN: run, PMOS_GRILL_BUDGET: String(b.budget) };
|
|
113
|
+
const env = { PMOS_GRILL_FILE: PR.ledgerFile(dir), PMOS_GRILL_LOG: G.logFile(rt), PMOS_GRILL_ROOT: w.root || '', PMOS_GRILL_WS: ws, PMOS_GRILL_BOARD: board || '', PMOS_GRILL_INI: ini, PMOS_GRILL_RUN: run, PMOS_GRILL_BUDGET: String(b.budget) };
|
|
107
114
|
// With an API key the run goes to the Claude API (lib/grill-api.js); without one it stays a background agent session
|
|
108
115
|
if (deps.api && deps.api.available(deps.env)) {
|
|
109
116
|
const ctl = new AbortController();
|
|
@@ -158,6 +165,16 @@ function create(deps) {
|
|
|
158
165
|
const what = G.moved(prev, cur);
|
|
159
166
|
if (what) out.push({ ws: w.id, board: s.board, ini: s.ini, why: 'move', key: `move:${w.id}:${s.ini}:${cur.n}:${cur.end}`, detail: what });
|
|
160
167
|
}
|
|
168
|
+
// decision memory: a PRD that changed since the last look is checked against the decisions that stand. The first look only records it
|
|
169
|
+
// the PRDs come from deps.plans, else from the running initiatives' records in the workspace (lib/decision-memory.js plansIn)
|
|
170
|
+
const plans = deps.plans ? () => deps.plans(w) : () => (w.root && deps.looks ? require('./decision-memory.js').plansIn(w.root, deps.looks(w)) : []);
|
|
171
|
+
for (const p of tryList(plans)) {
|
|
172
|
+
if (!p || !p.ini || !p.sig) continue;
|
|
173
|
+
const prev = F.plans.get(w.id + '|' + p.ini);
|
|
174
|
+
if (prev === p.sig) continue;
|
|
175
|
+
try { G.append(deps.runtimeDir(), { kind: 'plan', ws: w.id, ini: p.ini, sig: p.sig }, now); } catch (e) { warn('grill plan', e); }
|
|
176
|
+
if (prev) out.push({ ws: w.id, board: p.board || '', ini: p.ini, why: 'plan', key: `plan:${w.id}:${p.ini}:${p.sig}`, detail: 'its PRD changed: ' + one(p.path, 120) });
|
|
177
|
+
}
|
|
161
178
|
// the monthly look: every running initiative with open work, at most once in 30 days each (Wawan 2026-11-02: "why only one?").
|
|
162
179
|
// Late work first, then the ones never grilled, then the ones grilled longest ago; the cap decides how far down the list a month goes.
|
|
163
180
|
const late = tryList(() => deps.checks && deps.checks(w, localDay(now))), seen = new Set();
|
package/workspace/lib/grill.js
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* finding is one proposal under the initiative's blue ✦ (lib/proposals.js, group `grill`, type = the angle), with a next step. The PM keeps,
|
|
8
8
|
* drops or undoes it there. Nothing changes before the PM's ✓; the ✓ adds the next step as a task. Zero findings is silence.
|
|
9
9
|
*
|
|
10
|
-
* THE ANGLES: premortem · redteam · voices · reframe · scope · whatif.
|
|
10
|
+
* THE ANGLES: premortem · redteam · voices · reframe · scope · whatif · decision. `decision` (decision memory) is a plan that goes against an
|
|
11
|
+
* earlier decision that still stands; it quotes both sides verbatim and is checked by lib/decision-memory.js, not by finding(). Voices are simulated from stored research and meeting notes only,
|
|
11
12
|
* and only with 3 sources or more ("Simulated, not evidence"). A what-if is "Simulated, not the plan". The angle and the tag are in the
|
|
12
13
|
* proposal's own title, so the ✦ list shows them as it is.
|
|
13
14
|
*
|
|
@@ -15,6 +16,8 @@
|
|
|
15
16
|
* exit the initiative is about to leave its stage (most of the stage's tasks are closed), once per stage
|
|
16
17
|
* forecast its forecast, or the forecast of a goal it moves, turned at-risk or will-miss (lib/forecast.js `turned` lines), once per turn
|
|
17
18
|
* move its scope or dates moved a lot since the last look (tasks ±30% and 3 or more, or the end ±14 days)
|
|
19
|
+
* plan its PRD changed since the last look (decision memory: a new plan is checked against the decisions that stand). The first
|
|
20
|
+
* look only records the PRD; a run starts when it changes after that
|
|
18
21
|
* monthly every running initiative with open work, at most once in 30 days each: late work first, then the ones grilled longest ago
|
|
19
22
|
* `exits` runs only the first. Every run counts against the monthly credit cap (grill.cap, US dollars): before a run the cap must hold
|
|
20
23
|
* the run's whole budget, which goes to the run as --max-budget-usd, so the month never goes beyond the cap. At the cap Claude pauses and
|
|
@@ -28,10 +31,11 @@
|
|
|
28
31
|
* voices { run, n } how many stored research and note sources the run found for Voices
|
|
29
32
|
* end { run, ok, cost, error? } cost: US dollars the run spent (lib/headless.js parseResult costUsd)
|
|
30
33
|
* snap { ws, ini, n, end } the open tasks and the latest due date the last look saw (for `move`)
|
|
34
|
+
* plan { ws, ini, sig } the signature of its PRD the last look saw (for `plan`)
|
|
31
35
|
* paused { month, spent, cap } the cap held a run back this month; written once per month
|
|
32
36
|
*
|
|
33
37
|
* logFile(dir) · readLines(file) · read(dir) → fold · append(dir, line)
|
|
34
|
-
* fold(lines) PURE → { runs: Map run → r, keys: Set, lastAt: Map ws|ini → iso, snaps: Map, paused: Set month }
|
|
38
|
+
* fold(lines) PURE → { runs: Map run → r, keys: Set, lastAt: Map ws|ini → iso, snaps: Map, plans: Map, paused: Set month }
|
|
35
39
|
* monthOf(ms) · spentIn(F, month) the month's spend (an open run counts its whole budget)
|
|
36
40
|
* budgetFor(F, { cap, now }) PURE → { ok, budget, spent } | { ok: false, spent, why: 'cap' }
|
|
37
41
|
* moved(prev, cur) PURE → '' | what moved, in words
|
|
@@ -44,11 +48,11 @@ const path = require('path');
|
|
|
44
48
|
const crypto = require('crypto');
|
|
45
49
|
|
|
46
50
|
const LOG = 'grill-runs.jsonl';
|
|
47
|
-
const KINDS = new Set(['start', 'voices', 'end', 'snap', 'paused']);
|
|
48
|
-
const ANGLES = Object.freeze(['premortem', 'redteam', 'voices', 'reframe', 'scope', 'whatif']);
|
|
49
|
-
const LABEL = Object.freeze({ premortem: 'Premortem', redteam: 'Red team', voices: 'Voices', reframe: 'Reframe', scope: 'Scope', whatif: 'What-if' });
|
|
51
|
+
const KINDS = new Set(['start', 'voices', 'end', 'snap', 'plan', 'paused']);
|
|
52
|
+
const ANGLES = Object.freeze(['premortem', 'redteam', 'voices', 'reframe', 'scope', 'whatif', 'decision']);
|
|
53
|
+
const LABEL = Object.freeze({ premortem: 'Premortem', redteam: 'Red team', voices: 'Voices', reframe: 'Reframe', scope: 'Scope', whatif: 'What-if', decision: 'Earlier decision' });
|
|
50
54
|
const SIMULATED = Object.freeze({ voices: 'Simulated, not evidence', whatif: 'Simulated, not the plan' });
|
|
51
|
-
const WHYS = Object.freeze(['exit', 'forecast', 'move', 'monthly', 'ask']);
|
|
55
|
+
const WHYS = Object.freeze(['exit', 'forecast', 'move', 'plan', 'monthly', 'ask']);
|
|
52
56
|
const MODES = Object.freeze(['full', 'exits', 'off']);
|
|
53
57
|
const VOICES_MIN = 3;
|
|
54
58
|
const RUN_MAX_USD = 2; // one run never spends more than this
|
|
@@ -92,7 +96,7 @@ function append(dir, ev, at = new Date()) {
|
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
function fold(lines) {
|
|
95
|
-
const runs = new Map(), keys = new Set(), lastAt = new Map(), snaps = new Map(), paused = new Set();
|
|
99
|
+
const runs = new Map(), keys = new Set(), lastAt = new Map(), snaps = new Map(), plans = new Map(), paused = new Set();
|
|
96
100
|
for (const l of lines || []) {
|
|
97
101
|
if (!isObj(l)) continue;
|
|
98
102
|
if (l.kind === 'start' && str(l.run)) {
|
|
@@ -102,9 +106,10 @@ function fold(lines) {
|
|
|
102
106
|
} else if (l.kind === 'voices') { const r = runs.get(l.run); if (r) r.voices = Number(l.n) || 0; }
|
|
103
107
|
else if (l.kind === 'end') { const r = runs.get(l.run); if (r) { r.ended = true; r.ok = !!l.ok; r.cost = Number.isFinite(Number(l.cost)) && l.cost !== null ? Number(l.cost) : null; r.endAt = str(l.at); if (l.error) r.error = str(l.error); } }
|
|
104
108
|
else if (l.kind === 'snap') snaps.set(l.ws + '|' + l.ini, { n: Number(l.n) || 0, end: str(l.end), at: str(l.at) });
|
|
109
|
+
else if (l.kind === 'plan') plans.set(l.ws + '|' + l.ini, str(l.sig));
|
|
105
110
|
else if (l.kind === 'paused') paused.add(str(l.month));
|
|
106
111
|
}
|
|
107
|
-
return { runs, keys, lastAt, snaps, paused };
|
|
112
|
+
return { runs, keys, lastAt, snaps, plans, paused };
|
|
108
113
|
}
|
|
109
114
|
|
|
110
115
|
/* ── the credit cap ── */
|
|
@@ -152,6 +157,8 @@ const LIM = { title: 140, why: 300, next: 140, label: 80, ref: 500 };
|
|
|
152
157
|
/** → { prop } | { error }. input: { title, why, next, sources: [{ label, ref }], key?, rank? } */
|
|
153
158
|
function finding(angle, input) {
|
|
154
159
|
if (!ANGLES.includes(angle)) return { error: 'the angle is one of ' + ANGLES.join(', ') };
|
|
160
|
+
// an earlier decision has its own shape and its own checks (lib/decision-memory.js); `pmos grill find decision` files it
|
|
161
|
+
if (angle === 'decision') return { error: 'the decision angle files through lib/decision-memory.js (pmos grill find decision)' };
|
|
155
162
|
if (!isObj(input)) return { error: 'the finding is a JSON object' };
|
|
156
163
|
const title = one(input.title, LIM.title), why = one(input.why, LIM.why), next = one(input.next, LIM.next);
|
|
157
164
|
if (!title) return { error: 'title is the finding in one short line' };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* past-chat.js — what a chat knows when the PM asks it about the past (initiative ask-the-past). There is no search box and no button: the PM
|
|
3
|
+
* asks in the initiative chat ("why did we drop X?", "did we ever decide Y?"), and this rule reaches the session first, as lib/program-chat.js
|
|
4
|
+
* does for programs.
|
|
5
|
+
*
|
|
6
|
+
* THE GUARDRAIL IS THE RULE'S ORDER. The chat runs `pmos past ask`, answers ONLY from the lines it prints, cites each as path:line, checks its
|
|
7
|
+
* draft with `pmos past verify`, and says "No record found" when the records hold nothing. It never guesses a reason, never quotes a
|
|
8
|
+
* superseded decision as the live rule, and never changes a record while answering.
|
|
9
|
+
*
|
|
10
|
+
* isAsk(text) → whether a message asks about the past (lib/past.js isPastAsk)
|
|
11
|
+
* rule() → the rule's lines
|
|
12
|
+
* wrap(text) → the message as PMOS types it into a live session
|
|
13
|
+
*/
|
|
14
|
+
const P = require('./past.js');
|
|
15
|
+
|
|
16
|
+
const MARK = '[PMOS past]';
|
|
17
|
+
const SAYS = 'The PM asks: ';
|
|
18
|
+
const RULE = [
|
|
19
|
+
'The PM asks about the past. Answer from the records PMOS keeps, never from memory:',
|
|
20
|
+
'1. Run `pmos past ask "<the PM\'s question, in their words>"`. It prints the records that answer it, each with its path:line and its own words, and how sure the match is.',
|
|
21
|
+
'2. Answer in two to four sentences, from those lines only. Put the path:line after each claim, as printed. Say when it was decided and by which record (for example D66).',
|
|
22
|
+
'3. A line marked SUPERSEDED is history: say what replaced it. A weak match says so in your answer.',
|
|
23
|
+
'4. If it prints "No record found", or its lines do not really answer the question, say "No record found" and name what PMOS read. Never guess a reason.',
|
|
24
|
+
'5. Before you answer, run `pmos past verify --text "<your answer>"` and fix every citation it marks ✗.',
|
|
25
|
+
'Do not change any record, task or decision while you answer. If the PM then wants something changed, ask first.',
|
|
26
|
+
].join('\n');
|
|
27
|
+
|
|
28
|
+
const isAsk = (text) => P.isPastAsk(text);
|
|
29
|
+
const rule = () => RULE;
|
|
30
|
+
function wrap(text) { return MARK + ' ' + RULE + '\n\n' + SAYS + String(text == null ? '' : text); }
|
|
31
|
+
|
|
32
|
+
module.exports = { MARK, isAsk, rule, wrap, RULE };
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* past-cli.js — `pmos past …`: ask PMOS about the past (initiative ask-the-past; lib/past.js is the engine).
|
|
3
|
+
*
|
|
4
|
+
* pmos past ask "<question>" [--json] [--limit N] the records that answer it, each with its path:line and its own words, and how sure
|
|
5
|
+
* the match is; "No record found" when nothing answers it
|
|
6
|
+
* pmos past check "<idea>" [--json] what was decided or declined before about this idea ("you decided this before")
|
|
7
|
+
* pmos past verify --text "<answer>" | --file <f> every path:line the answer cites is a record PMOS read, at a line that exists
|
|
8
|
+
* pmos past sources which records were read, and which were not on this machine
|
|
9
|
+
*
|
|
10
|
+
* WHAT IT READS. The repository's tracked records: DECISIONS.md, the ADRs, pm-decisions.md, planning/out-of-scope/, planning/initiatives/
|
|
11
|
+
* and discoveries.md; and, in the per-user state folder, the decisions caught in meeting notes that the PM did not mark wrong or delete
|
|
12
|
+
* (lib/meeting-points.js). A private note is never read. Chat transcripts and Jira tickets are not past records PMOS keeps on this machine,
|
|
13
|
+
* so they are named as not read rather than guessed at.
|
|
14
|
+
*
|
|
15
|
+
* WHAT IT NEVER DOES. It writes nothing, sends nothing and calls no model. The answer in words is the chat's: the initiative chat runs
|
|
16
|
+
* `pmos past ask`, answers only from what it prints and cites each line (lib/past-chat.js).
|
|
17
|
+
*/
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const { execFileSync } = require('child_process');
|
|
21
|
+
const P = require('./past.js');
|
|
22
|
+
const { defaultRoot } = require('./state-root');
|
|
23
|
+
const { parseArgs } = require('./schedule-cli');
|
|
24
|
+
|
|
25
|
+
const COMMANDS = { past: ['ask', 'check', 'verify', 'sources'] };
|
|
26
|
+
const USAGE = [
|
|
27
|
+
'usage: pmos past ask|check|verify|sources — ask PMOS about the past, with the source of every answer',
|
|
28
|
+
' pmos past ask "<question>" [--json] [--limit N]',
|
|
29
|
+
' pmos past check "<idea>" [--json]',
|
|
30
|
+
' pmos past verify --text "<answer>" | --file <path>',
|
|
31
|
+
' pmos past sources',
|
|
32
|
+
];
|
|
33
|
+
const REPO_RE = /^(DECISIONS\.md|pm-decisions\.md|discoveries\.md|okf\/products\/pmos\/adr\/[^/]+\.md|planning\/out-of-scope\/[^/]+\.md|planning\/initiatives\/[^/]+\.md)$/;
|
|
34
|
+
const NOT_READ = ['chat transcripts (PMOS keeps no searchable copy)', 'Jira tickets (no ticket history on this machine)'];
|
|
35
|
+
const KIND_WORD = { decision: 'decision', amendment: 'amendment', adr: 'decision record', 'pm-decision': 'PM journal', 'out-of-scope': 'declined request',
|
|
36
|
+
initiative: 'initiative', discovery: 'run log', meeting: 'meeting decision', claim: 'claim', ticket: 'ticket' };
|
|
37
|
+
|
|
38
|
+
const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
39
|
+
|
|
40
|
+
/** The repository's past records: [{ path, text }]. */
|
|
41
|
+
function repoFiles(root) {
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const rel of git(root, ['ls-files', '-z', '*.md']).split('\0').filter((p) => REPO_RE.test(p))) {
|
|
44
|
+
try { out.push({ path: rel, text: fs.readFileSync(path.join(root, rel), 'utf8') }); } catch { /* a file that cannot be read is not a record */ }
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The decisions caught in the PM's meeting notes: [{ kind: 'meeting', ref, title, text, date, quote }]. A private note, and a point marked
|
|
50
|
+
* wrong or deleted, is left out. */
|
|
51
|
+
function meetingRecords(stateDir) {
|
|
52
|
+
const dir = path.join(stateDir || '', 'notes');
|
|
53
|
+
let names = [];
|
|
54
|
+
try { names = fs.readdirSync(dir).filter((f) => /^[A-Za-z0-9_-]{1,80}\.json$/.test(f)); } catch { return { records: [], read: false, notes: 0 }; }
|
|
55
|
+
let MR = null, MP = null;
|
|
56
|
+
try { MR = require('./meeting-replay.js'); MP = require('./meeting-points.js'); } catch { return { records: [], read: false, notes: 0 }; }
|
|
57
|
+
const out = []; let notes = 0;
|
|
58
|
+
for (const f of names) {
|
|
59
|
+
let n = null;
|
|
60
|
+
try { n = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); } catch { continue; }
|
|
61
|
+
if (!n || n.private) continue;
|
|
62
|
+
notes++;
|
|
63
|
+
const id = f.slice(0, -5);
|
|
64
|
+
const verdicts = MP.pointsOf(stateDir, id);
|
|
65
|
+
const caught = MR.caughtOf(n);
|
|
66
|
+
const when = String((n.meeting && n.meeting.start) || n.created_at || '').slice(0, 10);
|
|
67
|
+
const title = String((n.meeting && n.meeting.title) || n.title || 'Meeting').slice(0, 100);
|
|
68
|
+
caught.decision.forEach((d, i) => {
|
|
69
|
+
const pid = 'd' + (i + 1);
|
|
70
|
+
if (verdicts[pid] === 'wrong' || verdicts[pid] === 'deleted') return;
|
|
71
|
+
out.push({ kind: 'meeting', ref: 'state:notes/' + f + ':1', title: title + ' — ' + d.text, text: d.text + (d.quote ? '\n' + d.quote : ''), date: when, quote: d.text,
|
|
72
|
+
...(verdicts[pid] === 'confirmed' ? { confirmed: true } : {}) });
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return { records: out, read: true, notes };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function corpusFor(cwd, stateDir) {
|
|
79
|
+
let root;
|
|
80
|
+
try { root = git(cwd, ['rev-parse', '--show-toplevel']).trim(); } catch { root = null; }
|
|
81
|
+
const files = root ? repoFiles(root) : [];
|
|
82
|
+
const m = meetingRecords(stateDir);
|
|
83
|
+
return { root, C: P.build(files, m.records), meetings: m };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sourcesLine(C, meetings) {
|
|
87
|
+
const k = C.kinds || {};
|
|
88
|
+
const parts = [];
|
|
89
|
+
const n = (key, word) => { if (k[key]) parts.push(k[key] + ' ' + word); };
|
|
90
|
+
n('decision', 'decisions'); n('adr', 'decision records'); n('amendment', 'amendments'); n('pm-decision', 'PM journal entries');
|
|
91
|
+
n('out-of-scope', 'declined requests'); n('initiative', 'initiatives'); n('discovery', 'run-log entries');
|
|
92
|
+
parts.push(meetings.read ? (k.meeting || 0) + ' meeting decisions from ' + meetings.notes + ' notes' : 'no meeting notes on this machine');
|
|
93
|
+
return 'Read: ' + parts.join(', ') + '. Not read: ' + NOT_READ.join('; ') + '.';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function hitLines(h, i) {
|
|
97
|
+
const st = h.status === 'reverted' ? ' · REVERTED: not the live rule' : h.status === 'superseded' ? ` · SUPERSEDED${h.partial ? ' in part' : ''}${h.supersededBy && h.supersededBy.length ? ' by ' + h.supersededBy.join(', ') : ''}: not the live rule`
|
|
98
|
+
: h.status === 'amended' ? ` · amended${h.amendedBy && h.amendedBy.length ? ' by ' + h.amendedBy.join(', ') : ''}` : '';
|
|
99
|
+
const out = [`${i + 1}. ${h.title} (${KIND_WORD[h.kind] || h.kind}${h.date ? ', ' + h.date : ''}${st})`, ` ${h.ref} "${h.quote}"`];
|
|
100
|
+
if (h.recordStatus) out.push(` its status: ${h.recordStatus}`);
|
|
101
|
+
if (h.dropSaid === false) out.push(' (names the subject, but does not say it was dropped)');
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const CONF_WORD = { high: 'a strong match: two or more records agree', medium: 'a fair match', low: 'a weak match: read the lines before you rely on them' };
|
|
106
|
+
|
|
107
|
+
function run(argv, { say = console.log, cwd = process.cwd(), stateDir = defaultRoot() } = {}) {
|
|
108
|
+
const args = argv[0] === 'past' ? argv.slice(1) : argv.slice();
|
|
109
|
+
const verb = args[0];
|
|
110
|
+
if (!verb || verb === 'help' || verb === '--help') { for (const l of USAGE) say(l); return verb ? 0 : 2; }
|
|
111
|
+
if (!COMMANDS.past.includes(verb)) { say(`✗ unknown past command "${verb}" — one of ${COMMANDS.past.join(', ')}`); return 2; }
|
|
112
|
+
const { pos, flags } = parseArgs(args.slice(1));
|
|
113
|
+
const { C, meetings, root } = corpusFor(cwd, stateDir);
|
|
114
|
+
if (!root && !meetings.read) { say('✗ this is not a git repository and there are no meeting notes here, so there is no past to read'); return 1; }
|
|
115
|
+
|
|
116
|
+
if (verb === 'sources') { say(sourcesLine(C, meetings)); return 0; }
|
|
117
|
+
|
|
118
|
+
if (verb === 'verify') {
|
|
119
|
+
let text = flags.text;
|
|
120
|
+
if (flags.file) { try { text = fs.readFileSync(String(flags.file), 'utf8'); } catch (e) { say('✗ cannot read ' + flags.file + ': ' + (e.code || e.message)); return 1; } }
|
|
121
|
+
if (typeof text !== 'string' || !text.trim()) { say('✗ give the answer with --text "<answer>" or --file <path>'); return 2; }
|
|
122
|
+
const v = P.verify(C, text);
|
|
123
|
+
if (!v.refs.length) { say('✗ the answer cites no record (path:line). Cite the lines `pmos past ask` printed, or say no record was found.'); return 1; }
|
|
124
|
+
for (const r of v.refs) say(`${r.ok ? '✓' : '✗'} ${r.ref}${r.ok ? '' : ' — ' + r.why}`);
|
|
125
|
+
say(v.ok ? 'Every citation is a record PMOS read.' : 'Remove or fix the citations marked ✗ before you answer.');
|
|
126
|
+
return v.ok ? 0 : 1;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const q = pos.join(' ').trim();
|
|
130
|
+
if (!q) { say(`✗ give the ${verb === 'ask' ? 'question' : 'idea'} in quotes: pmos past ${verb} "<${verb === 'ask' ? 'question' : 'idea'}>"`); return 2; }
|
|
131
|
+
const limit = Math.max(1, Math.min(10, Number(flags.limit) || 5));
|
|
132
|
+
const r = verb === 'ask' ? P.search(C, q, { limit }) : P.repeats(C, q, { limit });
|
|
133
|
+
if (flags.json) { say(JSON.stringify({ question: q, confidence: r.confidence, none: r.none, hits: r.hits, read: sourcesLine(C, meetings) }, null, 1)); return 0; }
|
|
134
|
+
if (r.none) {
|
|
135
|
+
say(verb === 'ask' ? `No record found for "${q}".` : `No earlier decision found about "${q}".`);
|
|
136
|
+
say('Nothing PMOS keeps answers it. Say so plainly, and do not guess a reason.');
|
|
137
|
+
say(sourcesLine(C, meetings));
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
say(`${verb === 'ask' ? 'What the records say' : 'You decided this before'}: ${CONF_WORD[r.confidence]}.`);
|
|
141
|
+
r.hits.forEach((h, i) => { for (const l of hitLines(h, i)) say(l); });
|
|
142
|
+
say('Answer only from these lines. Cite each one as path:line. A superseded decision is history, not the rule. If they do not answer the question, say no record was found.');
|
|
143
|
+
say(sourcesLine(C, meetings));
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { run, COMMANDS, USAGE, corpusFor, repoFiles, meetingRecords, REPO_RE };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* past-repeat.js — "You decided this before" (initiative ask-the-past). When the PM adds an initiative, PMOS reads what was decided or
|
|
3
|
+
* declined before (lib/past.js repeats) and, when the new idea repeats one, raises ONE finding under the initiative's blue ✦ (lib/proposals.js
|
|
4
|
+
* group `grill`, type `past`; public/pm/grill-words.js draws its words and mark). It quotes the earlier record and its line; the next step is to say what is different this time. Nothing
|
|
5
|
+
* changes before the PM's ✓, and ✕ drops it. Zero matches is silence.
|
|
6
|
+
*
|
|
7
|
+
* It is deterministic: no model, no session, no network. It still goes through THE ONE DOOR, lib/ai-guard.js: kill switch (PMOS_AI,
|
|
8
|
+
* PMOS_AI_PAST_REPEAT, Settings ai.off), the replay gate (the cases below, run once per version before anything shows), the check of every
|
|
9
|
+
* quote at its line, and the audit line. Until lib/ai-guard.js is on this machine, nothing is filed.
|
|
10
|
+
*
|
|
11
|
+
* Decision memory raises the other kind: "this contradicts an earlier decision" (keep, change or drop). This one says "you decided this
|
|
12
|
+
* before": the idea is a repeat, or a reminder of why it was set aside.
|
|
13
|
+
*
|
|
14
|
+
* proposalFor(C, { ini, title, intent }) → [proposal] (the guard's shape) | [] PURE
|
|
15
|
+
* findingOf(p) → the lib/grill.js finding input for one proposal
|
|
16
|
+
* cases() → the replay cases
|
|
17
|
+
* file({ root, dir, runtime, ws, board, ini, title, intent, env, guard?, now? }) → Promise<{ filed, dropped, held?, off?, none? }>
|
|
18
|
+
*/
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const crypto = require('crypto');
|
|
21
|
+
const P = require('./past.js');
|
|
22
|
+
const G = require('./grill.js');
|
|
23
|
+
const PR = require('./proposals.js');
|
|
24
|
+
|
|
25
|
+
const JOB = 'past-repeat';
|
|
26
|
+
const VERSION = '1';
|
|
27
|
+
const MIN_COVER = 0.6; // the earlier record holds at least this share of what the idea is about
|
|
28
|
+
const OWN = (ini) => new RegExp('(^|/)' + String(ini).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\.md$');
|
|
29
|
+
const hash = (s) => crypto.createHash('sha1').update(String(s)).digest('hex').slice(0, 10);
|
|
30
|
+
const one = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
31
|
+
const KIND_WORD = { decision: 'decision', amendment: 'decision', adr: 'decision record', 'pm-decision': 'PM journal', 'out-of-scope': 'declined request', initiative: 'initiative', meeting: 'meeting' };
|
|
32
|
+
|
|
33
|
+
function guardOf() { try { return require('./ai-guard.js'); } catch { return null; } }
|
|
34
|
+
|
|
35
|
+
/** PURE. The idea → the "you decided this before" proposal, or nothing. */
|
|
36
|
+
function proposalFor(C, { ini, title, intent } = {}) {
|
|
37
|
+
const idea = [title, intent].filter(Boolean).join('. ');
|
|
38
|
+
if (!idea.trim()) return [];
|
|
39
|
+
const own = ini ? OWN(ini) : null;
|
|
40
|
+
// a meeting decision lives in the per-user notes, outside the folders the guard checks a quote in: the chat cites it, the ✦ does not
|
|
41
|
+
const r = P.repeats(C, idea, { limit: 8, kinds: ['decision', 'adr', 'amendment', 'pm-decision', 'out-of-scope', 'initiative'] });
|
|
42
|
+
if (r.none || r.confidence === 'low') return [];
|
|
43
|
+
const hits = r.hits.filter((h) => h.cover >= MIN_COVER && !(own && own.test(h.path)) && !(ini && h.kind === 'initiative' && h.path.endsWith('/' + ini + '.md'))).slice(0, 3);
|
|
44
|
+
if (!hits.length) return [];
|
|
45
|
+
const top = hits[0];
|
|
46
|
+
const live = top.status === 'superseded' ? ` (later superseded${top.supersededBy && top.supersededBy.length ? ' by ' + top.supersededBy.join(', ') : ''})` : '';
|
|
47
|
+
const say = `${KIND_WORD[top.kind] || 'A record'}${top.date ? ' of ' + top.date : ''} already covers this${live}: "${one(top.quote, 160)}"`;
|
|
48
|
+
return [{
|
|
49
|
+
id: 'past-' + hash([ini || '', top.ref].join('|')),
|
|
50
|
+
title: one(top.title, 120),
|
|
51
|
+
say,
|
|
52
|
+
confidence: hits.length >= 2 && r.confidence === 'high' ? 'high' : r.confidence === 'high' ? 'medium' : r.confidence,
|
|
53
|
+
claims: hits.map((h) => ({ text: one(h.title, 160), sources: [{ label: h.id || (KIND_WORD[h.kind] || h.kind), ref: h.ref, quote: h.quote }] })),
|
|
54
|
+
data: { group: 'grill', angle: 'past', next: `Say what is different from "${one(top.title, 70)}" this time` },
|
|
55
|
+
}];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The lib/grill.js finding input for one guarded proposal. */
|
|
59
|
+
function findingOf(p) {
|
|
60
|
+
const srcs = [];
|
|
61
|
+
for (const c of p.claims || []) for (const s of c.sources || []) if (!srcs.some((x) => x.ref === s.ref)) srcs.push({ label: one(s.label + ' · ' + s.ref, 80), ref: s.ref });
|
|
62
|
+
return { title: p.title, why: p.say, next: p.data && p.data.next, sources: srcs.slice(0, 8), key: p.id, rank: 2 };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The finding as lib/grill.js finding() shapes it, for angle `past` (group grill, section check: its own ✓, never Keep all). The angle
|
|
66
|
+
* is not in grill.js ANGLES on purpose: a grill run never files it; only this module does, after the guard. */
|
|
67
|
+
const LABEL = 'You decided this before';
|
|
68
|
+
function grillProp(f) {
|
|
69
|
+
return { group: 'grill', type: 'past', section: 'check', title: one(LABEL + ': ' + f.title, 200), why: one(f.why + ' Next: ' + f.next, 460),
|
|
70
|
+
sources: f.sources.map((s) => ({ label: one(s.label, 80), ref: one(s.ref, 500) })), items: [], data: { angle: 'past', next: one(f.next, 140), rank: f.rank } };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* The replay: a repeat of a declined request is raised with its line; an idea nothing covers is silence; an idea that is the initiative's
|
|
74
|
+
* own record is not a repeat of itself. */
|
|
75
|
+
const FIXTURE = [
|
|
76
|
+
{ path: 'planning/out-of-scope/graph-store.md', text: '---\ntype: Reference\ntitle: "A graph store for OKF ingestion (Neo4j-class)"\n---\n\n# A graph store for OKF ingestion (Neo4j-class)\n\n- decision: declined\n- date: 2026-06-29\n\n## Reasoning\n\nA Neo4j graph store was declined twice: the reads are joins, not path traversal.\n' },
|
|
77
|
+
{ path: 'pm-decisions.md', text: '---\ntype: Decision\ntitle: "pm-decisions.md"\n---\n\n# pm-decisions.md\n\n## 2026-10-01 — Dark launch of the phone app dropped\n- decision: the phone app stays a web page; no store app.\n' },
|
|
78
|
+
{ path: 'planning/initiatives/kg-graph-db.md', text: '---\ntype: Initiative\ntitle: "Store the knowledge graph in a Neo4j graph store"\nid: kg-graph-db\n---\n\n# Store the knowledge graph in a Neo4j graph store\n' },
|
|
79
|
+
];
|
|
80
|
+
function cases() {
|
|
81
|
+
const C = P.build(FIXTURE);
|
|
82
|
+
return [
|
|
83
|
+
{ name: 'a repeat of a declined request is raised with its line', run: async () => proposalFor(C, { ini: 'kg-graph-db', title: 'Store the knowledge graph in a Neo4j graph store', intent: 'path traversal over OKF' }), expect: { filed: 1, cites: ['planning/out-of-scope/graph-store.md'] } },
|
|
84
|
+
{ name: 'an idea nothing covers is silence', run: async () => proposalFor(C, { ini: 'x', title: 'Weekly espresso budget for the design team', intent: '' }), expect: { none: true } },
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
let fixtureRoot = null;
|
|
88
|
+
/** The replay reads the fixture's files from a folder of its own (the guard checks each quote on disk). */
|
|
89
|
+
function replayRoot() {
|
|
90
|
+
if (fixtureRoot && fs.existsSync(fixtureRoot)) return fixtureRoot;
|
|
91
|
+
const os = require('os'), path = require('path');
|
|
92
|
+
fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pmos-past-replay-'));
|
|
93
|
+
for (const f of FIXTURE) { const p = path.join(fixtureRoot, f.path); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, f.text); }
|
|
94
|
+
return fixtureRoot;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Raise the finding for one new initiative, through the guard. dir: the workspace's PMOS state folder (proposals.jsonl, the audit);
|
|
98
|
+
* runtime: the per-user state folder (meeting notes, Settings). */
|
|
99
|
+
async function file({ root, dir, runtime, ws, board, ini, title, intent, env = process.env, guard = guardOf(), now = Date.now(), corpus = null } = {}) {
|
|
100
|
+
if (!guard) return { filed: [], dropped: [], off: 'the AI guardrail (lib/ai-guard.js) is not installed' };
|
|
101
|
+
if (!dir || !ini) return { filed: [], dropped: [], off: 'no PMOS state folder' };
|
|
102
|
+
const stop = guard.off(JOB, { env, settingsDir: runtime || null });
|
|
103
|
+
if (stop) return { filed: [], dropped: [], off: stop };
|
|
104
|
+
// the guard's own files (the replay record, the audit) live in the per-user state folder, never in the repository: a write under the
|
|
105
|
+
// repository's pmos/state is a record change the pages refresh on and Sync commits. Only the finding itself goes to proposals.jsonl.
|
|
106
|
+
const gdir = runtime || dir;
|
|
107
|
+
if (!guard.ready(gdir, JOB, VERSION)) {
|
|
108
|
+
const rp = await guard.replay(JOB, VERSION, cases(), { root: replayRoot(), stateDir: gdir });
|
|
109
|
+
if (!rp.ok) return { filed: [], dropped: [], held: true };
|
|
110
|
+
}
|
|
111
|
+
let C = corpus;
|
|
112
|
+
if (!C) { const CLI = require('./past-cli.js'); C = CLI.corpusFor(root, runtime).C; }
|
|
113
|
+
const props = proposalFor(C, { ini, title, intent });
|
|
114
|
+
if (!props.length) return { filed: [], dropped: [], none: true };
|
|
115
|
+
const run = 'pa' + Number(now).toString(36);
|
|
116
|
+
return guard.file({ job: JOB, version: VERSION, run, root, stateDir: gdir, settingsDir: runtime || null, env }, props, async (accepted) => {
|
|
117
|
+
const F = PR.read(dir);
|
|
118
|
+
for (const p of accepted) {
|
|
119
|
+
const prop = { id: G.idOf(ini, 'past', p.id), ...grillProp(findingOf(p)) };
|
|
120
|
+
if (F.drops.has(PR.sigOf(prop)) || PR.keptIds(F).has(prop.id)) continue;
|
|
121
|
+
PR.append(dir, null, { kind: 'propose', ini, board: board || '', run, ...prop, at: new Date(now).toISOString() });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { JOB, VERSION, MIN_COVER, LABEL, proposalFor, findingOf, grillProp, cases, file, FIXTURE };
|