create-pmos 0.504.0 → 0.506.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/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/guide/ask-the-past.md +46 -0
- package/workspace/guide/claude-proposes.md +29 -2
- package/workspace/guide/index.md +2 -0
- package/workspace/guide/notes.md +8 -1
- 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 +9 -0
- package/workspace/lib/proposal-runs.js +20 -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/cozy-home-rows.css +8 -0
- package/workspace/public/pm/cozy-live-pill.css +10 -0
- package/workspace/public/pm/cozy-live-pill.js +13 -5
- package/workspace/public/pm/cozy-rec-tab.js +5 -2
- 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/public/pm/home-rec-row.js +11 -2
- package/workspace/public/pm/rec-state.js +30 -4
- 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/weekly-drift-check.json +5 -0
|
@@ -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 };
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* past.js — ask PMOS about the past (initiative ask-the-past). THE ONE RETRIEVAL LAYER over what PMOS already wrote down about earlier
|
|
3
|
+
* choices: "why did we drop X?", "when did we decide Y?", and "did we decide this before?" for a new idea. lib/decision-ledger.js
|
|
4
|
+
* (decision memory) and the initiative chat both read through it; nobody writes a second matcher.
|
|
5
|
+
*
|
|
6
|
+
* PURE. Takes the text of the records and returns passages, each with the line it rests on. No filesystem, no git, no network, no model.
|
|
7
|
+
* Same input → the same output. lib/past-cli.js does the reading (`pmos past ask|check|verify`).
|
|
8
|
+
*
|
|
9
|
+
* WHAT IS AN ENTRY. One thing that was decided, rejected or recorded, cut where the file itself cuts it:
|
|
10
|
+
* decision DECISIONS.md, one `### Dnn — title` section id Dnn
|
|
11
|
+
* amendment DECISIONS.md, one dated bullet of the amendment log ids: the D-ids it names
|
|
12
|
+
* adr okf/products/pmos/adr/dNN-*.md, the whole file id Dnn
|
|
13
|
+
* pm-decision pm-decisions.md, one `## YYYY-MM-DD — title` entry
|
|
14
|
+
* out-of-scope planning/out-of-scope/*.md, the whole file (a declined request)
|
|
15
|
+
* initiative planning/initiatives/*.md, the record: its title, status, why
|
|
16
|
+
* discovery discoveries.md, one `## ` entry (the run log; weighs less)
|
|
17
|
+
* meeting a decision caught in a meeting note, not marked wrong or deleted (records, from the per-user state folder)
|
|
18
|
+
* claim a Jev claim the claims ledger holds (records)
|
|
19
|
+
* ticket a Jira ticket draft (records)
|
|
20
|
+
* A record that cannot be read is not an entry; the CLI says which sources it read and which were not on this machine.
|
|
21
|
+
*
|
|
22
|
+
* HOW A PASSAGE IS FOUND. Words, not similarity of meaning: the question's content words (stop words and question words out, a light
|
|
23
|
+
* stem) are scored BM25 over each entry, its title counting three times. "Drop", "reject", "retire", "remove", "decline" and their kin are
|
|
24
|
+
* one family, so "why did we drop X" finds "X was retired". A D-id in the question ("D66") finds that decision first. Nothing is inferred.
|
|
25
|
+
*
|
|
26
|
+
* HONEST CONFIDENCE. An answer's confidence is the share of the question's distinct content (by weight) that the best entry holds:
|
|
27
|
+
* high ≥ 0.75 of it, and a second entry agrees (≥ 0.5)
|
|
28
|
+
* medium ≥ 0.6
|
|
29
|
+
* low ≥ 0.45 (shown, with "a weak match")
|
|
30
|
+
* none under 0.45, or no content word at all → "No record found". It never guesses.
|
|
31
|
+
*
|
|
32
|
+
* WHAT A HIT IS. { kind, id?, path, line, title, date, quote, ref: 'path:line', score, cover, status?, supersededBy? }. `quote` is the
|
|
33
|
+
* line's own text, verbatim (cut at 220 characters), so lib/ai-guard.js check() finds it at `ref`. A superseded or amended decision says
|
|
34
|
+
* so (from lib/decision-ledger.js when present, else from the ADR's own `superseded_by` / `amended_by` keys): an answer never quotes a
|
|
35
|
+
* dead decision as live.
|
|
36
|
+
*
|
|
37
|
+
* build(files, records?) → corpus files: [{ path, text }] (repository Markdown); records: [{ kind, ref, title, text, date, quote? }]
|
|
38
|
+
* search(corpus, question, o) → { hits: [hit], none, confidence, terms } o: { limit = 5, kinds }
|
|
39
|
+
* repeats(corpus, idea, o) → { hits, none, confidence } only what was decided or declined (not the run log); the shape decision
|
|
40
|
+
* memory reads: [{ kind, path, line, quote, date, id?, score }], ranked, none: true when nothing passes
|
|
41
|
+
* verify(corpus, text) → { refs: [{ ref, ok, why? }], ok } every `path:line` an answer cites is one the corpus holds
|
|
42
|
+
* isPastAsk(text) → whether a message asks about the past ("why did we…", "did we ever…", "what did we decide…")
|
|
43
|
+
* statusOf(corpus, id) → { status, supersededBy, amendedBy } for a D-id
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const STOP = new Set(('a an and are as at be been being but by can could did do does doing done for from had has have having how i if in into '
|
|
47
|
+
+ 'is it its me my no not of on or our ours should so than that the their them then there these they this those to too us was we were '
|
|
48
|
+
+ 'what when where which who whom whose why will with would you your yours ever again any some all about over also just very really '
|
|
49
|
+
+ 'pmos decide decided decision decisions choose chose chosen pick picked go went make made said say says tell told know think thought '
|
|
50
|
+
+ 'don doesn didn isn aren wasn weren won haven hasn cannot must every need needs '
|
|
51
|
+
+ 'one two use used using get got thing things way still now new old before after earlier first last').split(' '));
|
|
52
|
+
/* One family: a "why did we drop X" question meets the words the records use for it. */
|
|
53
|
+
const FAMILY = {
|
|
54
|
+
drop: ['drop', 'dropp', 'retir', 'remov', 'reject', 'declin', 'deprecat', 'cut', 'kill', 'delet', 'revert', 'abandon', 'repeal', 'lift', 'out-of-scope', 'scrap', 'stop', 'supersed'],
|
|
55
|
+
};
|
|
56
|
+
const FAMILY_OF = new Map();
|
|
57
|
+
for (const [k, list] of Object.entries(FAMILY)) for (const w of list) FAMILY_OF.set(w, k);
|
|
58
|
+
const KIND_WEIGHT = { decision: 1.15, adr: 1.0, amendment: 0.95, 'pm-decision': 1.0, 'out-of-scope': 1.1, initiative: 0.85, meeting: 1.0, claim: 0.6, ticket: 0.7, discovery: 0.55 };
|
|
59
|
+
const DECIDED = new Set(['decision', 'adr', 'amendment', 'pm-decision', 'out-of-scope', 'initiative', 'meeting']);
|
|
60
|
+
const QUOTE_MAX = 220;
|
|
61
|
+
const TITLE_X = 3;
|
|
62
|
+
const K1 = 1.2, B = 0.75;
|
|
63
|
+
const CONF = { high: 0.75, medium: 0.6, low: 0.45 };
|
|
64
|
+
|
|
65
|
+
/** A light stem: enough that "retired", "retiring" and "retire" meet, never so much that two words merge. */
|
|
66
|
+
function stem(w) {
|
|
67
|
+
let s = w;
|
|
68
|
+
if (s.length > 5 && s.endsWith('ies')) s = s.slice(0, -3) + 'y';
|
|
69
|
+
else if (s.length > 5 && /(ing|ion)$/.test(s)) s = s.slice(0, -3);
|
|
70
|
+
else if (s.length > 4 && s.endsWith('ed')) s = s.slice(0, -2);
|
|
71
|
+
else if (s.length > 4 && s.endsWith('es') && !s.endsWith('ses')) s = s.slice(0, -2);
|
|
72
|
+
else if (s.length > 3 && s.endsWith('s') && !s.endsWith('ss')) s = s.slice(0, -1);
|
|
73
|
+
if (s.length > 4 && s.endsWith('e')) s = s.slice(0, -1);
|
|
74
|
+
return s;
|
|
75
|
+
}
|
|
76
|
+
const fold = (t) => String(t == null ? '' : t).toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '');
|
|
77
|
+
function words(t) { return fold(t).replace(/\bout of scope\b/g, 'out-of-scope').match(/[a-z0-9][a-z0-9-]*[a-z0-9]|[a-z0-9]/g) || []; }
|
|
78
|
+
/** A word as the index holds it: a D-id stays whole ("d66"), a family word becomes its family, else its stem. */
|
|
79
|
+
function termOf(w) {
|
|
80
|
+
const d = /^d0*(\d{2,3})$/.exec(w); if (d) return 'd' + d[1];
|
|
81
|
+
if (w === 'out-of-scope') return 'drop';
|
|
82
|
+
const s = stem(w);
|
|
83
|
+
for (const [root, fam] of FAMILY_OF) if (s === root || s.startsWith(root) && s.length - root.length <= 3) return fam;
|
|
84
|
+
return s;
|
|
85
|
+
}
|
|
86
|
+
function termsOf(text, { keepStop = false } = {}) {
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const w of words(text)) {
|
|
89
|
+
if (w.includes('-')) { for (const p of w.split('-')) if (p && (keepStop || !STOP.has(p)) && p.length > 1) out.push(termOf(p)); if (w === 'out-of-scope') out.push('drop'); continue; }
|
|
90
|
+
if (!keepStop && STOP.has(w)) continue;
|
|
91
|
+
if (w.length < 2 && !/\d/.test(w)) continue;
|
|
92
|
+
out.push(termOf(w));
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
const did = (s) => { const m = /\bD0*(\d{2,3})\b/i.exec(String(s || '')); return m ? 'D' + (m[1].length < 2 ? '0' + m[1] : m[1]) : null; };
|
|
97
|
+
const dids = (s) => [...new Set((String(s || '').match(/\bD\d{2,3}\b/g) || []))];
|
|
98
|
+
const isoOf = (s) => { const m = /\b(20\d{2}-\d{2}-\d{2})\b/.exec(String(s || '')); return m ? m[1] : null; };
|
|
99
|
+
|
|
100
|
+
/* ── frontmatter (one-line `key: value`, as lib/related-docs.js reads it) ── */
|
|
101
|
+
function front(text) {
|
|
102
|
+
const m = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(String(text || ''));
|
|
103
|
+
if (!m) return { fm: {}, bodyLine: 1 };
|
|
104
|
+
const fm = {};
|
|
105
|
+
for (const l of m[1].split('\n')) { const k = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(l); if (k) fm[k[1]] = k[2].replace(/^["']|["']$/g, '').trim(); }
|
|
106
|
+
return { fm, bodyLine: m[0].split('\n').length };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ── cutting files into entries ── */
|
|
110
|
+
function sections(path, text, re, make) {
|
|
111
|
+
const lines = String(text || '').split('\n');
|
|
112
|
+
const out = [];
|
|
113
|
+
let cur = null, fence = false;
|
|
114
|
+
for (let i = 0; i < lines.length; i++) {
|
|
115
|
+
if (/^\s{0,3}(```|~~~)/.test(lines[i])) fence = !fence;
|
|
116
|
+
const m = !fence && re.exec(lines[i]);
|
|
117
|
+
if (m) { if (cur) out.push(cur); cur = make(m, i + 1); if (cur) cur.lines = [lines[i]]; continue; }
|
|
118
|
+
if (cur) { if (/^#{1,2}\s/.test(lines[i]) && !fence) { out.push(cur); cur = null; continue; } cur.lines.push(lines[i]); }
|
|
119
|
+
}
|
|
120
|
+
if (cur) out.push(cur);
|
|
121
|
+
return out.map((e) => ({ ...e, path, text: e.lines.join('\n') }));
|
|
122
|
+
}
|
|
123
|
+
function wholeFile(path, text, kind, extra = {}) {
|
|
124
|
+
const { fm, bodyLine } = front(text);
|
|
125
|
+
const lines = String(text || '').split('\n');
|
|
126
|
+
const h1 = lines.findIndex((l, i) => i >= bodyLine - 1 && /^#\s+/.test(l));
|
|
127
|
+
const title = fm.title || (h1 >= 0 ? lines[h1].replace(/^#\s+/, '') : path.split('/').pop().replace(/\.md$/, ''));
|
|
128
|
+
return { kind, path, line: h1 >= 0 ? h1 + 1 : bodyLine, title, date: isoOf(fm.date) || isoOf(fm.timestamp), text: lines.slice(bodyLine - 1).join('\n'), lines: lines.slice(bodyLine - 1), from: bodyLine, fm, ...extra };
|
|
129
|
+
}
|
|
130
|
+
function entriesOf(file) {
|
|
131
|
+
const p = file.path, t = file.text;
|
|
132
|
+
if (p === 'DECISIONS.md') {
|
|
133
|
+
const dec = sections(p, t, /^###\s+(D\d{2,3})\s+[—–-]\s+(.+?)\s*$/, (m, line) => ({ kind: 'decision', id: m[1], title: m[1] + ' — ' + m[2], line, from: line }));
|
|
134
|
+
const amend = [];
|
|
135
|
+
const lines = String(t).split('\n');
|
|
136
|
+
const logAt = lines.findIndex((l) => /^##\s+Amendment log/i.test(l));
|
|
137
|
+
if (logAt >= 0) for (let i = logAt + 1; i < lines.length; i++) {
|
|
138
|
+
const m = /^-\s+(20\d{2}-\d{2}-\d{2})\s+[—–-]\s+(.+)$/.exec(lines[i]);
|
|
139
|
+
if (!m) continue;
|
|
140
|
+
const ids = dids(m[2]);
|
|
141
|
+
amend.push({ kind: 'amendment', id: ids[0] || null, ids, path: p, line: i + 1, from: i + 1, date: m[1], title: 'Amendment ' + m[1] + (ids.length ? ' (' + ids.join(', ') + ')' : ''), text: m[2], lines: [lines[i]] });
|
|
142
|
+
}
|
|
143
|
+
return dec.concat(amend);
|
|
144
|
+
}
|
|
145
|
+
if (p === 'pm-decisions.md') return sections(p, t, /^##\s+(20\d{2}-\d{2}-\d{2})\s+[—–-]\s+(.+?)\s*$/, (m, line) => ({ kind: 'pm-decision', title: m[2], date: m[1], line, from: line, ids: dids(m[2]) }));
|
|
146
|
+
if (p === 'discoveries.md') return sections(p, t, /^##\s+(.+?)\s*$/, (m, line) => ({ kind: 'discovery', title: m[1], date: isoOf(m[1]), line, from: line }));
|
|
147
|
+
let m = /^okf\/products\/pmos\/adr\/(d\d{2,3})[-.]/i.exec(p);
|
|
148
|
+
if (m) { const e = wholeFile(p, t, 'adr'); e.id = 'D' + m[1].slice(1).replace(/^0(?=\d{2})/, ''); return [e]; }
|
|
149
|
+
if (/^planning\/out-of-scope\/(?!README\.md$|index\.md$)[^/]+\.md$/.test(p)) {
|
|
150
|
+
const e = wholeFile(p, t, 'out-of-scope');
|
|
151
|
+
const dl = /^-\s*date:\s*(.+)$/m.exec(e.text); if (dl && !e.date) e.date = isoOf(dl[1]);
|
|
152
|
+
return [e];
|
|
153
|
+
}
|
|
154
|
+
if (/^planning\/initiatives\/[^/]+\.md$/.test(p) && !/\/(index|README)\.md$/.test(p)) {
|
|
155
|
+
const e = wholeFile(p, t, 'initiative');
|
|
156
|
+
e.id = e.fm.id || null; e.status = e.fm.status || '';
|
|
157
|
+
// the record's own words about where it stands count as text (a "status:" line often holds the why of a drop)
|
|
158
|
+
if (e.status) e.text = e.title + '\n' + e.status + '\n' + e.text;
|
|
159
|
+
return [e];
|
|
160
|
+
}
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* ── the index ── */
|
|
165
|
+
function indexEntry(e) {
|
|
166
|
+
const tTerms = termsOf(e.title || ''), bTerms = termsOf(e.text || '');
|
|
167
|
+
const tf = new Map();
|
|
168
|
+
for (const w of tTerms) tf.set(w, (tf.get(w) || 0) + TITLE_X);
|
|
169
|
+
for (const w of bTerms) tf.set(w, (tf.get(w) || 0) + 1);
|
|
170
|
+
for (const x of e.ids || (e.id ? [e.id] : [])) { const k = 'd' + String(x).slice(1).replace(/^0(?=\d)/, ''); tf.set(k, (tf.get(k) || 0) + TITLE_X); }
|
|
171
|
+
e.tf = tf; e.len = tTerms.length * TITLE_X + bTerms.length;
|
|
172
|
+
return e;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** files: [{ path, text }]; records: [{ kind, ref, title, text, date, quote?, line? }] from the per-user state folder. */
|
|
176
|
+
function build(files, records = []) {
|
|
177
|
+
const entries = [];
|
|
178
|
+
const adrMeta = new Map();
|
|
179
|
+
for (const f of files || []) {
|
|
180
|
+
if (!f || typeof f.path !== 'string') continue;
|
|
181
|
+
for (const e of entriesOf(f)) entries.push(e);
|
|
182
|
+
}
|
|
183
|
+
for (const e of entries) if (e.kind === 'adr' && e.id) adrMeta.set(e.id, { superseded_by: e.fm.superseded_by || e.fm.supersededBy || '', amended_by: e.fm.amended_by || '', status: e.fm.status || '' });
|
|
184
|
+
for (const r of records || []) {
|
|
185
|
+
if (!r || !r.ref || !(r.text || r.title)) continue;
|
|
186
|
+
const m = /^(.*):(\d+)$/.exec(String(r.ref));
|
|
187
|
+
entries.push({ kind: r.kind || 'meeting', path: m ? m[1] : String(r.ref), line: m ? +m[2] : (r.line || 1), from: m ? +m[2] : 1, title: String(r.title || r.text).slice(0, 160), date: isoOf(r.date) || null,
|
|
188
|
+
text: String(r.text || ''), lines: [String(r.quote || r.text || '')], fixedQuote: r.quote || null, ids: dids(r.text) });
|
|
189
|
+
}
|
|
190
|
+
// decision status: lib/decision-ledger.js (decision memory) when it is here, else the ADR's own keys
|
|
191
|
+
let ledger = null;
|
|
192
|
+
try {
|
|
193
|
+
const DL = require('./decision-ledger.js');
|
|
194
|
+
const dec = (files || []).find((f) => f && f.path === 'DECISIONS.md');
|
|
195
|
+
if (DL && typeof DL.parse === 'function' && dec) {
|
|
196
|
+
ledger = DL.parse(dec.text, { path: 'DECISIONS.md' });
|
|
197
|
+
if (typeof DL.addAdr === 'function') for (const f of files || []) if (f && /^okf\/products\/pmos\/adr\//.test(f.path)) DL.addAdr(ledger, f);
|
|
198
|
+
}
|
|
199
|
+
} catch { ledger = null; }
|
|
200
|
+
const status = new Map();
|
|
201
|
+
if (ledger && ledger.decisions instanceof Map) for (const d of ledger.decisions.values()) status.set(d.id, { status: d.status || 'active', supersededBy: d.supersededBy || [], amendedBy: d.amendedBy || [], ...(d.partial ? { partial: true } : {}) });
|
|
202
|
+
// the ADR's own keys fill in what the ledger does not say (or stand alone when there is no ledger)
|
|
203
|
+
for (const [id, a] of adrMeta) {
|
|
204
|
+
const sup = dids(a.superseded_by), am = dids(a.amended_by), had = status.get(id);
|
|
205
|
+
if (had && had.status !== 'active') continue;
|
|
206
|
+
const st = sup.length || /supersed|revert/i.test(a.status) ? 'superseded' : am.length ? 'amended' : 'active';
|
|
207
|
+
if (had && st === 'active') continue;
|
|
208
|
+
status.set(id, { status: st, supersededBy: sup, amendedBy: am });
|
|
209
|
+
}
|
|
210
|
+
for (const e of entries) indexEntry(e);
|
|
211
|
+
const df = new Map();
|
|
212
|
+
for (const e of entries) for (const w of e.tf.keys()) df.set(w, (df.get(w) || 0) + 1);
|
|
213
|
+
const avg = entries.reduce((s, e) => s + e.len, 0) / Math.max(1, entries.length);
|
|
214
|
+
const lineSet = new Map();
|
|
215
|
+
const recPaths = new Set(entries.map((e) => e.path));
|
|
216
|
+
for (const f of files || []) if (f && typeof f.path === 'string' && recPaths.has(f.path)) lineSet.set(f.path, String(f.text || '').split('\n').length);
|
|
217
|
+
for (const r of records || []) if (r && r.ref) { const m = /^(.*):(\d+)$/.exec(String(r.ref)); const p = m ? m[1] : String(r.ref); lineSet.set(p, Math.max(lineSet.get(p) || 0, m ? +m[2] + 2 : 3)); }
|
|
218
|
+
const kinds = {};
|
|
219
|
+
for (const e of entries) kinds[e.kind] = (kinds[e.kind] || 0) + 1;
|
|
220
|
+
return { entries, df, avg, n: entries.length, status, lineSet, kinds, ledger: !!ledger };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const idf = (C, w) => { const n = C.df.get(w) || 0; return Math.log(1 + (C.n - n + 0.5) / (n + 0.5)); };
|
|
224
|
+
function bm25(C, e, q) {
|
|
225
|
+
let s = 0;
|
|
226
|
+
for (const w of q) {
|
|
227
|
+
const f = e.tf.get(w); if (!f) continue;
|
|
228
|
+
s += idf(C, w) * (f * (K1 + 1)) / (f + K1 * (1 - B + B * e.len / C.avg));
|
|
229
|
+
}
|
|
230
|
+
return s;
|
|
231
|
+
}
|
|
232
|
+
/** The line of the entry that holds most of the question (by weight): its own number and its own words. */
|
|
233
|
+
function bestLine(C, e, q, at = 0) {
|
|
234
|
+
if (e.fixedQuote) return { line: e.line, quote: clip(e.fixedQuote) };
|
|
235
|
+
let best = { line: e.line, quote: clip((e.lines || [])[0] || e.title), w: -1 };
|
|
236
|
+
const ls = e.lines || [];
|
|
237
|
+
for (let i = at; i < Math.min(ls.length, at + WINDOW); i++) {
|
|
238
|
+
const raw = ls[i]; if (!raw || !raw.trim() || /^---\s*$/.test(raw)) continue;
|
|
239
|
+
const have = new Set(termsOf(raw));
|
|
240
|
+
let w = 0; for (const t of q) if (have.has(t)) w += idf(C, t);
|
|
241
|
+
if (w > best.w + 1e-9) best = { line: e.from + i, quote: clip(raw), w };
|
|
242
|
+
}
|
|
243
|
+
return best;
|
|
244
|
+
}
|
|
245
|
+
function clip(s) {
|
|
246
|
+
let t = String(s == null ? '' : s).trim().replace(/^[-*>#\s]+/, '').trim();
|
|
247
|
+
if (t.length > QUOTE_MAX) { t = t.slice(0, QUOTE_MAX); const sp = t.lastIndexOf(' '); if (sp > QUOTE_MAX * 0.6) t = t.slice(0, sp); }
|
|
248
|
+
return t;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const WINDOW = 4;
|
|
252
|
+
/** The terms of the entry's title, joined with the best run of WINDOW lines (by how many of the question's terms it holds). */
|
|
253
|
+
function local(e, q) {
|
|
254
|
+
const title = new Set(termsOf(e.title || ''));
|
|
255
|
+
for (const x of e.ids || (e.id ? [e.id] : [])) title.add('d' + String(x).slice(1).replace(/^0(?=\d)/, ''));
|
|
256
|
+
const ls = e.lines || [];
|
|
257
|
+
const per = ls.map((l) => new Set(termsOf(l)));
|
|
258
|
+
let best = { n: -1, terms: title, at: 0 };
|
|
259
|
+
for (let i = 0; i < Math.max(1, per.length); i++) {
|
|
260
|
+
const u = new Set(title);
|
|
261
|
+
for (let j = i; j < Math.min(per.length, i + WINDOW); j++) for (const t of per[j]) u.add(t);
|
|
262
|
+
let n = 0; for (const t of q) if (u.has(t)) n++;
|
|
263
|
+
if (n > best.n) best = { n, terms: u, at: i };
|
|
264
|
+
}
|
|
265
|
+
return best;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function statusOf(C, id) { return (C && C.status && C.status.get(id)) || null; }
|
|
269
|
+
|
|
270
|
+
function rank(C, text, { limit = 5, kinds = null } = {}) {
|
|
271
|
+
const all = termsOf(text);
|
|
272
|
+
const q = [...new Set(all)];
|
|
273
|
+
if (!q.length) return { hits: [], none: true, confidence: 'none', terms: [] };
|
|
274
|
+
// the weight of what was asked: the family word counts less than the subject (it says how, not what)
|
|
275
|
+
const wq = (t) => idf(C, t) * (t === 'drop' ? 0.35 : 1);
|
|
276
|
+
const total = q.reduce((s, t) => s + wq(t), 0) || 1;
|
|
277
|
+
const asked = new Set(q.filter((t) => /^d\d{2,3}$/.test(t)));
|
|
278
|
+
const scored = [];
|
|
279
|
+
for (const e of C.entries) {
|
|
280
|
+
if (kinds && !kinds.has(e.kind)) continue;
|
|
281
|
+
let s = bm25(C, e, q);
|
|
282
|
+
if (!s) continue;
|
|
283
|
+
s *= (KIND_WEIGHT[e.kind] || 0.8);
|
|
284
|
+
// a D-id the question names finds that decision first
|
|
285
|
+
if (asked.size && [...(e.ids || (e.id ? [e.id] : []))].some((d) => asked.has('d' + String(d).slice(1).replace(/^0(?=\d)/, '')))) s *= 2.5;
|
|
286
|
+
scored.push({ e, s });
|
|
287
|
+
}
|
|
288
|
+
scored.sort((a, b) => b.s - a.s);
|
|
289
|
+
// what the question asks must sit TOGETHER: in the title, or within a few lines of each other (a long record that names each word
|
|
290
|
+
// somewhere is not an answer). The cover is that local share, and it is what the confidence reads.
|
|
291
|
+
const pool = scored.slice(0, 60);
|
|
292
|
+
for (const x of pool) {
|
|
293
|
+
const L = local(x.e, q);
|
|
294
|
+
let have = 0; for (const t of q) if (L.terms.has(t)) have += wq(t);
|
|
295
|
+
x.cover = have / total; x.verb = !q.includes('drop') || L.terms.has('drop'); x.at = L.at;
|
|
296
|
+
x.s *= (0.4 + x.cover) * (x.verb ? 1 : 0.85);
|
|
297
|
+
}
|
|
298
|
+
scored.length = 0;
|
|
299
|
+
scored.push(...pool);
|
|
300
|
+
scored.sort((a, b) => b.s - a.s || String(a.e.path).localeCompare(String(b.e.path)) || a.e.line - b.e.line);
|
|
301
|
+
// one hit per decision: the section, its ADR and its amendment lines are the same decision; keep the best of each, then the next one
|
|
302
|
+
const seen = new Set(), hits = [];
|
|
303
|
+
for (const x of scored) {
|
|
304
|
+
const key = x.e.kind === 'amendment' ? x.e.path + ':' + x.e.line : (x.e.id && (x.e.kind === 'decision' || x.e.kind === 'adr') ? 'D:' + x.e.id : x.e.path + ':' + x.e.line);
|
|
305
|
+
if (seen.has(key)) continue; seen.add(key);
|
|
306
|
+
const b = bestLine(C, x.e, q, x.at);
|
|
307
|
+
const st = x.e.id && (x.e.kind === 'decision' || x.e.kind === 'adr') ? statusOf(C, x.e.id) : null;
|
|
308
|
+
hits.push({ kind: x.e.kind, ...(x.e.id ? { id: x.e.id } : {}), path: x.e.path, line: b.line, ref: x.e.path + ':' + b.line, title: x.e.title, date: x.e.date || null, quote: b.quote,
|
|
309
|
+
score: Math.round(x.s * 1000) / 1000, cover: Math.round(x.cover * 100) / 100, ...(x.verb ? {} : { dropSaid: false }), ...(x.e.kind === 'initiative' && x.e.status ? { recordStatus: clip(x.e.status) } : {}),
|
|
310
|
+
...(st && st.status !== 'active' ? { status: st.status, supersededBy: st.supersededBy, amendedBy: st.amendedBy, ...(st.partial ? { partial: true } : {}) } : {}) });
|
|
311
|
+
if (hits.length >= limit) break;
|
|
312
|
+
}
|
|
313
|
+
const top = hits[0];
|
|
314
|
+
let confidence = 'none';
|
|
315
|
+
if (top) {
|
|
316
|
+
if (top.dropSaid === false && top.cover >= CONF.low) confidence = 'low';
|
|
317
|
+
else if (top.cover >= CONF.high && hits.slice(1).some((h) => h.cover >= 0.5)) confidence = 'high';
|
|
318
|
+
else if (top.cover >= CONF.medium) confidence = 'medium';
|
|
319
|
+
else if (top.cover >= CONF.low) confidence = 'low';
|
|
320
|
+
}
|
|
321
|
+
const none = confidence === 'none';
|
|
322
|
+
return { hits: none ? [] : hits.filter((h) => h.cover >= CONF.low * 0.8), none, confidence, terms: q };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** "why did we drop X?" → the passages that answer it, best first. */
|
|
326
|
+
function search(C, question, o = {}) { return rank(C, question, { limit: o.limit || 5, kinds: o.kinds ? new Set(o.kinds) : null }); }
|
|
327
|
+
|
|
328
|
+
/** A new idea → what was decided or declined about it before. The run log is not a decision; an idea is long, so it is cut to its
|
|
329
|
+
* 12 most telling words first (the words rarest across the records), and the bar is the same honest one. */
|
|
330
|
+
function repeats(C, idea, o = {}) {
|
|
331
|
+
// a word no record holds still counts (rarest of all): an idea made of new words is not a repeat, however well one old word matches
|
|
332
|
+
const q = [...new Set(termsOf(idea))].sort((a, b) => idf(C, b) - idf(C, a) || a.localeCompare(b)).slice(0, o.words || 12);
|
|
333
|
+
const r = rank(C, q.join(' '), { limit: o.limit || 5, kinds: new Set(o.kinds || [...DECIDED]) });
|
|
334
|
+
return { hits: r.hits, none: r.none, confidence: r.confidence, terms: r.terms };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Every `path:line` an answer cites must be one the records hold, at a line that exists. */
|
|
338
|
+
function verify(C, text) {
|
|
339
|
+
const refs = [];
|
|
340
|
+
const re = /(?:^|[\s(`[])((?:state:)?[A-Za-z0-9_./-]+\.(?:md|jsonl|json)):(\d+)\b/g;
|
|
341
|
+
let m;
|
|
342
|
+
while ((m = re.exec(String(text || '')))) {
|
|
343
|
+
const p = m[1].replace(/^\.\//, ''), line = +m[2];
|
|
344
|
+
const n = C.lineSet.get(p);
|
|
345
|
+
refs.push(n == null ? { ref: p + ':' + line, ok: false, why: 'not a record PMOS read' } : line < 1 || line > n ? { ref: p + ':' + line, ok: false, why: 'that line is not in the file' } : { ref: p + ':' + line, ok: true });
|
|
346
|
+
}
|
|
347
|
+
return { refs, ok: refs.length > 0 && refs.every((r) => r.ok) };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const ASK_RE = /(\bwhy (did|didn'?t|had) (we|i|you|pmos|the team)\b|\bwhy (was|were) .{1,80}\b(dropped|rejected|retired|removed|declined|chosen|picked|stopped|cut|killed|reverted|deprecated|decided|added|made|changed)\b|\bwhy (did|do) we (ever )?(decide|choose|pick|drop|stop|reject|retire|remove)\b|\bwhen did (we|i|you|pmos)\b|\bdid (we|i|you) (ever|already|decide|drop|try|reject|retire)\b|\bha(ve|s) (we|i|you|pmos) (ever|already) (decided|dropped|tried|rejected|retired|discussed|done)\b|\bwhat did (we|i|you) decide\b|\b(we|i) decided (this|that|it) before\b|\bis (this|that|it) a repeat\b|\bask (pmos )?(about )?the past\b)/i;
|
|
351
|
+
const isPastAsk = (text) => { const s = String(text == null ? '' : text).trim(); return !s.startsWith('/') && ASK_RE.test(s); };
|
|
352
|
+
|
|
353
|
+
module.exports = { build, search, repeats, verify, isPastAsk, statusOf, termsOf, stem, entriesOf, CONF, KIND_WEIGHT, QUOTE_MAX };
|
|
@@ -56,6 +56,9 @@ const VERBS = Object.freeze({
|
|
|
56
56
|
// Not a pm-os-absorb verb (run: null): briefing-source-flow added it through the same table.
|
|
57
57
|
source: Object.freeze({ module: 'source-cli.js', run: null, guide: 'data-freshness.md',
|
|
58
58
|
help: 'pmos source save|status|show — the stored read of each tool the Briefing uses: Mail, Slack, Confluence and the rest (pmos source help)' }),
|
|
59
|
+
// Not a pm-os-absorb verb (run: null): ask-the-past added it through the same table.
|
|
60
|
+
past: Object.freeze({ module: 'past-cli.js', run: null, guide: 'ask-the-past.md',
|
|
61
|
+
help: 'pmos past ask|check|verify|sources … — why did we decide or drop something: the records that say so, each with its line (pmos past help)' }),
|
|
59
62
|
// Not a pm-os-absorb verb (run: null): Home's Mentions A+ (premium-agreed-design) added it through the same table.
|
|
60
63
|
mention: Object.freeze({ module: 'mention-cli.js', run: null, guide: 'home.md',
|
|
61
64
|
help: 'pmos mention list|draft — Home\'s Mentions: what waits on you, and the proposed reply Claude changes from a chat (pmos mention help)' }),
|
|
@@ -78,6 +81,9 @@ const VERBS = Object.freeze({
|
|
|
78
81
|
// Not a pm-os-absorb verb (run: null): feedback batch 12 items 2, 3 and 4 (Claude proposes, the one ✦) added it through the same table.
|
|
79
82
|
propose: Object.freeze({ module: 'propose-cli.js', run: null, guide: 'claude-proposes.md',
|
|
80
83
|
help: 'pmos propose look|found|date|done|name|status … — what Claude\'s run files under an initiative\'s ✦ for the PM to keep or drop (pmos propose help)' }),
|
|
84
|
+
// Not a pm-os-absorb verb (run: null): the AI-ideas round (2026-11-03) added it through the same table: the kill switch and audit of lib/ai-guard.js.
|
|
85
|
+
ai: Object.freeze({ module: 'ai-cli.js', run: null, guide: 'claude-proposes.md',
|
|
86
|
+
help: 'pmos ai status|off|on|log … — the AI jobs: their kill switch, replay state and audit (pmos ai help)' }),
|
|
81
87
|
// Not a pm-os-absorb verb (run: null): Grill and Forecast part G-A (Claude grills an initiative by itself) added it through the same table.
|
|
82
88
|
grill: Object.freeze({ module: 'grill-cli.js', run: null, guide: 'claude-proposes.md',
|
|
83
89
|
help: 'pmos grill find|voices|status|mode|cap|spend … — what Claude\'s grill run files under an initiative\'s ✦, and when it grills and what it may spend (pmos grill help)' }),
|
|
@@ -87,6 +93,9 @@ const VERBS = Object.freeze({
|
|
|
87
93
|
// Not a pm-os-absorb verb (run: null): batch 16 item 7 (Your impact) added it through the same table.
|
|
88
94
|
impact: Object.freeze({ module: 'impact-cli.js', run: null, guide: 'your-impact.md',
|
|
89
95
|
help: 'pmos impact add|end|status … — what Claude\'s impact run files for you to check, ✓ real or ✕ not real (pmos impact help)' }),
|
|
96
|
+
// Not a pm-os-absorb verb (run: null): initiative weekly-drift-check added it through the same table.
|
|
97
|
+
drift: Object.freeze({ module: 'drift-cli.js', run: null, guide: 'what-you-missed.md',
|
|
98
|
+
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)' }),
|
|
90
99
|
driver: Object.freeze({ module: 'driver-cli.js', run: null, guide: 'initiative-driver.md',
|
|
91
100
|
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)' }),
|
|
92
101
|
// 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.
|
|
@@ -71,6 +71,7 @@ function create(deps) {
|
|
|
71
71
|
const active = new Map(); // run → { ws, ini, dir, session, why }
|
|
72
72
|
const warn = deps.warn || (() => {});
|
|
73
73
|
const lastScan = new Map(); // ws → ms of the last morning-check scan
|
|
74
|
+
const pastSeen = new Set(); // ws|initiative: the "You decided this before" look ran (ask-the-past)
|
|
74
75
|
|
|
75
76
|
function ended(run, r, now = Date.now()) {
|
|
76
77
|
const a = active.get(run); if (!a) return;
|
|
@@ -170,6 +171,24 @@ function create(deps) {
|
|
|
170
171
|
return null;
|
|
171
172
|
}
|
|
172
173
|
|
|
174
|
+
/* ask-the-past: a new initiative that repeats what was decided or declined before gets one "You decided this before" finding under its ✦
|
|
175
|
+
* (lib/past-repeat.js, through lib/ai-guard.js). Deterministic and local: no session, no model. Once per initiative while the server runs;
|
|
176
|
+
* a second pass files the same id, so a restart repeats nothing the PM sees. */
|
|
177
|
+
function pastOnce(w, dir, ini) {
|
|
178
|
+
const k = w.id + '|' + ini;
|
|
179
|
+
if (pastSeen.has(k) || !w.root) return;
|
|
180
|
+
pastSeen.add(k);
|
|
181
|
+
let row = {};
|
|
182
|
+
try { row = (deps.rowOf && deps.rowOf(w, ini)) || {}; } catch { row = {}; }
|
|
183
|
+
let board = '';
|
|
184
|
+
try { board = (deps.boardOf && deps.boardOf(w, ini)) || ''; } catch { board = ''; }
|
|
185
|
+
const PRP = require('./past-repeat.js');
|
|
186
|
+
let runtime = null;
|
|
187
|
+
try { runtime = require('./state-root.js').defaultRoot(); } catch { runtime = null; }
|
|
188
|
+
Promise.resolve().then(() => PRP.file({ root: w.root, dir, runtime, ws: w.id, board, ini, title: row.title || ini, intent: row.intent || '', env: deps.env || process.env }))
|
|
189
|
+
.catch((e) => warn('you decided this before', e));
|
|
190
|
+
}
|
|
191
|
+
|
|
173
192
|
function stop(run) { const a = active.get(run); if (a && a.session && deps.stop) deps.stop(a.session); }
|
|
174
193
|
|
|
175
194
|
function tick({ now = Date.now(), env } = {}) {
|
|
@@ -188,6 +207,7 @@ function create(deps) {
|
|
|
188
207
|
try { created = deps.created(w) || []; } catch { created = []; }
|
|
189
208
|
for (const c of created) {
|
|
190
209
|
if (!c || !c.initiative || String(c.at || '') < epoch || F.looks.has(c.initiative)) continue;
|
|
210
|
+
pastOnce(w, dir, c.initiative);
|
|
191
211
|
const r = start({ ws: w.id, ini: c.initiative, why: 'populate', press: false, now });
|
|
192
212
|
if (r.ok) return { started: c.initiative, why: 'populate' };
|
|
193
213
|
if (r.held) return { held: true }; // the governor said wait: the next tick tries again
|
|
@@ -114,6 +114,7 @@ const MODULES = Object.freeze([
|
|
|
114
114
|
'routes-goal-status', // batch 16 item 6: the goals' one status from readings (product page), notify choice, recipe, Read now, test read, CSV readings
|
|
115
115
|
'routes-refresh', // data-refresh-settings: Settings › Data refresh: what refreshes, when, the refresh log, Refresh now
|
|
116
116
|
'routes-impact-views', // batch 16 item 7 (Your impact, Phase 4): the chapter card's read, a stage that closed this week (boards.jsonl; read-only)
|
|
117
|
+
'routes-drift', // weekly-drift-check: What would I have missed, Home's weekly ✦ and its ⋯ drop and Undo (runtime drift/drift.jsonl; page-press POST)
|
|
117
118
|
]);
|
|
118
119
|
const PM_DIR = path.join(__dirname, '..', 'public', 'pm');
|
|
119
120
|
const MODULE_NAME = /^[a-z0-9][a-z0-9-]*\.js$/;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-drift.js — "What would I have missed" over HTTP (initiative weekly-drift-check; lib/drift.js). The ledger is the person's own, in the
|
|
3
|
+
* runtime folder (<runtime>/drift/drift.jsonl): outside every repository. Nothing here sends anything, and nothing here starts the check: it
|
|
4
|
+
* runs by itself each Monday (lib/drift-runs.js).
|
|
5
|
+
*
|
|
6
|
+
* GET /api/drift/check → { ok, on, check: { week, label, looked, items, notRead } | null } Home's ✦ for last week; null in a quiet
|
|
7
|
+
* week, before the product's replay passed, or when everything shown was dropped
|
|
8
|
+
* POST /api/drift/drop { id } → { ok } ⋯ "Not right, drop it": it never comes back (PRD R5)
|
|
9
|
+
* POST /api/drift/undo { id } → { ok } the drop taken back
|
|
10
|
+
* POST /api/drift/looked { week } → { ok } the card was shown for that week
|
|
11
|
+
*
|
|
12
|
+
* THE SWITCH. PMOS_DRIFT=off, or the shared guard's switch (lib/ai-guard.js off: PMOS_AI, the ai settings): every GET answers { ok, on: false }
|
|
13
|
+
* and every POST 409. WHO WRITES: a POST that carries an agent session header is refused (403); pm-guard requirePagePress checks the page. */
|
|
14
|
+
const guard = require('./pm-guard.js');
|
|
15
|
+
const D = require('./drift.js');
|
|
16
|
+
const R = require('./drift-runs.js');
|
|
17
|
+
|
|
18
|
+
const text = (v) => (typeof v === 'string' ? v : '');
|
|
19
|
+
const bad = (status, error) => ({ status, body: { ok: false, error } });
|
|
20
|
+
const nowMs = (ctx) => { const t = Date.parse((ctx.env || process.env).PMOS_DRIFT_NOW || ''); return Number.isFinite(t) ? t : Date.now(); };
|
|
21
|
+
function isOn(ctx) {
|
|
22
|
+
const env = ctx.env || process.env;
|
|
23
|
+
if (R.off(env)) return false;
|
|
24
|
+
try { const AG = require('./ai-guard.js'); return !AG.off(D.JOB, { env, settingsDir: ctx.runtimeDir }); } catch { return true; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function handle(req, res, url, ctx) {
|
|
28
|
+
const send = (r) => { ctx.json(res, r.body, r.status); return true; };
|
|
29
|
+
const what = url.pathname.replace(/^\/api\/drift\/?/, '');
|
|
30
|
+
const dir = D.dirOf(ctx.runtimeDir);
|
|
31
|
+
if (what === 'check') {
|
|
32
|
+
if (req.method !== 'GET') return send(bad(405, 'GET only'));
|
|
33
|
+
if (!isOn(ctx)) return send({ status: 200, body: { ok: true, on: false } });
|
|
34
|
+
const F = dir ? D.read(dir) : D.fold([]);
|
|
35
|
+
return send({ status: 200, body: { ok: true, on: true, check: D.checkView(F, D.weekOf(nowMs(ctx))) } });
|
|
36
|
+
}
|
|
37
|
+
if (!['drop', 'undo', 'looked'].includes(what)) return send(bad(404, 'no such drift route'));
|
|
38
|
+
if (req.method !== 'POST') return send(bad(405, 'POST only'));
|
|
39
|
+
if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'Only you drop an item, from the page.'));
|
|
40
|
+
const rd = await ctx.readJson(req);
|
|
41
|
+
if (!rd.ok) return send(bad(400, 'the body is not JSON'));
|
|
42
|
+
const body = rd.body || {};
|
|
43
|
+
const g = guard.requirePagePress(req, body);
|
|
44
|
+
if (!g.ok) return send(bad(g.status, g.error));
|
|
45
|
+
if (!isOn(ctx)) return send(bad(409, 'The weekly check is off.'));
|
|
46
|
+
if (!dir) return send(bad(409, 'There is no runtime folder.'));
|
|
47
|
+
const F = D.read(dir), at = new Date(nowMs(ctx)).toISOString();
|
|
48
|
+
if (what === 'looked') {
|
|
49
|
+
const week = text(body.week);
|
|
50
|
+
if (week !== D.prevWeek(D.weekOf(nowMs(ctx)))) return send(bad(400, 'week is the Monday of last week, YYYY-MM-DD: the week the check shows'));
|
|
51
|
+
if (!F.looked.has(week)) D.append(dir, { kind: 'looked', week }, at);
|
|
52
|
+
return send({ status: 200, body: { ok: true } });
|
|
53
|
+
}
|
|
54
|
+
const id = text(body.id);
|
|
55
|
+
const item = F.weeks.flatMap((w) => w.items).reverse().find((x) => x.id === id);
|
|
56
|
+
if (!item) return send(bad(404, 'There is no such item.'));
|
|
57
|
+
if (what === 'drop') { if (!F.drops.has(id)) D.append(dir, { kind: 'drop', id, level: item.level || 0 }, at); }
|
|
58
|
+
else { if (!F.drops.has(id)) return send(bad(409, 'This item is not dropped.')); D.append(dir, { kind: 'undo', id }, at); }
|
|
59
|
+
return send({ status: 200, body: { ok: true } });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { prefixes: ['/api/drift'], handle };
|