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
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* rehearse-read.js — the snapshot a rehearsal reads (initiative plan-rehearsal; lib/rehearse.js is the pure engine). READ ONLY: it writes
|
|
3
|
+
* nothing, and the server need not run. Every value comes through the readers the pmos verbs use, never from a ledger line by hand:
|
|
4
|
+
*
|
|
5
|
+
* tasks lib/routes-task-links.js frame (the Board's items of every board, Jira tickets and breakdown items too)
|
|
6
|
+
* links lib/task-links.js read (the approved links, folded)
|
|
7
|
+
* rows lib/routes-boards.js baseRows (each initiative's end, the Board's) and lib/initiative-rows.js (its anchor and record)
|
|
8
|
+
* goals the KR metric records (scripts/records) → lib/routes-c3-strategy.js outcomeOf → lib/insights-premium.js goalsOf, each with
|
|
9
|
+
* lib/forecast.js goalForecast and the PM's kept baseline. The server's Insights route also files fixes and records turns;
|
|
10
|
+
* this reads only, so it builds the goals itself
|
|
11
|
+
* programs lib/programs.js fold of this workspace's <state>/programs.jsonl
|
|
12
|
+
*
|
|
13
|
+
* THE SOURCES. A ledger is read twice: once folded (the values), once as lines, only to find the line number and a verbatim quote that
|
|
14
|
+
* an effect cites (`<path>:<line>`, relative to the repository root, as lib/ai-guard.js checks it). The value is never read from that line.
|
|
15
|
+
*
|
|
16
|
+
* snapshotOf(root, { today?, stateDir? }) → snapshot (lib/rehearse.js)
|
|
17
|
+
* refsOf(root, snap, files) → snap.refs · lineRef(root, file, test, label) → src | null
|
|
18
|
+
*/
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const D = require('./driver.js');
|
|
22
|
+
const TL = require('./task-links.js');
|
|
23
|
+
const FC = require('./forecast.js');
|
|
24
|
+
const { repoScript } = require('./repo-script.js');
|
|
25
|
+
|
|
26
|
+
const rel = (root, f) => path.relative(root, f).split(path.sep).join('/');
|
|
27
|
+
const QUOTE = 100, QUOTE_MIN = 12;
|
|
28
|
+
|
|
29
|
+
/** The last line of `file` that passes `test`, as a source: { label, ref: '<path>:<line>', quote }. */
|
|
30
|
+
function lineRef(root, file, test, label) {
|
|
31
|
+
let text = '';
|
|
32
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return null; }
|
|
33
|
+
const lines = text.split('\n');
|
|
34
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
35
|
+
const m = test(lines[i]);
|
|
36
|
+
if (!m) continue;
|
|
37
|
+
let quote = (typeof m === 'string' ? m : lines[i]).trim().slice(0, QUOTE);
|
|
38
|
+
// a short line ("id: promo") is quoted with the line above it, as lib/ai-guard.js reads a quote across nearby lines
|
|
39
|
+
if (quote.length < QUOTE_MIN && i > 0) quote = (lines[i - 1].trim() + ' ' + quote).slice(0, QUOTE);
|
|
40
|
+
return { label, ref: rel(root, file) + ':' + (i + 1), quote };
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const jsonHas = (k, v) => '"' + k + '":' + JSON.stringify(v);
|
|
45
|
+
|
|
46
|
+
function ctxOf(root, stateDir) {
|
|
47
|
+
const w = { id: D.wsIdOf(root), name: path.basename(root), root };
|
|
48
|
+
return { w, ctx: { getWs: () => w, workspaceList: () => [w], stateDirFor: () => stateDir, selfRoot: path.resolve(__dirname, '..', '..') } };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function snapshotOf(root, o = {}) {
|
|
52
|
+
const today = o.today || new Date().toISOString().slice(0, 10);
|
|
53
|
+
const stateDir = o.stateDir || D.stateDirOf(root);
|
|
54
|
+
const missing = [], snap = { today, tasks: [], links: [], rows: [], goals: [], programs: [], refs: { links: {}, rows: {}, goals: {}, programs: {}, tasks: {} }, missing };
|
|
55
|
+
if (!stateDir) { missing.push('the PMOS state folder'); return snap; }
|
|
56
|
+
const RB = require('./routes-boards.js'), TLR = require('./routes-task-links.js'), rowsLib = require('./initiative-rows.js');
|
|
57
|
+
const { w, ctx } = ctxOf(root, stateDir);
|
|
58
|
+
const files = { records: {} };
|
|
59
|
+
|
|
60
|
+
// the initiatives: the Board's end, the record's anchor
|
|
61
|
+
let base = RB.baseRows(w);
|
|
62
|
+
if (base.pending) { try { rowsLib.deriveNow(root); } catch { /* answered below */ } base = RB.baseRows(w); }
|
|
63
|
+
const recOf = new Map();
|
|
64
|
+
try { const got = rowsLib.rowsFor(root); if (got && got.ok) for (const r of got.rows) recOf.set(r.id, r.record || null); } catch { /* no record paths */ }
|
|
65
|
+
if (base.ok) {
|
|
66
|
+
for (const r of base.rows) {
|
|
67
|
+
snap.rows.push({ id: r.id, title: r.title || r.id, end: r.end || null, anchor: r.anchor || null });
|
|
68
|
+
if (recOf.get(r.id)) files.records[r.id] = path.resolve(root, recOf.get(r.id));
|
|
69
|
+
}
|
|
70
|
+
} else missing.push('initiatives');
|
|
71
|
+
|
|
72
|
+
// the tasks of every board, and the approved links
|
|
73
|
+
try {
|
|
74
|
+
const fr = RB.frameFor(ctx, w), seen = new Set();
|
|
75
|
+
for (const b of fr.boards || []) {
|
|
76
|
+
const f = TLR.frame(ctx, w, b, { derive: true });
|
|
77
|
+
if (!f) { missing.push('board ' + b.id); continue; }
|
|
78
|
+
for (const [uid, x] of f.byUid) { if (seen.has(uid)) continue; seen.add(uid); snap.tasks.push(x); }
|
|
79
|
+
}
|
|
80
|
+
} catch { missing.push('tasks'); }
|
|
81
|
+
files.links = TL.ledgerFile(stateDir);
|
|
82
|
+
files.board = safe(() => RB.ledgerFile(ctx, w));
|
|
83
|
+
files.items = safe(() => require('./plan-ledgers.js').ledgerPath(root, 'plan-items'));
|
|
84
|
+
snap.links = TL.linksOf(TL.read(stateDir));
|
|
85
|
+
|
|
86
|
+
// the goals, each with today's forecast
|
|
87
|
+
try {
|
|
88
|
+
const R = require(repoScript('records/index.js')), dir = require('./records-dir.js').recordsDirFor(ctx, w.id);
|
|
89
|
+
const krs = R.list(dir, 'kr-metrics').filter((k) => k.retired !== true);
|
|
90
|
+
const strategy = require('./routes-c3-strategy.js'), Insights = require('./insights-premium.js');
|
|
91
|
+
const outcomes = krs.map((k) => strategy.outcomeOf(k, today)), meta = Object.fromEntries(krs.map((k) => [k.rid, k]));
|
|
92
|
+
const goals = Insights.goalsOf(outcomes, [], base.ok ? base.rows : [], Date.parse(today + 'T00:00:00Z'), meta, null);
|
|
93
|
+
const L = FC.read(stateDir);
|
|
94
|
+
files.krs = R.fileOf(dir, 'kr-metrics');
|
|
95
|
+
for (const g of goals) {
|
|
96
|
+
const forecast = FC.goalForecast(g, { today, baseline: L.baselines.get(g.id) || null });
|
|
97
|
+
snap.goals.push({ id: g.id, title: g.title, byAt: g.byAt, moves: (g.moves || []).map((m) => m.id), forecast: forecast ? { state: forecast.state, likely: forecast.likely } : null });
|
|
98
|
+
}
|
|
99
|
+
} catch { missing.push('goals'); }
|
|
100
|
+
|
|
101
|
+
// the programs whose milestones wait on these initiatives
|
|
102
|
+
try {
|
|
103
|
+
const P = require('./programs.js');
|
|
104
|
+
files.programs = path.join(stateDir, 'programs.jsonl');
|
|
105
|
+
for (const p of P.merge([{ ws: w.id, lines: readJsonl(files.programs) }])) {
|
|
106
|
+
if (p.archived) continue;
|
|
107
|
+
snap.programs.push({ id: p.id, title: p.title || p.id, milestones: (p.milestones || []).map((m) => ({ id: m.id, name: m.name, date: m.date || null, waits: m.waits || [], done: !!m.done, risk: m.risk === true })) });
|
|
108
|
+
}
|
|
109
|
+
} catch { missing.push('programs'); }
|
|
110
|
+
snap.refs = refsOf(root, snap, files);
|
|
111
|
+
return snap;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The sources an effect cites: the line of each link, initiative record, goal record, program charter and ◇ milestone flag.
|
|
115
|
+
* files: { links, board?, krs?, programs?, records: { [initiative]: file } } (absolute paths). */
|
|
116
|
+
function refsOf(root, snap, files) {
|
|
117
|
+
const R = { links: {}, rows: {}, goals: {}, programs: {}, tasks: {} }, F = files || {};
|
|
118
|
+
const after = (k) => (line) => (line.includes(k) ? line.slice(line.indexOf(k)) : null);
|
|
119
|
+
for (const l of snap.links || []) {
|
|
120
|
+
const k = jsonHas('from', l.from) + ',' + jsonHas('to', l.to);
|
|
121
|
+
const s = F.links && lineRef(root, F.links, (line) => (line.includes('"kind":"link"') ? after(k)(line) : null), 'link ' + l.from + ' → ' + l.to);
|
|
122
|
+
if (s) R.links[TL.keyOf(l.from, l.to)] = s;
|
|
123
|
+
}
|
|
124
|
+
for (const [ini, file] of Object.entries(F.records || {})) {
|
|
125
|
+
const a = lineRef(root, file, (l) => /^(parent_kr|anchor):/.test(l), ini + ' anchor');
|
|
126
|
+
if (a) R.rows[ini + ':anchor'] = a;
|
|
127
|
+
const idl = lineRef(root, file, (l) => /^id:/.test(l), ini + ' record (its end is the Board\'s, from its tasks)');
|
|
128
|
+
if (idl) R.rows[ini + ':end'] = idl;
|
|
129
|
+
}
|
|
130
|
+
for (const g of snap.goals || []) { const s = F.krs && lineRef(root, F.krs, after(jsonHas('rid', g.id)), 'goal ' + g.id); if (s) R.goals[g.id] = s; }
|
|
131
|
+
for (const p of snap.programs || []) { const s = F.programs && lineRef(root, F.programs, (line) => (line.includes('"kind":"program"') ? after(jsonHas('id', p.id))(line) : null), 'program ' + p.id); if (s) R.programs[p.id] = s; }
|
|
132
|
+
// each task's date: the last line of the Board or breakdown ledger that names the task and its due day (a ◇ flag line for a milestone
|
|
133
|
+
// with no date line). A ledger outside the repository (a worktree's main tree) is not cited: the guard reads only inside the root.
|
|
134
|
+
const inside = (f) => !!f && !path.relative(root, f).startsWith('..') && !path.isAbsolute(path.relative(root, f));
|
|
135
|
+
const ledgers = [F.board, F.items].filter(inside).map((f) => ({ f, lines: safe(() => fs.readFileSync(f, 'utf8').split('\n')) || [] }));
|
|
136
|
+
for (const t of snap.tasks || []) {
|
|
137
|
+
const uid = t.uid || t.key, due = String(t.end || t.due || '').slice(0, 10), id = JSON.stringify(uid);
|
|
138
|
+
if (!uid) continue;
|
|
139
|
+
for (const { f, lines } of ledgers) {
|
|
140
|
+
let i = lines.length - 1;
|
|
141
|
+
for (; i >= 0; i--) if (lines[i].includes(id) && (due ? lines[i].includes(due) : /milestone/.test(lines[i]))) break;
|
|
142
|
+
if (i < 0) continue;
|
|
143
|
+
const quote = lines[i].trim().slice(0, QUOTE);
|
|
144
|
+
R.tasks[uid] = { label: uid + (due ? ' due ' + due : ' milestone'), ref: rel(root, f) + ':' + (i + 1), quote };
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return R;
|
|
149
|
+
}
|
|
150
|
+
function safe(fn) { try { return fn(); } catch { return null; } }
|
|
151
|
+
function readJsonl(file) {
|
|
152
|
+
let text = '';
|
|
153
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
154
|
+
const out = [];
|
|
155
|
+
for (const raw of text.split('\n')) { if (!raw.trim()) continue; try { out.push(JSON.parse(raw)); } catch { /* a torn line is skipped */ } }
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { snapshotOf, refsOf, lineRef };
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* rehearse.js — rehearse a plan (initiative plan-rehearsal, PRD planning/prd/plan-rehearsal-prd.md). PURE: no file, no clock, no network.
|
|
3
|
+
* lib/rehearse-read.js reads the snapshot through the pmos readers; lib/rehearse-cli.js is `pmos rehearse`; lib/rehearse-guard.js is the
|
|
4
|
+
* kill switch, the evidence check and the audit line.
|
|
5
|
+
*
|
|
6
|
+
* Before the PM approves a change, Claude runs it here and shows what it breaks and what it helps: tasks in other initiatives, ◇ milestones,
|
|
7
|
+
* Jira tickets, committed ends, goals and program milestones. Nothing is changed. The PM approves in their own words.
|
|
8
|
+
*
|
|
9
|
+
* rehearse(snap, change) → { ok, change, effects: [effect], counts: { breaks, helps, moves }, checked, missing } | { ok: false, error }
|
|
10
|
+
* CHANGES: { kind: 'move', task, days? , start?, due? } · { kind: 'end', ini, to } · { kind: 'cut', task } · { kind: 'cut', ini }
|
|
11
|
+
* effect: { tone: 'breaks' | 'helps' | 'moves', kind, ini, text, evidence: [{ label, ref, quote? }] }
|
|
12
|
+
*
|
|
13
|
+
* SNAPSHOT. { today: 'YYYY-MM-DD',
|
|
14
|
+
* tasks: [{ uid, title, ini, status, start, end, milestone, src }] the shape lib/task-links.js taskOf reads
|
|
15
|
+
* links: [{ from, to, type, lag }] the approved links
|
|
16
|
+
* rows: [{ id, title, end, anchor }] the initiatives (end = the committed end)
|
|
17
|
+
* goals: [{ id, title, byAt, moves: [initiative id], forecast: { state, likely } | null }] lib/forecast.js goalForecast
|
|
18
|
+
* programs: [{ id, title, milestones: [{ id, name, date, waits: [initiative id], done }] }]
|
|
19
|
+
* refs: { links: { 'from>to': src }, rows: { 'ini:end' | 'ini:anchor': src }, goals: { id: src }, programs: { id: src }, tasks: { uid: src } }
|
|
20
|
+
* missing: ['goals', …] what the reader could not read, said as it is }
|
|
21
|
+
*
|
|
22
|
+
* THE RULES.
|
|
23
|
+
* - The effects are the DIFFERENCE between the plan as it is and the plan with the change. Both are first settled through the links
|
|
24
|
+
* (lib/task-links.js settle, with every task allowed to move so the whole chain shows), so a link already broken today is not blamed
|
|
25
|
+
* on the change.
|
|
26
|
+
* - PROVEN ONLY. An effect is made from a link, a date and a record only. A goal effect says the date fact and the goal's forecast state
|
|
27
|
+
* as it is today; it never estimates a new number. An effect with no source is dropped.
|
|
28
|
+
* - QUIET. The tasks of the changed initiative that move with it are one `moves` line, not one line each. No effect: `effects` is empty.
|
|
29
|
+
*/
|
|
30
|
+
const TL = require('./task-links.js');
|
|
31
|
+
const FC = require('./forecast.js');
|
|
32
|
+
|
|
33
|
+
const KINDS = Object.freeze(['move', 'end', 'cut']);
|
|
34
|
+
const MAX_LIST = 8;
|
|
35
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
36
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
37
|
+
const days = (n) => (Math.abs(n) === 1 ? 'a day' : Math.abs(n) + ' days');
|
|
38
|
+
const clip = (s, n) => { s = str(s).replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s; };
|
|
39
|
+
const q = (s) => '"' + clip(s, 60) + '"';
|
|
40
|
+
const has = (src) => !!(src && str(src.ref));
|
|
41
|
+
|
|
42
|
+
/** The tasks as the engine reads them. */
|
|
43
|
+
function tasksOf(list) {
|
|
44
|
+
const T = new Map();
|
|
45
|
+
for (const x of list || []) { if (!x || !(x.uid || x.key)) continue; const t = TL.taskOf(x); T.set(t.uid, t); }
|
|
46
|
+
return T;
|
|
47
|
+
}
|
|
48
|
+
const copy = (T) => { const C = new Map(); for (const [k, v] of T) C.set(k, { ...v }); return C; };
|
|
49
|
+
const ALL = { has: () => true };
|
|
50
|
+
/** Settle the links over T, every task allowed to move: answers the settled copy and what could not move. */
|
|
51
|
+
function project(T, links, o) {
|
|
52
|
+
const C = copy(T);
|
|
53
|
+
const S = TL.settle(C, links, { seeds: o.seeds, today: o.today, ini: '', mode: 'quiet', allow: ALL });
|
|
54
|
+
for (const m of S.moves) { const t = C.get(m.uid); C.set(m.uid, { ...t, s: m.to.s, e: m.to.e }); }
|
|
55
|
+
return { T: C, moves: S.moves, clashes: S.clashes };
|
|
56
|
+
}
|
|
57
|
+
/** An initiative's last open day: its latest open task's end, or null. */
|
|
58
|
+
function finishOf(T, ini) {
|
|
59
|
+
let f = null;
|
|
60
|
+
for (const t of T.values()) if (t.ini === ini && !t.closed && t.e != null && (f == null || t.e > f)) f = t.e;
|
|
61
|
+
return f;
|
|
62
|
+
}
|
|
63
|
+
/** When an initiative's work lands: its last open task's end; its committed end only when no open task has a date. A later deadline alone
|
|
64
|
+
* does not move when the work lands (Wawan's Mac, 2026-11-03: extending an end flagged a milestone the work still met). */
|
|
65
|
+
function landOf(T, row, ini) {
|
|
66
|
+
const f = finishOf(T, ini);
|
|
67
|
+
return f != null ? f : row && row.end ? TL.dn(row.end) : null;
|
|
68
|
+
}
|
|
69
|
+
/** The open task that ends an initiative's work, or null. */
|
|
70
|
+
function lastOf(T, ini) {
|
|
71
|
+
let t = null;
|
|
72
|
+
for (const x of T.values()) if (x.ini === ini && !x.closed && x.e != null && (!t || x.e > t.e)) t = x;
|
|
73
|
+
return t;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validate(snap, c) {
|
|
77
|
+
if (!c || !KINDS.includes(c.kind)) return 'the change is one of ' + KINDS.join(', ');
|
|
78
|
+
const T = (snap && snap.tasks) || [], rows = (snap && snap.rows) || [];
|
|
79
|
+
const isTask = (u) => T.some((t) => (t.uid || t.key) === u), isIni = (i) => rows.some((r) => r.id === i);
|
|
80
|
+
if (c.kind === 'move') {
|
|
81
|
+
if (!isTask(c.task)) return ((snap && snap.links) || []).some((l) => l && (l.from === c.task || l.to === c.task)) ? 'task ' + c.task + ' is named in a task link, but no board has it any more (removed or renamed)' : 'no task ' + str(c.task) + ' is on a board';
|
|
82
|
+
const n = [c.days != null, !!c.start, !!c.due].filter(Boolean).length;
|
|
83
|
+
if (!n) return 'say how it moves: --by <days>, --start or --due';
|
|
84
|
+
if (c.days != null && (!Number.isInteger(c.days) || c.days === 0 || Math.abs(c.days) > 730)) return '--by takes whole days, not 0, up to 730';
|
|
85
|
+
if (c.days != null && n > 1) return '--by goes alone; --start and --due go together';
|
|
86
|
+
for (const k of ['start', 'due']) if (c[k] && !DATE_RE.test(c[k])) return '--' + k + ' takes YYYY-MM-DD';
|
|
87
|
+
}
|
|
88
|
+
if (c.kind === 'end') { if (!isIni(c.ini)) return 'no initiative ' + str(c.ini); if (!DATE_RE.test(str(c.to))) return '--to takes YYYY-MM-DD'; }
|
|
89
|
+
if (c.kind === 'cut' && !(c.task ? isTask(c.task) : isIni(c.ini))) return c.task ? 'no task ' + c.task + ' is on a board' : 'no initiative ' + str(c.ini);
|
|
90
|
+
return '';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function rehearse(snap, change) {
|
|
94
|
+
const err = validate(snap, change);
|
|
95
|
+
if (err) return { ok: false, error: err };
|
|
96
|
+
const S = snap || {}, R = S.refs || {}, today = TL.dn(S.today);
|
|
97
|
+
const refL = (l) => (R.links || {})[TL.keyOf(l.from, l.to)] || null, refRow = (ini, k) => (R.rows || {})[ini + ':' + k] || null;
|
|
98
|
+
const rows = new Map((S.rows || []).map((r) => [r.id, r]));
|
|
99
|
+
const T0 = tasksOf(S.tasks);
|
|
100
|
+
const links0 = (S.links || []).filter((l) => l && T0.has(l.from) && T0.has(l.to));
|
|
101
|
+
const stale = (S.links || []).filter((l) => l && !(T0.has(l.from) && T0.has(l.to))).length;
|
|
102
|
+
const base = project(T0, links0, { today }); // the plan as it is
|
|
103
|
+
const titleOf = (u) => (base.T.get(u) || {}).title || u, iniTitle = (i) => (rows.get(i) || {}).title || i;
|
|
104
|
+
const c = change, effects = [];
|
|
105
|
+
let unsourced = 0;
|
|
106
|
+
const add = (e) => { const ev = (e.evidence || []).filter(has); if (ev.length) effects.push({ ...e, evidence: dedupe(ev) }); else unsourced++; };
|
|
107
|
+
const refT = (uid) => (R.tasks || {})[uid] || null;
|
|
108
|
+
|
|
109
|
+
// the change, applied to the plan as it is
|
|
110
|
+
let note = '';
|
|
111
|
+
let Ta = copy(base.T), links = links0, seeds = [], home = '', rowsA = new Map(rows), cutT = new Set(), cutIni = '';
|
|
112
|
+
if (c.kind === 'move') {
|
|
113
|
+
const t = Ta.get(c.task); home = t.ini; seeds = [t.uid];
|
|
114
|
+
let s = t.s, e = t.e;
|
|
115
|
+
if (c.days != null) { s = s == null ? null : s + c.days; e = e == null ? null : e + c.days; }
|
|
116
|
+
else {
|
|
117
|
+
if (c.start) { const ns = TL.dn(c.start); if (!c.due && e != null && s != null) e = e + (ns - s); s = ns; }
|
|
118
|
+
if (c.due) e = TL.dn(c.due);
|
|
119
|
+
if (s != null && e != null && e < s) return { ok: false, error: 'the due day would come before the start day' };
|
|
120
|
+
}
|
|
121
|
+
Ta.set(t.uid, { ...t, s, e });
|
|
122
|
+
if (c.days != null && t.e != null && today != null && t.e < today && !t.closed) note = q(t.title) + ' is late: due ' + TL.md(t.e) + '. --by ' + c.days + ' moves its due to ' + TL.md(t.e + c.days) + (t.e + c.days > today ? ', ' + days(t.e + c.days - today) + ' after today' : ', still before today') + '.';
|
|
123
|
+
} else if (c.kind === 'end') {
|
|
124
|
+
home = c.ini; rowsA.set(c.ini, { ...rows.get(c.ini), end: c.to });
|
|
125
|
+
} else if (c.task) {
|
|
126
|
+
home = Ta.get(c.task).ini; cutT.add(c.task);
|
|
127
|
+
} else {
|
|
128
|
+
home = cutIni = c.ini; for (const t of Ta.values()) if (t.ini === c.ini) cutT.add(t.uid);
|
|
129
|
+
}
|
|
130
|
+
if (cutT.size) { for (const u of cutT) Ta.delete(u); links = links0.filter((l) => !cutT.has(l.from) && !cutT.has(l.to)); }
|
|
131
|
+
const after = seeds.length ? project(Ta, links, { today, seeds }) : { T: Ta, moves: [], clashes: [] };
|
|
132
|
+
const TA = after.T;
|
|
133
|
+
|
|
134
|
+
// tasks that move: the changed initiative's in one line; another initiative's and every ◇ milestone each
|
|
135
|
+
const mine = [];
|
|
136
|
+
for (const m of after.moves) {
|
|
137
|
+
const b = base.T.get(m.uid), a = TA.get(m.uid);
|
|
138
|
+
if (!b || !a || a.e === b.e && a.s === b.s) continue;
|
|
139
|
+
const d = (a.e != null && b.e != null ? a.e - b.e : a.s - b.s), via = refL(m.link), why = q(titleOf(m.link.from));
|
|
140
|
+
const o = T0.get(m.uid), key = b.ms || a.s == null ? 'e' : 's';
|
|
141
|
+
const from = ' from ' + TL.md(b[key]) + (o && o[key] !== b[key] ? ' (' + TL.md(o[key]) + ' on the Board; late work before it already pushes it to ' + TL.md(b[key]) + ')' : '');
|
|
142
|
+
if (b.ms) add({ tone: 'breaks', kind: 'milestone', ini: b.ini, task: b.uid, text: '◇ ' + q(b.title) + (b.ini !== home ? ' in ' + q(iniTitle(b.ini)) : '') + ' moves ' + days(d) + ' later,' + from + ' to ' + TL.md(a.e) + '. It waits on ' + why + '.', evidence: [via, refT(b.uid)] });
|
|
143
|
+
else if (b.ini !== home) add({ tone: 'breaks', kind: 'task', ini: b.ini, task: b.uid, text: q(b.title) + ' in ' + q(iniTitle(b.ini)) + ' moves ' + days(d) + ' later,' + from + ' to ' + TL.md(a[key]) + '. It waits on ' + why + '.', evidence: [via] });
|
|
144
|
+
else mine.push({ m, d, via });
|
|
145
|
+
}
|
|
146
|
+
if (mine.length) {
|
|
147
|
+
const names = mine.slice(0, MAX_LIST).map((x) => q(titleOf(x.m.uid)) + ' +' + x.d + 'd').join(', ') + (mine.length > MAX_LIST ? ' and ' + (mine.length - MAX_LIST) + ' more' : '');
|
|
148
|
+
add({ tone: 'moves', kind: 'tasks', ini: home, text: (mine.length === 1 ? '1 task' : mine.length + ' tasks') + ' in ' + q(iniTitle(home)) + ' move with it: ' + names + '.', evidence: mine.map((x) => x.via) });
|
|
149
|
+
}
|
|
150
|
+
// a Jira ticket the links say must start later: PMOS cannot move it
|
|
151
|
+
const clashKey = (x) => x.uid + '<' + x.after;
|
|
152
|
+
const was = new Set(base.clashes.map(clashKey));
|
|
153
|
+
for (const x of after.clashes) {
|
|
154
|
+
if (x.kind !== 'jira' || was.has(clashKey(x))) continue;
|
|
155
|
+
add({ tone: 'breaks', kind: 'jira', ini: (TA.get(x.uid) || {}).ini || '', task: x.uid, text: x.uid + ' ' + q(titleOf(x.uid)) + ' would have to start ' + days(x.days) + ' later. It is a Jira ticket, so PMOS cannot move it; its assignee would.', evidence: [refL(x.link)] });
|
|
156
|
+
}
|
|
157
|
+
// what waited on a cut task: it loses what it waited on, and may start earlier
|
|
158
|
+
if (cutT.size) {
|
|
159
|
+
for (const l of links0) {
|
|
160
|
+
if (!cutT.has(l.from) || cutT.has(l.to)) continue;
|
|
161
|
+
const b = base.T.get(l.to); if (!b || b.closed) continue;
|
|
162
|
+
add({ tone: 'breaks', kind: 'loses', ini: b.ini, task: b.uid, text: q(b.title) + (b.ini !== home ? ' in ' + q(iniTitle(b.ini)) : '') + ' waits on ' + q(titleOf(l.from)) + ', which would be cut. Whatever it needed from it has to come from somewhere else.', evidence: [refL(l)] });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// a task moved earlier than a link into it allows: it would start before what it waits on is done
|
|
166
|
+
if (c.kind === 'move') {
|
|
167
|
+
const t1 = TA.get(c.task);
|
|
168
|
+
for (const l of links0) {
|
|
169
|
+
if (l.to !== c.task) continue;
|
|
170
|
+
const a = base.T.get(l.from); if (!a || a.closed) continue;
|
|
171
|
+
const short1 = TL.shortOf(TL.needOf(l, TA.get(l.from) || a, today), t1), short0 = TL.shortOf(TL.needOf(l, a, today), base.T.get(c.task));
|
|
172
|
+
if (short1 > 0 && short0 <= 0) add({ tone: 'breaks', kind: 'order', ini: t1.ini, task: t1.uid, text: q(t1.title) + ' would start ' + days(short1) + ' before ' + q(a.title) + (a.ini !== t1.ini ? ' in ' + q(iniTitle(a.ini)) : '') + ' allows. It waits on it (' + TL.TYPE_WORDS[l.type] + ').', evidence: [refL(l)] });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// what can start earlier: a task held tight by a task moved earlier now has room (a task that lost a cut task is said above)
|
|
176
|
+
if (c.kind === 'move' && moveDelta(base.T, Ta, c.task) < 0) {
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
for (const l of links0) {
|
|
179
|
+
if (seen.has(l.to) || l.from !== c.task) continue;
|
|
180
|
+
const b = TA.get(l.to); if (!b || b.closed || b.jira || b.s == null || b.status !== 'To do') continue;
|
|
181
|
+
const roomB = roomOf(base.T, links0, b.uid, today), roomA = roomOf(TA, links, b.uid, today);
|
|
182
|
+
if (roomB > 0 || roomA <= 0) continue;
|
|
183
|
+
seen.add(b.uid);
|
|
184
|
+
add({ tone: 'helps', kind: 'earlier', ini: b.ini, task: b.uid, text: q(b.title) + (b.ini !== home ? ' in ' + q(iniTitle(b.ini)) : '') + ' could start up to ' + days(roomA) + ' earlier. Nothing moves it until you do.', evidence: [refL(l)] });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// committed ends: an initiative whose last task now ends after its committed end, or no longer does
|
|
188
|
+
const inis = new Set([...base.T.values()].map((t) => t.ini).concat(c.kind === 'end' ? [c.ini] : []));
|
|
189
|
+
for (const ini of inis) {
|
|
190
|
+
if (!ini || ini === cutIni) continue;
|
|
191
|
+
const r0 = rows.get(ini), r1 = rowsA.get(ini);
|
|
192
|
+
const end0 = r0 && r0.end ? TL.dn(r0.end) : null, end1 = r1 && r1.end ? TL.dn(r1.end) : null;
|
|
193
|
+
if (end1 == null) continue;
|
|
194
|
+
const f0 = finishOf(base.T, ini), f1 = finishOf(TA, ini);
|
|
195
|
+
const late0 = f0 != null && end0 != null && f0 > end0, late1 = f1 != null && f1 > end1;
|
|
196
|
+
const lt = lastOf(TA, ini);
|
|
197
|
+
const ev = [refRow(ini, 'end'), lt ? refT(lt.uid) : null].concat(after.moves.filter((m) => (TA.get(m.uid) || {}).ini === ini).slice(0, 2).map((m) => refL(m.link)));
|
|
198
|
+
if (!late0 && late1) add({ tone: 'breaks', kind: 'end', ini, text: q(iniTitle(ini)) + "'s last task would end " + TL.md(f1) + ', ' + days(f1 - end1) + ' after its committed end, ' + TL.md(end1) + '.', evidence: ev });
|
|
199
|
+
else if (late0 && !late1 && f1 != null) add({ tone: 'helps', kind: 'end', ini, text: q(iniTitle(ini)) + "'s last task would end " + TL.md(f1) + ', back inside its committed end, ' + TL.md(end1) + '.', evidence: ev });
|
|
200
|
+
}
|
|
201
|
+
// goals and program milestones: when each initiative's work lands, before and after
|
|
202
|
+
const land0 = new Map(), land1 = new Map();
|
|
203
|
+
for (const ini of new Set([...rows.keys(), ...inis])) { land0.set(ini, landOf(base.T, rows.get(ini), ini)); if (ini !== cutIni) land1.set(ini, landOf(TA, rowsA.get(ini), ini)); }
|
|
204
|
+
const lastRef = (ini) => { const t = lastOf(TA, ini) || lastOf(base.T, ini); return t ? refT(t.uid) : null; };
|
|
205
|
+
const movedIni = [...land0.keys()].filter((i) => i === cutIni || land0.get(i) !== land1.get(i));
|
|
206
|
+
for (const g of S.goals || []) {
|
|
207
|
+
const by = TL.dn(g.byAt); if (by == null) continue;
|
|
208
|
+
const fc = g.forecast && g.forecast.state ? ' Its forecast today: ' + FORECAST_WORDS(g.forecast) + '.' : '';
|
|
209
|
+
for (const ini of movedIni) {
|
|
210
|
+
if (!(g.moves || []).includes(ini)) continue;
|
|
211
|
+
const ev = [(R.goals || {})[g.id], refRow(ini, 'anchor')];
|
|
212
|
+
if (ini === cutIni) { add({ tone: 'breaks', kind: 'goal', ini, goal: g.id, text: 'Goal ' + q(g.title) + ' loses ' + q(iniTitle(ini)) + ', ' + ((g.moves || []).length === 1 ? 'the only initiative that moves it' : 'one of the ' + g.moves.length + ' initiatives that move it') + '.' + fc, evidence: ev }); continue; }
|
|
213
|
+
const a = land0.get(ini), b = land1.get(ini); if (a == null || b == null) continue;
|
|
214
|
+
if (a <= by && b > by) add({ tone: 'breaks', kind: 'goal', ini, goal: g.id, text: 'Goal ' + q(g.title) + ' is due ' + TL.md(by) + '. ' + q(iniTitle(ini)) + ', which moves it, would land ' + TL.md(b) + ', after that.' + fc, evidence: ev.concat([refRow(ini, 'end'), lastRef(ini)]) });
|
|
215
|
+
else if (a > by && b <= by) add({ tone: 'helps', kind: 'goal', ini, goal: g.id, text: 'Goal ' + q(g.title) + ' is due ' + TL.md(by) + '. ' + q(iniTitle(ini)) + ', which moves it, would land ' + TL.md(b) + ', in time.' + fc, evidence: ev.concat([refRow(ini, 'end'), lastRef(ini)]) });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const byIni = (L) => new Map([...L.entries()].filter(([, v]) => v != null).map(([k, v]) => [k, { state: null, high: TL.isoN(v) }]));
|
|
219
|
+
const B0 = byIni(land0), B1 = byIni(land1);
|
|
220
|
+
for (const p of S.programs || []) {
|
|
221
|
+
const ev0 = (R.programs || {})[p.id];
|
|
222
|
+
for (const m of (p.milestones || []).filter((x) => x && !x.done && x.name)) {
|
|
223
|
+
for (const ini of movedIni) {
|
|
224
|
+
if (!(m.waits || []).includes(ini)) continue;
|
|
225
|
+
const md = TL.dn(m.date), a = land0.get(ini), b = land1.get(ini);
|
|
226
|
+
const ctx = ' Program ' + q(p.title || p.id) + ': ' + atRiskWords(p, B0, B1, cutIni) + '.';
|
|
227
|
+
if (ini === cutIni) add({ tone: 'breaks', kind: 'program', ini, program: p.id, text: 'Milestone ' + q(m.name) + ' waits on ' + q(iniTitle(ini)) + ', which would be cut.' + ctx, evidence: [ev0] });
|
|
228
|
+
else if (md != null && a != null && b != null && a <= md && b > md) add({ tone: 'breaks', kind: 'program', ini, program: p.id, text: 'Milestone ' + q(m.name) + ' (' + TL.md(md) + ') waits on ' + q(iniTitle(ini)) + ', which would land ' + TL.md(b) + ', after it.' + ctx, evidence: [ev0, refRow(ini, 'end'), lastRef(ini)] });
|
|
229
|
+
else if (md != null && a != null && b != null && a > md && b <= md) add({ tone: 'helps', kind: 'program', ini, program: p.id, text: 'Milestone ' + q(m.name) + ' (' + TL.md(md) + ') waits on ' + q(iniTitle(ini)) + ', which would land ' + TL.md(b) + ', in time.' + ctx, evidence: [ev0, refRow(ini, 'end'), lastRef(ini)] });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const order = { breaks: 0, helps: 1, moves: 2 };
|
|
234
|
+
effects.sort((x, y) => order[x.tone] - order[y.tone]);
|
|
235
|
+
const counts = { breaks: 0, helps: 0, moves: 0 }; for (const e of effects) counts[e.tone]++;
|
|
236
|
+
return { ok: true, change: c, effects, counts, checked: { tasks: T0.size, links: links0.length, goals: (S.goals || []).length, programs: (S.programs || []).length }, missing: S.missing || [], unsourced, stale, note };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function moveDelta(T0, T1, uid) { const a = T0.get(uid), b = T1.get(uid); return a && b && a.e != null && b.e != null ? b.e - a.e : a && b && a.s != null && b.s != null ? b.s - a.s : 0; }
|
|
240
|
+
/** How many days earlier `uid` could start under the links into it (and not before tomorrow). */
|
|
241
|
+
function roomOf(T, links, uid, today) {
|
|
242
|
+
const b = T.get(uid); if (!b || b.s == null) return 0;
|
|
243
|
+
let need = today == null ? -Infinity : today + 1;
|
|
244
|
+
for (const l of links) {
|
|
245
|
+
if (l.to !== uid) continue;
|
|
246
|
+
const a = T.get(l.from); if (!a) continue;
|
|
247
|
+
const n = TL.needOf(l, a, today);
|
|
248
|
+
if (n.start != null) need = Math.max(need, n.start);
|
|
249
|
+
if (n.due != null && b.e != null) need = Math.max(need, b.s + (n.due - b.e));
|
|
250
|
+
}
|
|
251
|
+
return need === -Infinity ? 0 : Math.max(0, b.s - need);
|
|
252
|
+
}
|
|
253
|
+
const FORECAST_WORDS = (f) => ({ 'on-track': 'on track', 'at-risk': 'at risk', 'will-miss': 'will miss', 'too-early': 'too early to say', held: 'held, not read for over a week' }[f.state] || f.state) + (f.likely != null ? ', likely ' + f.likely : '');
|
|
254
|
+
function atRiskWords(p, B0, B1, cutIni) {
|
|
255
|
+
const P1 = { ...p, milestones: (p.milestones || []).map((m) => (cutIni && (m.waits || []).includes(cutIni) ? { ...m, risk: true } : m)) };
|
|
256
|
+
const a = FC.programForecast(p, B0), b = FC.programForecast(P1, B1);
|
|
257
|
+
if (!a || !b) return 'no milestone forecast';
|
|
258
|
+
return b.atRisk + ' of ' + b.total + ' milestones at risk' + (b.atRisk !== a.atRisk ? ' (was ' + a.atRisk + ')' : '');
|
|
259
|
+
}
|
|
260
|
+
function dedupe(ev) { const seen = new Set(); return ev.filter((s) => { const k = s.ref + '|' + (s.quote || ''); if (seen.has(k)) return false; seen.add(k); return true; }); }
|
|
261
|
+
|
|
262
|
+
/** The rehearsal in words, for the chat: breaks first, then helps, then what moves with it; each with its sources. */
|
|
263
|
+
function textOf(r) {
|
|
264
|
+
if (!r.ok) return ['✗ ' + r.error];
|
|
265
|
+
const out = [];
|
|
266
|
+
if (!r.effects.length) out.push('Nothing else moves: no other task, initiative, goal or program milestone changes.');
|
|
267
|
+
for (const e of r.effects) {
|
|
268
|
+
out.push((e.tone === 'breaks' ? '✗ Breaks: ' : e.tone === 'helps' ? '✓ Helps: ' : '· Moves: ') + e.text);
|
|
269
|
+
out.push(' source: ' + e.evidence.map((s) => s.label + ' (' + s.ref + ')').join('; '));
|
|
270
|
+
}
|
|
271
|
+
if (r.note) out.push('Note: ' + r.note);
|
|
272
|
+
if (r.stale) out.push((r.stale === 1 ? '1 task link names' : r.stale + ' task links name') + ' a task no board has any more, so ' + (r.stale === 1 ? 'it is' : 'they are') + ' left out.');
|
|
273
|
+
if (r.unsourced) out.push((r.unsourced === 1 ? '1 effect is' : r.unsourced + ' effects are') + ' not shown: PMOS found no record line to cite for it.');
|
|
274
|
+
if (r.missing && r.missing.length) out.push('Not read: ' + r.missing.join(', ') + '. What depends on them is not shown.');
|
|
275
|
+
out.push('This was a rehearsal. Nothing changed. The PM approves the change in their own words before anyone makes it.');
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
module.exports = { KINDS, rehearse, textOf, validate, roomOf, finishOf, landOf };
|
|
@@ -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 };
|
|
@@ -101,6 +101,10 @@ const DEFS = Object.freeze({
|
|
|
101
101
|
// and the monthly credit cap in US dollars. At the cap Claude pauses. `pmos grill mode|cap` reads and sets them.
|
|
102
102
|
'grill.mode': one('checks', 'computer', ['full', 'exits', 'off'], 'full'),
|
|
103
103
|
'grill.cap': { section: 'checks', scope: 'computer', type: 'dollars', min: 0, max: 500, def: 10 },
|
|
104
|
+
// The AI jobs' kill switch (lib/ai-guard.js): 'ai.on' false stops every AI job; 'ai.off' lists the jobs that are off one by one.
|
|
105
|
+
// `pmos ai off [job]` and `pmos ai on [job]` set them; PMOS_AI=off wins over both.
|
|
106
|
+
'ai.on': bool('checks', 'computer', true),
|
|
107
|
+
'ai.off': { section: 'checks', scope: 'computer', type: 'jobs', max: 40, def: [] },
|
|
104
108
|
});
|
|
105
109
|
|
|
106
110
|
/* The environment variables that win over a setting, here or in a setting's own store. `key` names the setting the page
|
|
@@ -112,6 +116,7 @@ const ENV = Object.freeze([
|
|
|
112
116
|
{ key: 'rec.final', env: 'PMOS_NOTES_FINAL', off: 'off', effect: 'The pass after Stop is off on this computer.' },
|
|
113
117
|
{ key: 'notes.wispr', env: 'PMOS_NOTES_WISPR', off: 'off', effect: 'Wispr Flow notes are not fetched on this computer.' },
|
|
114
118
|
{ key: 'notes.engine', env: 'PMOS_NOTES_QWEN', off: 'off', effect: 'Qwen3-ASR is not used on this computer.' },
|
|
119
|
+
{ key: 'ai.on', env: 'PMOS_AI', off: 'off', effect: 'Every AI job is off on this computer: nothing is filed under ✦.' },
|
|
115
120
|
]);
|
|
116
121
|
|
|
117
122
|
const WS_ID = /^[A-Za-z0-9_-]{1,80}$/;
|
|
@@ -163,6 +168,7 @@ function validate(key, value) {
|
|
|
163
168
|
}
|
|
164
169
|
// ['*'] alone is every workspace (the product picker's All, lib/briefing-premium-products.js)
|
|
165
170
|
case 'ids': return value === null || Array.isArray(value) && (value.length === 1 && value[0] === '*' || value.length <= d.max && value.every((x) => typeof x === 'string' && WS_ID.test(x)) && new Set(value).size === value.length) ? null : `${key} must list at most ${d.max} workspace ids, each once, or ['*'] for every one`;
|
|
171
|
+
case 'jobs': return Array.isArray(value) && value.length <= d.max && value.every((x) => typeof x === 'string' && /^[a-z][a-z0-9-]{1,40}$/.test(x)) && new Set(value).size === value.length ? null : `${key} must list at most ${d.max} job ids, each once`;
|
|
166
172
|
// Dress for the day (pm/cozy-seasons.js): a day's key is its charm key, as in DAYS there
|
|
167
173
|
case 'country': return value === '' || typeof value === 'string' && /^[A-Z]{2}$/.test(value) ? null : `${key} must be a two-letter country code, or empty for this computer's`;
|
|
168
174
|
case 'colors': return Array.isArray(value) && value.length === 2 && value.every((c) => DRESS_COLORS.includes(c)) ? null : `${key} must be two of ${DRESS_COLORS.join(', ')}`;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.507.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.507.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/sdk": "^1.3.0",
|
package/workspace/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* pm/grill-words.js — the words and marks every page uses for Claude's checks (Grill and Forecast screens; canvas GrillResult, GrillLenses,
|
|
2
2
|
* FcIntel, FcPhone). Pure: Node can import it.
|
|
3
3
|
*
|
|
4
|
-
* ANGLE { [angle]: { label, icon (a 24-unit path) } }: premortem · redteam · voices · reframe · scope · whatif
|
|
4
|
+
* ANGLE { [angle]: { label, icon (a 24-unit path) } }: premortem · redteam · voices · reframe · scope · whatif · decision · past
|
|
5
5
|
* findingOf(p) a grill proposal (lib/grill.js finding) → { angle, label, icon, title, why, next, simulated }
|
|
6
6
|
* the title and why without the angle prefix and the "Next:" tail lib/grill.js writes into them
|
|
7
7
|
* whenWord(iso, now) 'overnight' · 'today' · 'yesterday' · 'on Oct 30'
|
|
@@ -16,6 +16,8 @@ export const ANGLE = Object.freeze({
|
|
|
16
16
|
reframe: { label: 'Reframe', icon: 'M4 8V4h4M20 8V4h-4M4 16v4h4M20 16v4h-4M9 9h6v6H9z' },
|
|
17
17
|
scope: { label: 'Scope', icon: 'M4 6h16M7 12h10M10 18h4' },
|
|
18
18
|
whatif: { label: 'What-if', icon: 'M4 18c4 0 4-12 8-12s4 12 8 12M4 12c4 0 4-6 8-6' },
|
|
19
|
+
decision: { label: 'Earlier decision', icon: 'M6 3h9l3 3v15H6zM9 9h6M9 13h6M9 17h3' },
|
|
20
|
+
past: { label: 'You decided this before', icon: 'M3 12a9 9 0 1 0 3-6.7M3 4v4h4M12 7v5l3 2' },
|
|
19
21
|
});
|
|
20
22
|
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
21
23
|
|
|
@@ -44,7 +46,7 @@ export function costWord(usd) {
|
|
|
44
46
|
if (usd == null || !Number.isFinite(n)) return '';
|
|
45
47
|
return n < 1 ? Math.max(1, Math.round(n * 100)) + '¢' : '$' + n.toFixed(2);
|
|
46
48
|
}
|
|
47
|
-
const WHY = { exit: 'before it leaves its stage', forecast: 'when its forecast turned', move: 'after its scope or dates moved', monthly: 'in the monthly look' };
|
|
49
|
+
const WHY = { exit: 'before it leaves its stage', forecast: 'when its forecast turned', move: 'after its scope or dates moved', plan: 'when its PRD changed', monthly: 'in the monthly look' };
|
|
48
50
|
export const whyWord = (r) => (r && WHY[r.why]) || '';
|
|
49
51
|
export function runLine(r, now = Date.now()) {
|
|
50
52
|
if (!r) return '';
|