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,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Guide
|
|
3
|
+
title: "What you might have missed"
|
|
4
|
+
description: "The weekly check for quiet drift: what it looks for, when it says nothing, how to drop an item, the replay that comes first, how to switch it off, and the pmos drift commands."
|
|
5
|
+
timestamp: 2026-11-03
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# What you might have missed
|
|
9
|
+
|
|
10
|
+
Once a week PMOS looks across your products for work that looks fine on the surface but has quietly drifted. It shows at most three items, as one ✦ in Home's chat. In a quiet week it says nothing.
|
|
11
|
+
|
|
12
|
+
## What it looks for
|
|
13
|
+
|
|
14
|
+
It reads only records. It does not read chats or meetings, and it uses no model, so it costs no credits.
|
|
15
|
+
|
|
16
|
+
- **Gone quiet.** An initiative that is still live and has open tasks, but no task and no record has changed for 14 days.
|
|
17
|
+
- **Slipped again.** An open task whose due date moved later at least twice, with the last move in the week it checks. A milestone comes first.
|
|
18
|
+
- **Jira and PMOS disagree.** The initiative is closed but one of its Jira tickets is still open. Or every Jira ticket was resolved at least 7 days ago and the initiative is still open.
|
|
19
|
+
|
|
20
|
+
Each item shows the records it came from: the task, the dates and when they moved, the Jira key and its status. The facts are read from records. Whether they matter is for you to judge.
|
|
21
|
+
|
|
22
|
+
## When it runs
|
|
23
|
+
|
|
24
|
+
It runs by itself each Monday, inside your day-check hours, for the week before. You never start it. The card stays in Home's chat for the week.
|
|
25
|
+
|
|
26
|
+
## Drop an item
|
|
27
|
+
|
|
28
|
+
Open ⋯ on an item and choose **Not right, drop it**. A dropped item never comes back. Undo brings it back. An item you have already seen comes back only when it gets worse: another slip, two more quiet weeks, or more open tickets.
|
|
29
|
+
|
|
30
|
+
## The replay comes first
|
|
31
|
+
|
|
32
|
+
Nothing shows for a product until its replay has passed. In the product's repository, run `pmos drift replay`. It runs the check on each of the last four weeks of real records, checks that no week has more than three items, and checks that every quote is at the line it cites. The shared guard then runs its own replay.
|
|
33
|
+
|
|
34
|
+
## Switch it off
|
|
35
|
+
|
|
36
|
+
Set `PMOS_DRIFT=off`, or turn Claude's checks off with `pmos ai off drift`. Every result, drop, undo and replay is a line in your own ledger, `drift/drift.jsonl` in the runtime folder. Only you see it. Nothing leaves PMOS, and nothing changes a task, an initiative or a Jira ticket.
|
|
37
|
+
|
|
38
|
+
## Commands
|
|
39
|
+
|
|
40
|
+
- `pmos drift show [--weeks <n>]`: what each of the last weeks would have shown for this repository. It writes nothing.
|
|
41
|
+
- `pmos drift replay`: the replay of the last four weeks of this repository.
|
|
42
|
+
- `pmos drift status`: the last weekly result and the replays on record.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* ai-cli.js — `pmos ai`: the kill switch and the audit of the AI jobs that file through lib/ai-guard.js.
|
|
3
|
+
*
|
|
4
|
+
* pmos ai status [--json] the switch, each job's replay state and its last run
|
|
5
|
+
* pmos ai off [<job>] turn every AI job off, or one job
|
|
6
|
+
* pmos ai on [<job>] turn every AI job on again, or one job
|
|
7
|
+
* pmos ai log [<job>] [--n N] the last audit lines: filed, dropped (with why), held, off, replay
|
|
8
|
+
*
|
|
9
|
+
* A background run never changes the switch: inside one (PMOS_AI_RUN set) off and on refuse. PMOS_AI=off in the environment wins
|
|
10
|
+
* over Settings. Exit codes: 0 done, 1 refused, 2 usage. Nothing is sent. */
|
|
11
|
+
const AG = require('./ai-guard.js');
|
|
12
|
+
|
|
13
|
+
const COMMANDS = { ai: ['status', 'off', 'on', 'log'] };
|
|
14
|
+
const USAGE = [
|
|
15
|
+
'usage: pmos ai status|off|on|log — the AI jobs: their kill switch and what each one filed or dropped',
|
|
16
|
+
' pmos ai status [--json] is each job on, has its replay passed, what did its last run file',
|
|
17
|
+
' pmos ai off [<job>] every AI job off, or one job',
|
|
18
|
+
' pmos ai on [<job>] every AI job on again, or one job',
|
|
19
|
+
' pmos ai log [<job>] [--n N] the last audit lines',
|
|
20
|
+
'A job files only proposals. Each one cites its sources and waits under ✦ for your ✓. Nothing is sent.',
|
|
21
|
+
];
|
|
22
|
+
const flag = (args, k) => { const i = args.indexOf('--' + k); return i >= 0 ? (args[i + 1] === undefined || args[i + 1].startsWith('--') ? true : args[i + 1]) : undefined; };
|
|
23
|
+
const stateOf = (env) => require('./state-root.js').defaultRoot({ env });
|
|
24
|
+
|
|
25
|
+
function run(argv, { say = (s) => console.log(s), env = process.env, stateDir = null } = {}) {
|
|
26
|
+
const args = argv[0] === 'ai' ? argv.slice(1) : argv.slice();
|
|
27
|
+
const verb = args[0];
|
|
28
|
+
if (!verb || ['help', '--help', '-h'].includes(verb)) { for (const l of USAGE) say(l); return verb ? 0 : 1; }
|
|
29
|
+
if (!COMMANDS.ai.includes(verb)) { say(`unknown ai command "${verb}" — one of ${COMMANDS.ai.join(', ')}`); return 2; }
|
|
30
|
+
const dir = stateDir || stateOf(env);
|
|
31
|
+
const job = args[1] && !args[1].startsWith('--') ? args[1] : null;
|
|
32
|
+
|
|
33
|
+
if (verb === 'status') {
|
|
34
|
+
const s = AG.status({ stateDir: dir, settingsDir: dir, env });
|
|
35
|
+
if (flag(args, 'json')) { say(JSON.stringify(s)); return 0; }
|
|
36
|
+
say(`AI jobs: ${s.on ? 'on' : 'off'}${s.settingsOff.length ? ' · off one by one: ' + s.settingsOff.join(', ') : ''}`);
|
|
37
|
+
if (!s.jobs.length) say(' no job has run yet');
|
|
38
|
+
for (const j of s.jobs) {
|
|
39
|
+
const rep = j.replay ? `replay v${j.replay.version} ${j.replay.ok ? 'passed' : 'FAILED'}` : 'no replay yet: everything is held';
|
|
40
|
+
const last = j.last ? ` · last run ${String(j.last.at).slice(0, 16).replace('T', ' ')}: ${j.last.filed} filed, ${j.last.dropped} dropped` : '';
|
|
41
|
+
say(` ${j.job}: ${j.off ? 'off' : 'on'} · ${rep}${last}`);
|
|
42
|
+
}
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
if (verb === 'log') {
|
|
46
|
+
const n = Math.min(500, Math.max(1, Number(flag(args, 'n')) || 20));
|
|
47
|
+
const lines = AG.readAudit(dir, { job, limit: n });
|
|
48
|
+
if (!lines.length) { say('no audit lines yet'); return 0; }
|
|
49
|
+
for (const l of lines) {
|
|
50
|
+
const { at, job: j, event, ...rest } = l;
|
|
51
|
+
say(`${String(at).slice(0, 19).replace('T', ' ')} ${j || '-'} ${event} ${JSON.stringify(rest)}`);
|
|
52
|
+
}
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
if (env.PMOS_AI_RUN) { say('✗ an AI run never changes its own switch'); return 1; }
|
|
56
|
+
const r = AG.setOff(dir, job, verb === 'off');
|
|
57
|
+
if (!r || !r.ok) { say('✗ ' + ((r && r.error) || 'could not save')); return 1; }
|
|
58
|
+
const envWins = verb === 'on' && AG.off(job, { env, settingsDir: null });
|
|
59
|
+
say(`${job || 'every AI job'}: ${verb}${envWins ? ' in Settings, but ' + envWins + ' wins' : ''}`);
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { run, COMMANDS, USAGE };
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* ai-guard.js — THE ONE DOOR every AI job's output passes before the PM sees it (the AI-ideas round, 2026-11-03: decision memory,
|
|
3
|
+
* the weekly what-I'd-miss check, ask about the past, and the features after them). A job runs in the PM's usual background Claude
|
|
4
|
+
* session (lib/headless.js, as grill does). It may file only proposals, and each one goes through file() below:
|
|
5
|
+
*
|
|
6
|
+
* kill switch → replay gate → check each proposal → audit → deliver(accepted)
|
|
7
|
+
*
|
|
8
|
+
* THE PROPOSAL (the only thing a job may file):
|
|
9
|
+
* { id, title, say, confidence: 'high'|'medium'|'low'|'none',
|
|
10
|
+
* claims: [{ text, sources: [{ label, ref, quote }] }], data? }
|
|
11
|
+
* ref is `path:line` relative to the repository root, or `state:<file>:line` for a ledger in the job's state folder. Other refs
|
|
12
|
+
* (`jira:KEY`, a URL) are kept as context but are unverified. confidence 'none' is "no record": claims may be empty, and that is a
|
|
13
|
+
* valid, complete answer.
|
|
14
|
+
*
|
|
15
|
+
* THE RULES (check):
|
|
16
|
+
* propose-only a key named write, apply, ops, patch, send, command or exec anywhere in the proposal rejects it
|
|
17
|
+
* every claim at least 1 verified source: the quote (12+ characters) is found within ±2 lines of ref, case and spacing ignored
|
|
18
|
+
* honest 'high' needs 2 or more distinct verified refs; 'none' must have no claims
|
|
19
|
+
* bounded at most `max` proposals per run (default 3); the rest are dropped, in the order given
|
|
20
|
+
*
|
|
21
|
+
* THE REPLAY GATE: a job's (job, version) shows nothing until replay() has passed for it. A replay runs the job's own cases (its
|
|
22
|
+
* deterministic part or a recorded model output, never the model) and the guard's negative cases made from the job's first filed
|
|
23
|
+
* proposal: a fabricated quote, a missing source, a ref outside the repository and a write key. Each of those must be dropped.
|
|
24
|
+
*
|
|
25
|
+
* THE KILL SWITCH: PMOS_AI=off (every job), PMOS_AI_<JOB>=off (one job, dashes as underscores), Settings 'ai.on' false, or the job
|
|
26
|
+
* in Settings 'ai.off'. `pmos ai off [job]` and `pmos ai on [job]` set the Settings ones.
|
|
27
|
+
*
|
|
28
|
+
* THE AUDIT: <stateDir>/ai-audit.jsonl, one line per event { at, job, version?, run?, event, ... }. Events: start, filed, dropped,
|
|
29
|
+
* held, off, replay, end. No line holds a file body or a key: only ids, refs, reasons and counts.
|
|
30
|
+
*
|
|
31
|
+
* off(job, { env, settingsDir }) → null | reason
|
|
32
|
+
* check(p, { root, stateDir }) → { ok, p, errors }
|
|
33
|
+
* replay(job, version, cases, { root, stateDir }) → Promise<{ ok, failed: [{ name, why }] }>
|
|
34
|
+
* ready(stateDir, job, version) → bool
|
|
35
|
+
* file({ job, version, run, root, stateDir, settingsDir?, env?, max? }, proposals, deliver) → Promise<{ filed, dropped, held?, off? }>
|
|
36
|
+
* audit(stateDir, line) · readAudit(stateDir, { job?, limit? }) → [line] · status({ stateDir, settingsDir, env }) → summary
|
|
37
|
+
* setOff(settingsDir, job | null, off) → { ok } | { ok: false, error }
|
|
38
|
+
*/
|
|
39
|
+
const fs = require('fs');
|
|
40
|
+
const path = require('path');
|
|
41
|
+
|
|
42
|
+
const AUDIT = 'ai-audit.jsonl';
|
|
43
|
+
const REPLAYS = 'ai-replays.json';
|
|
44
|
+
const MAX = 3;
|
|
45
|
+
const QUOTE_MIN = 12;
|
|
46
|
+
const NEAR = 2;
|
|
47
|
+
const CONF = ['high', 'medium', 'low', 'none'];
|
|
48
|
+
const WRITE_KEYS = new Set(['write', 'apply', 'ops', 'patch', 'send', 'command', 'exec']);
|
|
49
|
+
const JOB_ID = /^[a-z][a-z0-9-]{1,40}$/;
|
|
50
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/;
|
|
51
|
+
|
|
52
|
+
const flagOff = (v) => /^(off|0|false|no)$/i.test(String(v == null ? '' : v).trim());
|
|
53
|
+
const one = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
54
|
+
const norm = (s) => String(s == null ? '' : s).toLowerCase().replace(/\s+/g, ' ').trim();
|
|
55
|
+
const envName = (job) => 'PMOS_AI_' + String(job).toUpperCase().replace(/-/g, '_');
|
|
56
|
+
|
|
57
|
+
/* ── kill switch ─────────────────────────────────────────────────────────────────────────── */
|
|
58
|
+
|
|
59
|
+
function settings(settingsDir) {
|
|
60
|
+
if (!settingsDir) return { on: true, off: [] };
|
|
61
|
+
try {
|
|
62
|
+
const S = require('./settings-store.js');
|
|
63
|
+
const offList = S.get(settingsDir, 'ai.off');
|
|
64
|
+
return { on: S.get(settingsDir, 'ai.on') !== false, off: Array.isArray(offList) ? offList : [] };
|
|
65
|
+
} catch { return { on: true, off: [] }; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** null when the job may run; else the reason it is off. */
|
|
69
|
+
function off(job, { env = process.env, settingsDir = null } = {}) {
|
|
70
|
+
if (flagOff((env || {}).PMOS_AI)) return 'every AI job is off (PMOS_AI)';
|
|
71
|
+
if (job && flagOff((env || {})[envName(job)])) return `${job} is off (${envName(job)})`;
|
|
72
|
+
const s = settings(settingsDir);
|
|
73
|
+
if (!s.on) return 'every AI job is off (Settings)';
|
|
74
|
+
if (job && s.off.includes(job)) return `${job} is off (Settings)`;
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function setOff(settingsDir, job, isOff) {
|
|
79
|
+
const S = require('./settings-store.js');
|
|
80
|
+
if (!job) return S.set(settingsDir, 'ai.on', !isOff);
|
|
81
|
+
if (!JOB_ID.test(String(job))) return { ok: false, error: `${JSON.stringify(String(job))} is not a job id (lowercase letters, digits and dashes)` };
|
|
82
|
+
const cur = settings(settingsDir).off.filter((x) => x !== job);
|
|
83
|
+
return S.set(settingsDir, 'ai.off', isOff ? [...cur, job].sort() : cur);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* ── the citation check ──────────────────────────────────────────────────────────────────── */
|
|
87
|
+
|
|
88
|
+
function linesOf(abs, cache) {
|
|
89
|
+
if (cache.has(abs)) return cache.get(abs);
|
|
90
|
+
let L = null;
|
|
91
|
+
try { const st = fs.statSync(abs); if (st.isFile() && st.size <= 20e6) L = fs.readFileSync(abs, 'utf8').split('\n'); } catch { /* unreadable: unverified */ }
|
|
92
|
+
cache.set(abs, L);
|
|
93
|
+
return L;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Where a ref points: { abs, line } for a verifiable ref, { context: true } for jira:/http refs, { error } otherwise. */
|
|
97
|
+
function resolveRef(ref, { root, stateDir }) {
|
|
98
|
+
const r = String(ref || '').trim();
|
|
99
|
+
if (!r) return { error: 'a source has no ref' };
|
|
100
|
+
if (/^(https?:\/\/|jira:|slack:|mail:|calendar:)/i.test(r)) return { context: true };
|
|
101
|
+
const m = /^(state:)?(.+):(\d+)$/.exec(r);
|
|
102
|
+
if (!m) return { error: `ref ${JSON.stringify(one(r, 80))} is not path:line` };
|
|
103
|
+
const base = m[1] ? stateDir : root;
|
|
104
|
+
if (!base) return { error: `ref ${JSON.stringify(one(r, 80))} names a folder this run has none of` };
|
|
105
|
+
const baseAbs = path.resolve(base);
|
|
106
|
+
const abs = path.resolve(baseAbs, m[2]);
|
|
107
|
+
if (abs !== baseAbs && !abs.startsWith(baseAbs + path.sep)) return { error: `ref ${JSON.stringify(one(r, 80))} is outside the ${m[1] ? 'state folder' : 'repository'}` };
|
|
108
|
+
return { abs, line: Number(m[3]) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function quoteAt(L, line, quote) {
|
|
112
|
+
const q = norm(quote);
|
|
113
|
+
if (!L || line < 1 || line > L.length) return false;
|
|
114
|
+
const from = Math.max(0, line - 1 - NEAR), to = Math.min(L.length, line + NEAR);
|
|
115
|
+
return norm(L.slice(from, to).join(' ')).includes(q);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function writeKey(v, depth = 0) {
|
|
119
|
+
if (!v || typeof v !== 'object' || depth > 8) return null;
|
|
120
|
+
for (const [k, x] of Object.entries(v)) {
|
|
121
|
+
if (WRITE_KEYS.has(String(k).toLowerCase())) return k;
|
|
122
|
+
const deep = writeKey(x, depth + 1);
|
|
123
|
+
if (deep) return deep;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Check one proposal. Pure apart from reading the cited files. → { ok, p (normalized), errors } */
|
|
129
|
+
function check(p, { root, stateDir, cache = new Map() } = {}) {
|
|
130
|
+
const errors = [];
|
|
131
|
+
if (!p || typeof p !== 'object' || Array.isArray(p)) return { ok: false, p: null, errors: ['a proposal must be an object'] };
|
|
132
|
+
const wk = writeKey(p);
|
|
133
|
+
if (wk) errors.push(`propose-only: the key "${wk}" names a write`);
|
|
134
|
+
const id = String(p.id || '').trim();
|
|
135
|
+
if (!ID.test(id)) errors.push('id is missing or has characters other than letters, digits and . _ : -');
|
|
136
|
+
const title = one(p.title, 200), say = one(p.say, 1200);
|
|
137
|
+
if (!title) errors.push('title is empty');
|
|
138
|
+
if (!say) errors.push('say is empty');
|
|
139
|
+
const confidence = String(p.confidence || '').trim();
|
|
140
|
+
if (!CONF.includes(confidence)) errors.push(`confidence must be one of ${CONF.join(', ')}`);
|
|
141
|
+
const claimsIn = p.claims == null ? [] : p.claims;
|
|
142
|
+
if (!Array.isArray(claimsIn)) errors.push('claims must be a list');
|
|
143
|
+
const claims = [];
|
|
144
|
+
const verifiedRefs = new Set();
|
|
145
|
+
(Array.isArray(claimsIn) ? claimsIn : []).slice(0, 20).forEach((c, i) => {
|
|
146
|
+
const text = one(c && c.text, 600);
|
|
147
|
+
if (!text) { errors.push(`claim ${i + 1} has no text`); return; }
|
|
148
|
+
const srcIn = Array.isArray(c.sources) ? c.sources.slice(0, 8) : [];
|
|
149
|
+
if (!srcIn.length) { errors.push(`claim ${i + 1} cites no source`); return; }
|
|
150
|
+
const sources = [];
|
|
151
|
+
let verified = 0;
|
|
152
|
+
for (const s of srcIn) {
|
|
153
|
+
const ref = one(s && s.ref, 300), quote = String((s && s.quote) || '').slice(0, 2000), label = one(s && s.label, 120) || ref;
|
|
154
|
+
const at = resolveRef(ref, { root, stateDir });
|
|
155
|
+
if (at.error) { errors.push(`claim ${i + 1}: ${at.error}`); continue; }
|
|
156
|
+
if (at.context) { sources.push({ label, ref, quote: one(quote, 400), verified: false }); continue; }
|
|
157
|
+
if (norm(quote).length < QUOTE_MIN) { errors.push(`claim ${i + 1}: the quote for ${ref} is shorter than ${QUOTE_MIN} characters`); continue; }
|
|
158
|
+
if (!quoteAt(linesOf(at.abs, cache), at.line, quote)) { errors.push(`claim ${i + 1}: the quote is not at ${ref}`); continue; }
|
|
159
|
+
sources.push({ label, ref, quote: one(quote, 400), verified: true });
|
|
160
|
+
verifiedRefs.add(ref);
|
|
161
|
+
verified++;
|
|
162
|
+
}
|
|
163
|
+
if (!verified) { errors.push(`claim ${i + 1} has no verified source`); return; }
|
|
164
|
+
claims.push({ text, sources });
|
|
165
|
+
});
|
|
166
|
+
if (confidence === 'none' && Array.isArray(claimsIn) && claimsIn.length) errors.push('confidence none ("no record") must have no claims');
|
|
167
|
+
if (confidence && confidence !== 'none' && Array.isArray(claimsIn) && !claimsIn.length) errors.push('a proposal with claims must cite them; use confidence none for "no record"');
|
|
168
|
+
if (confidence === 'high' && verifiedRefs.size < 2) errors.push('confidence high needs 2 or more distinct verified sources');
|
|
169
|
+
const data = p.data && typeof p.data === 'object' && !Array.isArray(p.data) ? p.data : undefined;
|
|
170
|
+
if (errors.length) return { ok: false, p: null, errors };
|
|
171
|
+
return { ok: true, p: { id, title, say, confidence, claims, ...(data ? { data } : {}) }, errors: [] };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* ── audit ───────────────────────────────────────────────────────────────────────────────── */
|
|
175
|
+
|
|
176
|
+
function audit(stateDir, line) {
|
|
177
|
+
if (!stateDir) return;
|
|
178
|
+
try {
|
|
179
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
180
|
+
fs.appendFileSync(path.join(stateDir, AUDIT), JSON.stringify({ at: new Date().toISOString(), ...line }) + '\n', { mode: 0o600 });
|
|
181
|
+
} catch { /* the audit never stops a run; a failed append is lost, never thrown */ }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function readAudit(stateDir, { job = null, limit = 200 } = {}) {
|
|
185
|
+
let text = '';
|
|
186
|
+
try { text = fs.readFileSync(path.join(stateDir, AUDIT), 'utf8'); } catch { return []; }
|
|
187
|
+
const out = [];
|
|
188
|
+
for (const s of text.split('\n')) {
|
|
189
|
+
if (!s.trim()) continue;
|
|
190
|
+
try { const o = JSON.parse(s); if (!job || o.job === job) out.push(o); } catch { /* a torn line */ }
|
|
191
|
+
}
|
|
192
|
+
return out.slice(-limit);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/* ── the replay gate ─────────────────────────────────────────────────────────────────────── */
|
|
196
|
+
|
|
197
|
+
function readReplays(stateDir) {
|
|
198
|
+
try { const o = JSON.parse(fs.readFileSync(path.join(stateDir, REPLAYS), 'utf8')); return o && typeof o === 'object' ? o : {}; } catch { return {}; }
|
|
199
|
+
}
|
|
200
|
+
function writeReplays(stateDir, o) {
|
|
201
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
202
|
+
const f = path.join(stateDir, REPLAYS), tmp = f + '.' + process.pid + '.tmp';
|
|
203
|
+
fs.writeFileSync(tmp, JSON.stringify(o, null, 2) + '\n', { mode: 0o600 });
|
|
204
|
+
fs.renameSync(tmp, f);
|
|
205
|
+
}
|
|
206
|
+
const keyOf = (job, version) => `${job}@${version}`;
|
|
207
|
+
|
|
208
|
+
function ready(stateDir, job, version) {
|
|
209
|
+
const r = readReplays(stateDir)[keyOf(job, version)];
|
|
210
|
+
return !!(r && r.ok === true);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* What the guard alone would file from a list: the same checks as file(), without the gate, audit or deliver. */
|
|
214
|
+
function sift(list, opts) {
|
|
215
|
+
const cache = new Map(), filed = [], dropped = [];
|
|
216
|
+
const arr = Array.isArray(list) ? list : [];
|
|
217
|
+
for (const [i, p] of arr.entries()) {
|
|
218
|
+
const id = p && p.id ? String(p.id) : `#${i + 1}`;
|
|
219
|
+
if (filed.length >= (opts.max || MAX)) { dropped.push({ id, why: `over the ${opts.max || MAX} proposals a run may file` }); continue; }
|
|
220
|
+
const c = check(p, { ...opts, cache });
|
|
221
|
+
if (c.ok) filed.push(c.p); else dropped.push({ id, why: c.errors.join('; ') });
|
|
222
|
+
}
|
|
223
|
+
return { filed, dropped };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* The guard's own negative cases, made from a proposal that passed. Each must be dropped. */
|
|
227
|
+
function negatives(p) {
|
|
228
|
+
const clone = () => JSON.parse(JSON.stringify(p));
|
|
229
|
+
const first = (q) => q.claims[0].sources.find((s) => s.verified !== false) || q.claims[0].sources[0];
|
|
230
|
+
const out = [];
|
|
231
|
+
if (!p.claims || !p.claims.length) return out;
|
|
232
|
+
{ const q = clone(); first(q).quote = 'this sentence was never written anywhere in the sources ' + Date.now(); out.push({ name: 'a fabricated quote is dropped', p: q }); }
|
|
233
|
+
{ const q = clone(); q.claims[0].sources = []; out.push({ name: 'a claim with no source is dropped', p: q }); }
|
|
234
|
+
{ const q = clone(); first(q).ref = '../../etc/passwd:1'; out.push({ name: 'a ref outside the repository is dropped', p: q }); }
|
|
235
|
+
{ const q = clone(); q.data = { ...(q.data || {}), apply: { op: 'set' } }; out.push({ name: 'a write key is dropped', p: q }); }
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function expectFails(name, expect, got) {
|
|
240
|
+
const e = expect || {};
|
|
241
|
+
if (e.none && got.filed.length) return `expected nothing filed, got ${got.filed.length}`;
|
|
242
|
+
if (Number.isInteger(e.filed) && got.filed.length !== e.filed) return `expected ${e.filed} filed, got ${got.filed.length}${got.dropped.length ? ' (dropped: ' + one(got.dropped.map((d) => d.why).join(' | '), 300) + ')' : ''}`;
|
|
243
|
+
if (Number.isInteger(e.min) && got.filed.length < e.min) return `expected at least ${e.min} filed, got ${got.filed.length}`;
|
|
244
|
+
if (Array.isArray(e.ids)) for (const id of e.ids) if (!got.filed.some((p) => p.id === id)) return `expected ${id} to be filed`;
|
|
245
|
+
if (Array.isArray(e.cites)) for (const c of e.cites) if (!got.filed.some((p) => p.claims.some((cl) => cl.sources.some((s) => s.verified && s.ref.split(':').slice(0, -1).join(':').replace(/^state:/, '') === c)))) return `expected a verified citation of ${c}`;
|
|
246
|
+
if (Array.isArray(e.dropped)) for (const sub of e.dropped) if (!got.dropped.some((d) => d.why.includes(sub))) return `expected a drop that says "${sub}"`;
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Run a job's replay cases plus the guard's negatives; record the result for (job, version). */
|
|
251
|
+
async function replay(job, version, cases, { root, stateDir, max = MAX } = {}) {
|
|
252
|
+
const failed = [];
|
|
253
|
+
let seed = null;
|
|
254
|
+
if (!JOB_ID.test(String(job || ''))) return { ok: false, failed: [{ name: 'job', why: 'the job id is not valid' }] };
|
|
255
|
+
if (!Array.isArray(cases) || !cases.length) failed.push({ name: '(cases)', why: 'a job needs at least 1 replay case' });
|
|
256
|
+
for (const c of Array.isArray(cases) ? cases : []) {
|
|
257
|
+
const name = one(c && c.name, 120) || '(unnamed)';
|
|
258
|
+
let list;
|
|
259
|
+
try { list = await c.run(); } catch (e) { failed.push({ name, why: 'the case threw: ' + one(e && e.message, 200) }); continue; }
|
|
260
|
+
const got = sift(list, { root, stateDir, max });
|
|
261
|
+
const why = expectFails(name, c.expect, got);
|
|
262
|
+
if (why) failed.push({ name, why });
|
|
263
|
+
if (!seed) seed = got.filed.find((p) => p.claims.length) || null;
|
|
264
|
+
}
|
|
265
|
+
if (seed) for (const n of negatives(seed)) {
|
|
266
|
+
const got = sift([n.p], { root, stateDir, max });
|
|
267
|
+
if (got.filed.length) failed.push({ name: n.name, why: 'the guard filed it' });
|
|
268
|
+
} else if (!failed.length) failed.push({ name: '(negatives)', why: 'no case filed a cited proposal, so the guard could not test its own drops; add a case that files one' });
|
|
269
|
+
const ok = !failed.length;
|
|
270
|
+
if (stateDir) {
|
|
271
|
+
const all = readReplays(stateDir);
|
|
272
|
+
all[keyOf(job, version)] = { ok, at: new Date().toISOString(), cases: (cases || []).length, failed: failed.slice(0, 20) };
|
|
273
|
+
try { writeReplays(stateDir, all); } catch { /* an unwritten pass keeps the gate shut: the safe side */ }
|
|
274
|
+
audit(stateDir, { job, version: String(version), event: 'replay', ok, failed: failed.map((f) => f.name + ': ' + one(f.why, 160)).slice(0, 10) });
|
|
275
|
+
}
|
|
276
|
+
return { ok, failed };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/* ── the door ────────────────────────────────────────────────────────────────────────────── */
|
|
280
|
+
|
|
281
|
+
async function file(o, proposals, deliver) {
|
|
282
|
+
const { job, version, run = null, root, stateDir, settingsDir = stateDir, env = process.env, max = MAX } = o || {};
|
|
283
|
+
const base = { job, version: String(version), ...(run ? { run } : {}) };
|
|
284
|
+
if (!JOB_ID.test(String(job || ''))) return { filed: [], dropped: [], error: 'the job id is not valid' };
|
|
285
|
+
const why = off(job, { env, settingsDir });
|
|
286
|
+
if (why) { audit(stateDir, { ...base, event: 'off', why }); return { filed: [], dropped: [], off: why }; }
|
|
287
|
+
const list = Array.isArray(proposals) ? proposals : [];
|
|
288
|
+
if (!ready(stateDir, job, version)) {
|
|
289
|
+
audit(stateDir, { ...base, event: 'held', n: list.length, why: 'no passing replay for this version' });
|
|
290
|
+
return { filed: [], dropped: [], held: true };
|
|
291
|
+
}
|
|
292
|
+
const got = sift(list, { root, stateDir, max });
|
|
293
|
+
for (const d of got.dropped) audit(stateDir, { ...base, event: 'dropped', id: d.id, why: one(d.why, 400) });
|
|
294
|
+
if (!got.filed.length) { audit(stateDir, { ...base, event: 'end', filed: 0, dropped: got.dropped.length }); return { filed: [], dropped: got.dropped }; }
|
|
295
|
+
try { await deliver(got.filed); } catch (e) {
|
|
296
|
+
audit(stateDir, { ...base, event: 'end', filed: 0, dropped: got.dropped.length, error: 'deliver failed: ' + one(e && e.message, 200) });
|
|
297
|
+
return { filed: [], dropped: got.dropped, error: 'deliver failed' };
|
|
298
|
+
}
|
|
299
|
+
for (const p of got.filed) audit(stateDir, { ...base, event: 'filed', id: p.id, confidence: p.confidence, refs: p.claims.flatMap((c) => c.sources.map((s) => s.ref)).slice(0, 12) });
|
|
300
|
+
audit(stateDir, { ...base, event: 'end', filed: got.filed.length, dropped: got.dropped.length });
|
|
301
|
+
return { filed: got.filed.map((p) => p.id), dropped: got.dropped };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** What `pmos ai status` shows: the switch, each job's replay state and its last run. */
|
|
305
|
+
function status({ stateDir, settingsDir = stateDir, env = process.env } = {}) {
|
|
306
|
+
const s = settings(settingsDir), reps = readReplays(stateDir), lines = readAudit(stateDir, { limit: 2000 });
|
|
307
|
+
const jobs = {};
|
|
308
|
+
for (const [k, r] of Object.entries(reps)) { const [job, version] = k.split('@'); (jobs[job] = jobs[job] || { job }).replay = { version, ok: r.ok, at: r.at }; }
|
|
309
|
+
for (const l of lines) if (l.job && l.event === 'end') (jobs[l.job] = jobs[l.job] || { job: l.job }).last = { at: l.at, filed: l.filed, dropped: l.dropped };
|
|
310
|
+
for (const j of Object.values(jobs)) j.off = off(j.job, { env, settingsDir });
|
|
311
|
+
return { on: !flagOff((env || {}).PMOS_AI) && s.on, settingsOff: s.off, jobs: Object.values(jobs).sort((a, b) => a.job.localeCompare(b.job)) };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
module.exports = { AUDIT, REPLAYS, MAX, QUOTE_MIN, off, setOff, check, replay, ready, file, audit, readAudit, status, envName, resolveRef };
|
|
@@ -17,6 +17,7 @@ const headless = require('./headless.js');
|
|
|
17
17
|
const Goal = require('./goal-chat.js');
|
|
18
18
|
const ReportChat = require('./report-chat.js');
|
|
19
19
|
const ProgramChat = require('./program-chat.js');
|
|
20
|
+
const PastChat = require('./past-chat.js');
|
|
20
21
|
const WorkflowChat = require('./workflow-chat.js');
|
|
21
22
|
const IniChat = require('./initiative-chat.js');
|
|
22
23
|
|
|
@@ -221,6 +222,8 @@ function chat({ runtimeDir, key, text, model, context, sessions, wsId, iniInfo,
|
|
|
221
222
|
if (cmd) { const b = Goal.begin({ runtimeDir, key, text: cmd.text, now }); if (b.error) return fail(400, b.error); msg = b.prompt; goal = b.id; }
|
|
222
223
|
// D99: the New workflow chat (Settings › Workflows) carries the workflow rule on every message (lib/workflow-chat.js)
|
|
223
224
|
else if (WorkflowChat.isKey(key) && !q.startsWith('/')) msg = WorkflowChat.wrap(q);
|
|
225
|
+
// ask-the-past: "why did we drop X?" in any chat carries the past rule: answer from `pmos past ask`, cite every line, "No record found" when none
|
|
226
|
+
else if (PastChat.isAsk(q)) msg = PastChat.wrap(q);
|
|
224
227
|
// bug 2026-10-25: in a live initiative chat, a message about a stage, a workflow or a move carries the initiative's workflow and stage, read now
|
|
225
228
|
else if (alive && /^init:/.test(key) && IniChat.isAsk(q)) msg = IniChat.wrap(q, info(), iniId(key));
|
|
226
229
|
// premium-reports-from-chat R1: a message about a report, a chart or a dashboard carries the pmos-report rule (lib/report-chat.js)
|