create-pmos 0.518.0 → 0.520.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 +39 -47
- package/payload/AGENTS.md +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/board-columns-test.js +1 -1
- package/workspace/board-programs-test.js +26 -7
- package/workspace/briefing-premium-board-test.js +3 -3
- package/workspace/calm-timeline-test.js +2 -2
- package/workspace/changelog.json +38 -0
- package/workspace/guide/agent-capabilities.md +2 -1
- package/workspace/guide/descriptions.md +43 -0
- package/workspace/guide/index.md +3 -2
- package/workspace/guide/initiatives-roadmap.md +1 -0
- package/workspace/guide/next-prompt.md +4 -2
- package/workspace/guide/programs.md +2 -1
- package/workspace/guide/settings.md +1 -1
- package/workspace/lib/board-trail.js +8 -3
- package/workspace/lib/description-props.js +214 -0
- package/workspace/lib/headless.js +12 -2
- package/workspace/lib/initiative-cli.js +28 -2
- package/workspace/lib/initiative-writer.js +41 -1
- package/workspace/lib/proposals.js +13 -4
- package/workspace/lib/routes-next-prompt.js +67 -27
- package/workspace/lib/routes-proposals.js +5 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/bd-span.js +13 -17
- package/workspace/public/pm/cozy-settings.js +8 -7
- package/workspace/public/pm/ini-about.js +26 -11
- package/workspace/public/pm/ini-props.js +5 -3
- package/workspace/public/pm/ini-view-model.js +1 -1
- package/workspace/public/pm/ini-view.css +1 -0
- package/workspace/public/pm/ini-view.js +1 -1
- package/workspace/public/pm/premium-vw-guide.css +7 -2
- package/workspace/server.js +15 -1
- package/workspace/test-lists/initiative-description-field.json +5 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* description-props.js — every initiative has its own description, drafted by Claude and kept by the PM (initiative initiative-description-field).
|
|
3
|
+
*
|
|
4
|
+
* drafts(S, { only? }) PURE-ISH (reads files) → { drafts, ask: [{ id, title }] } S is lib/outcomes.js scan(root)
|
|
5
|
+
* new an initiative with no `description:` line: the PRD's paragraph (the first under Problem, Why, Background, Context, Summary or
|
|
6
|
+
* Overview, else its first prose paragraph), else the record's own first paragraph with the record boilerplate left out
|
|
7
|
+
* ("Feature lane.", "Repo-native initiative record", the gate notes). Nothing readable: no draft, the id is listed in `ask`.
|
|
8
|
+
* enrich a description exists and the PRD's paragraph is fuller and not in it yet: the PRD's paragraph replaces it once the PM keeps it.
|
|
9
|
+
* Each draft is the ai-guard shape { id, title, say, confidence, claims: [{ text, sources: [{ label, ref, quote }] }], data: { kind,
|
|
10
|
+
* initiative, text, from? } }, ref path:line from the repository root and quote that line's text. No model: the sources' own words.
|
|
11
|
+
* paragraphOf(lines, { prd }) → { text, n } | null the paragraph and the 1-based line it starts on
|
|
12
|
+
* create(deps) → { tick({ now }), runProduct(w, { now }) } once a day per product, at most PER_DAY drafts under the initiative's ✦
|
|
13
|
+
* (lib/proposals.js group `description`, section Check these), through lib/ai-guard.js file() (job `descriptions`, replay per product).
|
|
14
|
+
* A dropped draft is not filed again; an open `new` draft goes once the record has a description. It never writes a record: only ✓.
|
|
15
|
+
* deps: as lib/outcome-props.js { workspaces(), isLead(w), stateDirFor(w), boardOf(w, id), runtime(), open(now), env, every, mayRun(), warn() }
|
|
16
|
+
* Off: PMOS_DESCRIPTIONS=off, or `pmos ai off descriptions`. */
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
const O = require('./outcomes.js');
|
|
21
|
+
const PR = require('./proposals.js');
|
|
22
|
+
|
|
23
|
+
const JOB = 'descriptions';
|
|
24
|
+
const VERSION = 'descriptions-1';
|
|
25
|
+
const PER_DAY = 3;
|
|
26
|
+
const MAX = 700;
|
|
27
|
+
const MIN = 60;
|
|
28
|
+
const HEAD = /^#{1,3}\s*(problem|why|background|context|summary|overview)\b/i;
|
|
29
|
+
const SKIP = /^\s*([-*+]|\d+[.)])\s|^\s*>|^\s*\||^\s*```|^\s*<!--|^\*\*[^*]+:\*\*/;
|
|
30
|
+
// record boilerplate: a sentence that speaks about the record, the lane or the gates, not about the work
|
|
31
|
+
const META = /\blane\b|\(D\d+[^)]*\)|done-criteria|Initiative Gate|frontmatter|front matter|Repo-native|^Anchored to\b|^Batch \d+ item \d+\.?$|\bno PRD\b|\bno rubric\b/i;
|
|
32
|
+
|
|
33
|
+
const flagOff = (v) => /^(off|0|false|no)$/i.test(String(v == null ? '' : v).trim());
|
|
34
|
+
const off = (env) => flagOff((env || process.env).PMOS_DESCRIPTIONS);
|
|
35
|
+
const rel = (root, f) => path.relative(root, f).split(path.sep).join('/');
|
|
36
|
+
const linesOf = (f) => { try { return fs.readFileSync(f, 'utf8').replace(/\r\n?/g, '\n').split('\n'); } catch { return null; } };
|
|
37
|
+
const plain = (s) => String(s).replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/[*`]+/g, '').replace(/(^|\W)_+(?=\w)|(?<=\w)_+(?=\W|$)/g, '$1').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
|
38
|
+
const cut = (t) => (t.length > MAX ? t.slice(0, MAX - 1).replace(/\s+\S*$/, '') + '…' : t);
|
|
39
|
+
const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
40
|
+
const clip = (s, n) => { const t = String(s || ''); return t.length > n ? t.slice(0, n - 1).replace(/\s+\S*$/, '') + '…' : t; };
|
|
41
|
+
const localDay = (ms) => { const d = new Date(ms); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); };
|
|
42
|
+
|
|
43
|
+
/** The body's paragraphs after the front matter: [{ n (1-based), lines: [text] }] */
|
|
44
|
+
function paragraphs(L) {
|
|
45
|
+
let i = 0;
|
|
46
|
+
if (/^---\s*$/.test(L[0] || '')) { i = 1; while (i < L.length && !/^---\s*$/.test(L[i])) i++; i++; }
|
|
47
|
+
const out = [];
|
|
48
|
+
let cur = null;
|
|
49
|
+
for (; i < L.length; i++) {
|
|
50
|
+
const l = L[i];
|
|
51
|
+
if (!l.trim() || /^#{1,6}\s/.test(l)) { if (cur) out.push(cur); cur = null; if (/^#{1,6}\s/.test(l)) out.push({ n: i + 1, head: l }); continue; }
|
|
52
|
+
if (!cur) { if (SKIP.test(l)) continue; cur = { n: i + 1, lines: [] }; }
|
|
53
|
+
else if (SKIP.test(l)) { out.push(cur); cur = null; continue; }
|
|
54
|
+
cur.lines.push(l.trim());
|
|
55
|
+
}
|
|
56
|
+
if (cur) out.push(cur);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
/** The paragraph a PRD (prd: true) or a record gives, boilerplate left out of a record's. → { text, n } | null */
|
|
60
|
+
function paragraphOf(L, { prd = false } = {}) {
|
|
61
|
+
if (!L) return null;
|
|
62
|
+
const P = paragraphs(L);
|
|
63
|
+
if (prd) {
|
|
64
|
+
const h = P.findIndex((p) => p.head && HEAD.test(p.head));
|
|
65
|
+
const after = h >= 0 ? P.slice(h + 1).find((p) => p.head || p.lines) : null;
|
|
66
|
+
if (after && after.lines) { const t = plain(after.lines.join(' ')); if (t.length >= MIN) return { text: cut(t), n: after.n }; }
|
|
67
|
+
const first = P.find((p) => p.lines && plain(p.lines.join(' ')).length >= MIN);
|
|
68
|
+
return first ? { text: cut(plain(first.lines.join(' '))), n: first.n } : null;
|
|
69
|
+
}
|
|
70
|
+
// a record: the first paragraph before any section heading that is about the work. A paragraph with any sentence about the record, its
|
|
71
|
+
// lane or the gates is boilerplate and is skipped whole (precision first: a weak draft costs the PM a ✕).
|
|
72
|
+
for (const p of P) {
|
|
73
|
+
if (p.head) { if (/^#{2,6}\s/.test(p.head)) break; continue; }
|
|
74
|
+
const t = plain(p.lines.join(' '));
|
|
75
|
+
if (t.split(/(?<=[.!?:])\s+/).some((x) => META.test(x))) continue;
|
|
76
|
+
if (!/^[A-Z“"]/.test(t) || /^[\w ]{1,24}:\s/.test(t)) continue; // a cut-off continuation or a labelled note ("Rollback: …") is not a description
|
|
77
|
+
if (t.length >= MIN && !/:$/.test(t)) return { text: cut(t), n: p.n };
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function prdFile(root, it) {
|
|
83
|
+
const cands = [];
|
|
84
|
+
if (it.prd) cands.push(path.join(root, it.prd), path.join(root, 'pmos', it.prd));
|
|
85
|
+
cands.push(path.join(root, 'planning', 'prd', it.id + '-prd.md'), path.join(root, 'pmos', 'planning', 'prd', it.id + '-prd.md'));
|
|
86
|
+
for (const f of cands) {
|
|
87
|
+
const abs = path.resolve(f);
|
|
88
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) continue;
|
|
89
|
+
try { if (fs.statSync(abs).isFile()) return abs; } catch { /* next */ }
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const srcOf = (root, file, L, n, label) => ({ label, ref: rel(root, file) + ':' + n, quote: String(L[n - 1] || '').trim().slice(0, 400) });
|
|
94
|
+
|
|
95
|
+
/** What a PRD says (the source and text), or null. */
|
|
96
|
+
function fromPrd(S, it) {
|
|
97
|
+
const f = prdFile(S.root, it); if (!f) return null;
|
|
98
|
+
const L = linesOf(f), p = paragraphOf(L, { prd: true });
|
|
99
|
+
return p ? { text: p.text, source: srcOf(S.root, f, L, p.n, 'PRD') } : null;
|
|
100
|
+
}
|
|
101
|
+
function fromRecord(S, it) {
|
|
102
|
+
const f = path.join(S.root, it.record), L = linesOf(f), p = paragraphOf(L);
|
|
103
|
+
return p ? { text: p.text, source: srcOf(S.root, f, L, p.n, 'Its record') } : null;
|
|
104
|
+
}
|
|
105
|
+
/** The PRD says more: not already in the description, and fuller. */
|
|
106
|
+
const saysMore = (desc, prd) => { const d = norm(desc), q = norm(prd); return !!q && !d.includes(q.slice(0, 60)) && !q.includes(d) ? q.length > d.length + 40 : false; };
|
|
107
|
+
|
|
108
|
+
/** The ✦ drafts of one repository. */
|
|
109
|
+
function drafts(S, { only = null } = {}) {
|
|
110
|
+
const out = [], ask = [];
|
|
111
|
+
for (const it of S.subjects) {
|
|
112
|
+
if (only && it.id !== only) continue;
|
|
113
|
+
const desc = it.fm.description ? String(it.fm.description.value || '').trim() : '';
|
|
114
|
+
const prd = fromPrd(S, it);
|
|
115
|
+
if (!desc) {
|
|
116
|
+
const d = prd || fromRecord(S, it);
|
|
117
|
+
if (!d) { ask.push({ id: it.id, title: it.title }); continue; }
|
|
118
|
+
out.push({ id: `description-new:${it.id}`, title: `Description for ${clip(it.title, 80)}`, confidence: 'medium',
|
|
119
|
+
claims: [{ text: clip(`${d.source.label === 'PRD' ? 'Its PRD' : 'Its record'} says: ${d.text}`, 240), sources: [d.source] }],
|
|
120
|
+
say: `It has no description yet. I suggest: "${d.text}"`, data: { kind: 'new', initiative: it.id, text: d.text } });
|
|
121
|
+
} else if (prd && saysMore(desc, prd.text)) {
|
|
122
|
+
out.push({ id: `description-enrich:${it.id}`, title: `A fuller description for ${clip(it.title, 80)}`, confidence: 'medium',
|
|
123
|
+
claims: [{ text: clip('Its PRD says: ' + prd.text, 240), sources: [prd.source] }],
|
|
124
|
+
say: `Its PRD says more than its description now. I suggest: "${prd.text}"`, data: { kind: 'enrich', initiative: it.id, text: prd.text, from: desc } });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { drafts: out, ask };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The proposals line a draft becomes. */
|
|
131
|
+
function lineOf(d, board, run) {
|
|
132
|
+
const type = d.data.kind === 'enrich' ? 'enrich' : 'new', ini = d.data.initiative;
|
|
133
|
+
const c = PR.clean('description', type, { title: type === 'enrich' ? 'A fuller description from the PRD' : 'What this initiative is', why: d.say,
|
|
134
|
+
sources: d.claims.flatMap((x) => x.sources).map((s) => ({ label: s.label, ref: s.ref })), items: [{ id: 'description', title: d.data.text }],
|
|
135
|
+
data: { text: d.data.text, ...(d.data.from ? { from: d.data.from } : {}) } });
|
|
136
|
+
if (c.error) return null;
|
|
137
|
+
return { kind: 'propose', ini, board, run, id: PR.idOf(ini, 'description', type, ''), ...c.prop };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One product's replay cases: its own drafts must all pass, and an initiative with nothing readable gets no draft. */
|
|
141
|
+
function cases(S, list) {
|
|
142
|
+
return [{ name: 'the drafts of ' + String(S.root).split(/[\\/]/).pop(), run: async () => list.slice(0, PER_DAY), expect: { filed: Math.min(PER_DAY, list.length) } },
|
|
143
|
+
{ name: 'nothing readable, no draft', run: async () => drafts({ ...S, subjects: [{ id: 'zz-no-source', title: 'No source', fm: {}, prd: '', record: 'zz-none/zz-no-source.md' }] }).drafts, expect: { none: true } }];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function create(deps) {
|
|
147
|
+
const warn = deps.warn || (() => {});
|
|
148
|
+
const lastDay = new Map();
|
|
149
|
+
let busy = false;
|
|
150
|
+
const guardLib = () => { try { return require('./ai-guard.js'); } catch { return null; } };
|
|
151
|
+
|
|
152
|
+
async function runProduct(w, { now = Date.now() } = {}) {
|
|
153
|
+
const dir = deps.stateDirFor(w); if (!dir) return { ws: w.id, filed: 0, why: 'no state folder' };
|
|
154
|
+
const rt = deps.runtime && deps.runtime();
|
|
155
|
+
const S = O.scan(w.root, { today: localDay(now) });
|
|
156
|
+
const F = PR.read(dir), kept = PR.keptIds(F);
|
|
157
|
+
// a record that has its description now: its open draft goes (zero is silence)
|
|
158
|
+
const has = new Map(S.subjects.map((it) => [it.id, it]));
|
|
159
|
+
for (const p of F.props.values()) {
|
|
160
|
+
if (p.group !== 'description' || kept.has(p.id)) continue;
|
|
161
|
+
const it = has.get(p.ini), sig = PR.sigOf(p);
|
|
162
|
+
if (F.drops.has(sig)) continue;
|
|
163
|
+
const desc = it && it.fm.description ? String(it.fm.description.value || '') : '';
|
|
164
|
+
if (!it || (p.type === 'new' && desc) || (p.type === 'enrich' && p.data && norm(desc) === norm(p.data.text))) PR.append(dir, { by: 'pmos:descriptions' }, { kind: 'drop', ini: p.ini, sig });
|
|
165
|
+
}
|
|
166
|
+
const fresh = [];
|
|
167
|
+
for (const d of drafts(S).drafts) {
|
|
168
|
+
const board = deps.boardOf(w, d.data.initiative); if (!board) continue;
|
|
169
|
+
const l = lineOf(d, board, ''); if (!l) continue;
|
|
170
|
+
const prev = F.props.get(l.id);
|
|
171
|
+
if (kept.has(l.id)) continue;
|
|
172
|
+
if (prev && (F.drops.has(PR.sigOf(prev)) || (prev.data && prev.data.text === l.data.text))) continue; // dropped for good, or filed already
|
|
173
|
+
fresh.push({ d, board });
|
|
174
|
+
if (fresh.length >= PER_DAY) break;
|
|
175
|
+
}
|
|
176
|
+
if (!fresh.length) return { ws: w.id, filed: 0 };
|
|
177
|
+
const AG = guardLib(); if (!AG) return { ws: w.id, filed: 0, why: 'the shared guard is not installed' };
|
|
178
|
+
const version = VERSION + ':' + w.id, run = 'ds' + now.toString(36) + crypto.randomBytes(3).toString('hex');
|
|
179
|
+
if (!AG.off(JOB, { env: deps.env || process.env, settingsDir: rt }) && !AG.ready(rt, JOB, version)) {
|
|
180
|
+
const rep = await AG.replay(JOB, version, cases(S, fresh.map((x) => x.d)), { root: w.root, stateDir: rt, max: PER_DAY });
|
|
181
|
+
if (!rep.ok) { warn('descriptions replay ' + w.id, new Error(rep.failed.map((f) => f.name + ': ' + f.why).join('; '))); return { ws: w.id, filed: 0, replay: rep }; }
|
|
182
|
+
}
|
|
183
|
+
const byId = new Map(fresh.map((x) => [x.d.id, x]));
|
|
184
|
+
const r = await AG.file({ job: JOB, version, run, root: w.root, stateDir: rt, settingsDir: rt, env: deps.env || process.env, max: PER_DAY }, fresh.map((x) => x.d),
|
|
185
|
+
async (acc) => { for (const p of acc) { const x = byId.get(p.id); const l = x && lineOf(x.d, x.board, run); if (l) PR.append(dir, { by: 'agent:descriptions' }, l); } });
|
|
186
|
+
return { ws: w.id, filed: (r.filed || []).length, held: !!r.held, off: r.off || null };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function tick({ now = Date.now() } = {}) {
|
|
190
|
+
if (off(deps.env) || busy) return { off: off(deps.env), running: busy };
|
|
191
|
+
let open = true;
|
|
192
|
+
try { open = deps.open ? !!deps.open(now) : true; } catch { open = true; }
|
|
193
|
+
if (!open) return { closed: true };
|
|
194
|
+
busy = true;
|
|
195
|
+
const out = [];
|
|
196
|
+
try {
|
|
197
|
+
const day = localDay(now);
|
|
198
|
+
for (const w of deps.workspaces()) {
|
|
199
|
+
if (lastDay.get(w.id) === day) continue;
|
|
200
|
+
try { if (deps.isLead && !deps.isLead(w)) continue; } catch { continue; }
|
|
201
|
+
lastDay.set(w.id, day);
|
|
202
|
+
try { out.push(await runProduct(w, { now })); } catch (e) { warn('descriptions ' + w.id, e); }
|
|
203
|
+
}
|
|
204
|
+
} finally { busy = false; }
|
|
205
|
+
return { ran: out };
|
|
206
|
+
}
|
|
207
|
+
if (deps.every > 0) {
|
|
208
|
+
const t = setInterval(() => { try { if (deps.mayRun && !deps.mayRun()) return; } catch { /* no lock: run */ } tick({}).catch((e) => warn('descriptions tick', e)); }, deps.every);
|
|
209
|
+
if (t.unref) t.unref();
|
|
210
|
+
}
|
|
211
|
+
return { tick, runProduct };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
module.exports = { JOB, VERSION, PER_DAY, drafts, paragraphOf, lineOf, cases, create, off, fromPrd, fromRecord };
|
|
@@ -250,7 +250,17 @@ function initTools(line) {
|
|
|
250
250
|
return o.tools.filter((t) => typeof t === 'string' && t.startsWith('mcp__'));
|
|
251
251
|
}
|
|
252
252
|
// The one line every run gets about a refused tool (fix 2026-11-02: a Jira refresh called the same denied tool again and again).
|
|
253
|
-
const DENIED_RULE = 'A tool call that is refused in this run stays refused: never call that tool again. Carry on with the tools you have, and say in your answer what you could not read.';
|
|
253
|
+
const DENIED_RULE = 'A tool call that is refused in this run stays refused: never call that tool again. Carry on with the tools you have, and say in your answer what you could not read. When a tool result is too long and is saved to a file, open that file with Read, and use offset and limit for a long file. Never read it with a shell command: that is refused.';
|
|
254
|
+
// Hotfix 2026-11-04: a Jira search on the Mac returned more than Claude Code keeps inline, so its result went to a tool-results file; the run then
|
|
255
|
+
// tried `Bash F=<file> …` to parse it, Bash was refused, and the run failed. A background run keeps connector results inline up to this limit,
|
|
256
|
+
// and the rule above sends a saved result to Read. The PM's own MAX_MCP_OUTPUT_TOKENS, when set, wins.
|
|
257
|
+
const HEADLESS_ENV = Object.freeze({ MAX_MCP_OUTPUT_TOKENS: '100000' });
|
|
258
|
+
/** The run's environment with HEADLESS_ENV added where the PM has not set the variable. */
|
|
259
|
+
function headlessEnv(env) {
|
|
260
|
+
const out = { ...(env || {}) };
|
|
261
|
+
for (const [k, v] of Object.entries(HEADLESS_ENV)) if (!out[k]) out[k] = v;
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
254
264
|
const DENIED_RE = /Permission to use ([\w.-]+) has been denied|requested permissions to use ([\w.-]+)/;
|
|
255
265
|
/** Follows a run's stream and says when it calls a refused tool a second time: → { see(line) → tool name | null, denied: Map }. */
|
|
256
266
|
function denialGuard(limit = 2) {
|
|
@@ -488,4 +498,4 @@ function runEnvOf(extra) {
|
|
|
488
498
|
return out;
|
|
489
499
|
}
|
|
490
500
|
|
|
491
|
-
module.exports = { CONNECTOR_READ_KINDS, CONNECTOR_READ_SEED, DENIED_RULE, connectorReads, initTools, denialGuard, HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, DRIVE_ORIGINS, SCAN_WEB_ORIGINS, SCAN_REPO_ORIGINS, SCAN_RESEARCH_WEB_ORIGINS, SCAN_RESEARCH_REPO_ORIGINS, WEB_KINDS, REPORT_SETUP_ORIGINS, REPORT_FIND_ORIGINS, CORE_DOC_ORIGINS, REPORT_DESIGN_ORIGINS, REPORT_SAMPLE_FETCH_ORIGINS, REPORT_STRUCTURE_ORIGINS, REPORT_STRUCTURE_DRY_ORIGINS, METRIC_DRAFT_ORIGINS, IMPACT_ORIGINS, TOOLS, SKILL_WRITE_DIRS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, skillWriteDirs, timeoutMs, parseResult, streamLines, outcomeOf, signedOut, SIGNED_OUT_WHY, logLines, switchedOff, RUN_ENV, runEnvOf };
|
|
501
|
+
module.exports = { CONNECTOR_READ_KINDS, CONNECTOR_READ_SEED, DENIED_RULE, HEADLESS_ENV, headlessEnv, connectorReads, initTools, denialGuard, HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, DRIVE_ORIGINS, SCAN_WEB_ORIGINS, SCAN_REPO_ORIGINS, SCAN_RESEARCH_WEB_ORIGINS, SCAN_RESEARCH_REPO_ORIGINS, WEB_KINDS, REPORT_SETUP_ORIGINS, REPORT_FIND_ORIGINS, CORE_DOC_ORIGINS, REPORT_DESIGN_ORIGINS, REPORT_SAMPLE_FETCH_ORIGINS, REPORT_STRUCTURE_ORIGINS, REPORT_STRUCTURE_DRY_ORIGINS, METRIC_DRAFT_ORIGINS, IMPACT_ORIGINS, TOOLS, SKILL_WRITE_DIRS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, skillWriteDirs, timeoutMs, parseResult, streamLines, outcomeOf, signedOut, SIGNED_OUT_WHY, logLines, switchedOff, RUN_ENV, runEnvOf };
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* pmos initiative drop <proposal-eid>
|
|
12
12
|
* pmos initiative list
|
|
13
13
|
* pmos initiative export <id> the breakdown as markdown on stdout; nothing is written
|
|
14
|
+
* pmos initiative describe <id> ["<text>"] no text: its description and the cited lines Claude can draft it from; with text: write
|
|
15
|
+
* its `description:` line (initiative-description-field). An agent writes only after the PM's yes
|
|
14
16
|
* every command: [--json] [--root <dir>]
|
|
15
17
|
*
|
|
16
18
|
* The SAME writers the workspace page uses (lib/initiative-writer.js, lib/placements.js) — no server is needed. Who
|
|
@@ -59,11 +61,11 @@ function docNote(docs) {
|
|
|
59
61
|
return `Not on the Roadmap · ${docs.length} on a document lane: ${names}. A document lane does not show on Home or the Board. If it is work, set lane: in its record to ${WORK_LANES}.`;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
const COMMANDS = { initiative: ['new', 'top', 'up', 'down', 'bottom', 'after', 'check-by', 'adopt', 'drop', 'list', 'export'] };
|
|
64
|
+
const COMMANDS = { initiative: ['new', 'top', 'up', 'down', 'bottom', 'after', 'check-by', 'adopt', 'drop', 'list', 'export', 'describe'] };
|
|
63
65
|
/** The horizon verbs D105 retired: they answer with what to use instead. */
|
|
64
66
|
const RETIRED = Object.freeze(['now', 'next', 'later']);
|
|
65
67
|
const USAGE = [
|
|
66
|
-
'usage: pmos initiative new|top|up|down|bottom|after|check-by|adopt|drop|list|export — initiatives on the Roadmap',
|
|
68
|
+
'usage: pmos initiative new|top|up|down|bottom|after|check-by|adopt|drop|list|export|describe — initiatives on the Roadmap',
|
|
67
69
|
' pmos initiative new "<title>" --lane <lane> --anchor <KR|health:id> [--intent "<text>"] [--id <id>]',
|
|
68
70
|
' pmos initiative new --from capture-YYYY-MM-DD-<n> [--anchor <anchor>] [--lane <lane>] [--title "<title>"]',
|
|
69
71
|
' pmos initiative top|up|down|bottom <id> reorder it on the Roadmap',
|
|
@@ -73,6 +75,7 @@ const USAGE = [
|
|
|
73
75
|
' pmos initiative drop <proposal> close an open proposal',
|
|
74
76
|
' pmos initiative list the Roadmap, ranked, with each lifecycle stage',
|
|
75
77
|
' pmos initiative export <id> the breakdown as markdown on stdout',
|
|
78
|
+
' pmos initiative describe <id> ["<text>"] read its description and what to draft it from; with text, save it (after the PM\'s yes)',
|
|
76
79
|
' every command: [--json] [--root <dir>]',
|
|
77
80
|
'An agent session\'s move applies at once and names the agent. A new initiative lands at the end of the Roadmap.',
|
|
78
81
|
'Now, Next and Later are retired (D105): the rank says what comes first, the lifecycle stage says where it is.',
|
|
@@ -205,6 +208,29 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
|
|
|
205
208
|
return done({ written: r.written }, `✓ proposal ${eid} ${verb === 'adopt' ? 'kept' : 'dropped'}`);
|
|
206
209
|
}
|
|
207
210
|
|
|
211
|
+
if (verb === 'describe') {
|
|
212
|
+
// initiative-description-field: the record's own description. Read with no text; write with text, only after the PM said yes.
|
|
213
|
+
const did = pos[0];
|
|
214
|
+
if (!did || pos.length > 2) { say('usage: pmos initiative describe <id> ["<text>"]'); return 2; }
|
|
215
|
+
if (pos.length === 2) {
|
|
216
|
+
const r = writer.setDescription(root, did, pos[1], { actor: actorLib.fromCli(who) });
|
|
217
|
+
if (!r.ok) return fail(r.error, r.status === 400 ? 2 : 1);
|
|
218
|
+
return done({ id: did, description: r.text, from: r.from, same: !!r.same }, r.same ? `${did} says that already.` : `✓ ${did}: description saved. About shows it now. Commit the record to keep it.`);
|
|
219
|
+
}
|
|
220
|
+
const O = require('./outcomes.js'), DP = require('./description-props.js');
|
|
221
|
+
const S = O.scan(root);
|
|
222
|
+
const it = S.subjects.find((x) => x.id === did);
|
|
223
|
+
if (!it) return fail(`no initiative ${did} here (or it is stopped, or on a document lane)`);
|
|
224
|
+
const cur = it.fm.description ? String(it.fm.description.value || '') : '';
|
|
225
|
+
const srcs = [DP.fromPrd(S, it), DP.fromRecord(S, it)].filter(Boolean);
|
|
226
|
+
if (json) { say(JSON.stringify({ ok: true, id: did, description: cur || null, sources: srcs.map((x) => ({ label: x.source.label, ref: x.source.ref, text: x.text })) })); return 0; }
|
|
227
|
+
say(cur ? `${did}: ${cur}` : `${did} has no description yet.`);
|
|
228
|
+
if (srcs.length) say('Draft it from:\n' + srcs.map((x) => ` · ${x.source.label} [${x.source.ref}]: ${x.text}`).join('\n'));
|
|
229
|
+
else say('Nothing readable to draft it from (no PRD paragraph, no paragraph in its record): ask the PM what it is.');
|
|
230
|
+
say(`Show the draft to the PM; after a yes: pmos initiative describe ${did} "<text>"`);
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
208
234
|
const id = pos[0];
|
|
209
235
|
if (!id) { say(`usage: pmos initiative ${verb} <id>${verb === 'check-by' ? ' <YYYY-MM-DD|none>' : ''}`); return 2; }
|
|
210
236
|
|
|
@@ -298,6 +298,46 @@ function restoreOutcome(root, id, from = {}, to = {}, { actor } = {}) {
|
|
|
298
298
|
return { ok: true, id };
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
/** The record's own description (initiative-description-field): one `description:` line in the front matter, one paragraph of at most
|
|
302
|
+
* DESC_MAX characters. About shows it before the PRD's paragraph (pm/ini-about.js). The PM's ✓ on a ✦ draft (lib/routes-proposals.js) and
|
|
303
|
+
* `pmos initiative describe <id> "<text>"` after the PM's yes reach here. → { ok: true, id, from, text, same? } | refusal */
|
|
304
|
+
const DESC_MAX = 700;
|
|
305
|
+
const frontOfText = (text) => /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/.exec(text);
|
|
306
|
+
const descLine = (lines) => { const at = lines.findIndex((l) => l.startsWith('description:')); return { at, val: at >= 0 ? unquote(lines[at].slice(12)) : '' }; };
|
|
307
|
+
function setDescription(root, id, text0, { actor } = {}) {
|
|
308
|
+
if (!actorLib.valid(actor)) return refuse(500, 'initiative-writer: the caller must pass the actor from pm-guard.js or actor.js');
|
|
309
|
+
const t = typeof text0 === 'string' ? text0.replace(/\s+/g, ' ').trim() : '';
|
|
310
|
+
if (t.length < 12 || t.length > DESC_MAX) return refuse(400, `say what the initiative is in one paragraph of 12 to ${DESC_MAX} characters`, 'description');
|
|
311
|
+
const f = recordFile(root, id);
|
|
312
|
+
if (!f.ok) return f;
|
|
313
|
+
const text = fs.readFileSync(f.file, 'utf8');
|
|
314
|
+
const fm = frontOfText(text);
|
|
315
|
+
if (!fm) return refuse(409, `the record of ${id} has no front matter — nothing was saved`, 'id');
|
|
316
|
+
const lines = fm[1].split(/\r?\n/);
|
|
317
|
+
const { at, val: from } = descLine(lines);
|
|
318
|
+
if (from === t) return { ok: true, id, from, text: t, same: true };
|
|
319
|
+
const line = 'description: ' + q(t);
|
|
320
|
+
if (at >= 0) lines[at] = line; else lines.push(line);
|
|
321
|
+
try { fs.writeFileSync(f.file, '---\n' + lines.join('\n') + '\n---' + fm[2] + text.slice(fm[0].length), { flag: 'w' }); } catch { return refuse(500, 'the record could not be written — nothing was saved'); }
|
|
322
|
+
return { ok: true, id, from, text: t };
|
|
323
|
+
}
|
|
324
|
+
/** The Undo of a ✓ on a description ✦: the line back to `from` (removed when there was none), only while it still says `to`.
|
|
325
|
+
* A record the PM edited since keeps its edit (→ { ok: true, kept: true }). */
|
|
326
|
+
function restoreDescription(root, id, from, to, { actor } = {}) {
|
|
327
|
+
if (!actorLib.valid(actor)) return refuse(500, 'initiative-writer: the caller must pass the actor from pm-guard.js or actor.js');
|
|
328
|
+
const f = recordFile(root, id);
|
|
329
|
+
if (!f.ok) return f;
|
|
330
|
+
const text = fs.readFileSync(f.file, 'utf8');
|
|
331
|
+
const fm = frontOfText(text);
|
|
332
|
+
if (!fm) return refuse(409, `the record of ${id} has no front matter — nothing was saved`, 'id');
|
|
333
|
+
const lines = fm[1].split(/\r?\n/);
|
|
334
|
+
const { at, val } = descLine(lines);
|
|
335
|
+
if (val !== String(to || '')) return { ok: true, id, kept: true };
|
|
336
|
+
if (from) { const line = 'description: ' + q(from); if (at >= 0) lines[at] = line; else lines.push(line); } else if (at >= 0) lines.splice(at, 1);
|
|
337
|
+
try { fs.writeFileSync(f.file, '---\n' + lines.join('\n') + '\n---' + fm[2] + text.slice(fm[0].length), { flag: 'w' }); } catch { return refuse(500, 'the record could not be written — nothing was saved'); }
|
|
338
|
+
return { ok: true, id };
|
|
339
|
+
}
|
|
340
|
+
|
|
301
341
|
function withdrawSplit(root, id, want, { actor } = {}) {
|
|
302
342
|
if (!actorLib.valid(actor)) return refuse(500, 'initiative-writer: the caller must pass the actor from pm-guard.js or actor.js');
|
|
303
343
|
const f = recordFile(root, id);
|
|
@@ -309,4 +349,4 @@ function withdrawSplit(root, id, want, { actor } = {}) {
|
|
|
309
349
|
return { ok: true, removed: true };
|
|
310
350
|
}
|
|
311
351
|
|
|
312
|
-
module.exports = { setOutcome, restoreOutcome, createInitiative, renameInitiative, setAnchor, restoreAnchor, withdrawSplit, recordSha, slugOf, ID_RE, INPUT_KEYS };
|
|
352
|
+
module.exports = { setDescription, restoreDescription, DESC_MAX, setOutcome, restoreOutcome, createInitiative, renameInitiative, setAnchor, restoreAnchor, withdrawSplit, recordSha, slugOf, ID_RE, INPUT_KEYS };
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
* outcome the expected outcome and its check date (D57; lib/outcomes.js, filed by lib/outcome-props.js): type new (no outcome yet) or date
|
|
19
19
|
* (an outcome with no date). data { metric, horizon }; one item { id: 'outcome', title: the metric, due: the date }. Always section
|
|
20
20
|
* 'check': its own ✓, never Keep all. ✓ writes the record's outcome_metric and outcome_horizon lines (lib/initiative-writer.js)
|
|
21
|
+
* description the record's own description (initiative-description-field; filed by lib/description-props.js): type new (none yet) or
|
|
22
|
+
* enrich (the PRD says more). data { text, from? }; one item { id: 'description', title: the text cut }. Always section 'check'.
|
|
23
|
+
* ✓ writes the record's description line (lib/initiative-writer.js setDescription)
|
|
21
24
|
* grill what Claude found when it grilled the initiative by itself (lib/grill.js): type = the angle, no items, a next step in data.next;
|
|
22
25
|
* always section 'check' (its own ✓, never Keep all). 3 show, best rank first; the group's `more` counts the rest
|
|
23
26
|
*
|
|
@@ -56,8 +59,8 @@ const crypto = require('crypto');
|
|
|
56
59
|
|
|
57
60
|
const LEDGER = 'proposals.jsonl';
|
|
58
61
|
const KINDS = new Set(['epoch', 'look', 'src', 'end', 'propose', 'drop', 'keep', 'undo']);
|
|
59
|
-
const GROUPS = ['found', 'dates', 'status', 'names', 'outcome'];
|
|
60
|
-
const TYPES = { found: ['stage', 'docs', 'tasks', 'link', 'maybe', 'unplaced'], dates: ['stage', 'task'], status: ['done'], names: ['tidy'], outcome: ['new', 'date'] };
|
|
62
|
+
const GROUPS = ['found', 'dates', 'status', 'names', 'outcome', 'description'];
|
|
63
|
+
const TYPES = { found: ['stage', 'docs', 'tasks', 'link', 'maybe', 'unplaced'], dates: ['stage', 'task'], status: ['done'], names: ['tidy'], outcome: ['new', 'date'], description: ['new', 'enrich'] };
|
|
61
64
|
const SOURCES = ['drive', 'docs', 'sheets', 'slides', 'jira', 'calendar', 'notes', 'mail', 'slack', 'confluence', 'chat', 'repo'];
|
|
62
65
|
const SOURCE_NAMES = { drive: 'Drive and Slides', docs: 'Docs', sheets: 'Sheets', slides: 'Slides', jira: 'Jira', calendar: 'Calendar and meeting notes', notes: 'Meeting notes',
|
|
63
66
|
mail: 'Gmail', slack: 'Slack', confluence: 'Confluence', chat: 'Chat', repo: 'This repository' };
|
|
@@ -155,7 +158,13 @@ function clean(group, type, input) {
|
|
|
155
158
|
if (!DAY_RE.test(horizon)) return { error: 'data.horizon is the check date, YYYY-MM-DD' };
|
|
156
159
|
data.metric = metric; data.horizon = horizon;
|
|
157
160
|
}
|
|
158
|
-
|
|
161
|
+
if (group === 'description') {
|
|
162
|
+
const d = isObj(input.data) ? input.data : {};
|
|
163
|
+
const text = one(d.text, 700);
|
|
164
|
+
if (text.length < 12) return { error: 'data.text is the description, one paragraph of 12 to 700 characters' };
|
|
165
|
+
data.text = text; if (str(d.from)) data.from = one(d.from, 700);
|
|
166
|
+
}
|
|
167
|
+
return { prop: { group, type, section: group === 'outcome' || group === 'description' ? 'check' : section, title: one(input.title, LIM.title) || null, why, sources: s.v, items, data } };
|
|
159
168
|
}
|
|
160
169
|
/** The proposal's own id: one per initiative, group, type and key, so a newer run files over the older one. */
|
|
161
170
|
const idOf = (ini, group, type, key) => 'pr-' + hash([ini, group, type, key || ''].join('|'));
|
|
@@ -323,7 +332,7 @@ function datesOf(ini, T, o = {}) {
|
|
|
323
332
|
}
|
|
324
333
|
|
|
325
334
|
/* ── what r.props carries ── */
|
|
326
|
-
const ORDER = { outcome: -1, found: 0, dates: 1, status: 2, names: 3, links: 4, grill: 5 };
|
|
335
|
+
const ORDER = { description: -2, outcome: -1, found: 0, dates: 1, status: 2, names: 3, links: 4, grill: 5 };
|
|
327
336
|
const GRILL_SHOWN = 3;
|
|
328
337
|
/**
|
|
329
338
|
* One initiative's proposals, as the ✦ and the Viewer draw them, or null when there is nothing to show.
|
|
@@ -2,16 +2,17 @@
|
|
|
2
2
|
/* routes-next-prompt.js — the chat's suggested next prompt and its Sharper card over HTTP (batch 26 item 2, design B; lib/next-prompt.js).
|
|
3
3
|
*
|
|
4
4
|
* GET /api/next-prompt/prefs → { ok, next, sharpen, sharpenSet, style, rules, api }
|
|
5
|
-
* next: suggestions after a reply are on. sharpen: Sharpen as I type is on now (
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* next: suggestions after a reply are on. sharpen: Sharpen as I type is on now (on unless the PM turned it off; 'auto' is off only
|
|
6
|
+
* while an opted-in API path is failing). sharpenSet: 'auto'|'on'|'off'. style: 'polish'|'engineer'|'own'. rules: whether Your own
|
|
7
|
+
* rules are written. api: the opted-in API path is in use (PMOS_NEXT_PROMPT_API=on and a key). apiDown: { why, auth } while it fails.
|
|
8
8
|
* POST /api/next-prompt/prefs { key: 'next'|'sharpen'|'style'|'rules', value } → { ok } (saved in <runtime>/next-prompt/prefs.json)
|
|
9
9
|
* POST /api/next-prompt/next { key, asked, reply, initiative?, stage?, product?, title? } → { ok, on, cards: [{ text, move, rec }] }
|
|
10
10
|
* POST /api/next-prompt/sharpen { key, text, reply?, initiative?, title? } → { ok, on, text | null }
|
|
11
11
|
*
|
|
12
12
|
* Both POSTs are page presses (lib/pm-guard.js requirePagePress; an agent session header is refused) and run ONE short Claude call:
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* a background read-only `claude -p` run on the PM's own Claude login (lib/headless.js kind 'ask', origin briefing-ask:next-prompt:…), the
|
|
14
|
+
* default (Wawan 2026-11-04). Only with PMOS_NEXT_PROMPT_API=on does it go to the API with the PM's key (lib/anthropic-key.js, model
|
|
15
|
+
* PMOS_NEXT_PROMPT_MODEL or claude-sonnet-5-5), and a failed API call then falls back to the background run. The answer goes through the shared door, lib/ai-guard.js file():
|
|
15
16
|
* the kill switch (`pmos ai off next-prompt`, `pmos ai off sharpen-prompt`, PMOS_AI=off), the replay gate (fixtures/next-prompt-replay.json,
|
|
16
17
|
* recorded answers, never a model), the citation check and the audit (<runtime>/ai-audit.jsonl). A card with no verified source is never
|
|
17
18
|
* shown. The reply and the typed text a card cites are kept in <runtime>/next-prompt/ (the newest 30 files), outside every repository.
|
|
@@ -61,14 +62,29 @@ function setPref(runtimeDir, key, value) {
|
|
|
61
62
|
const get = (ctx, k) => { const v = readPrefs(ctx.runtimeDir)[k]; return PREFS[k](v) ? v : undefined; };
|
|
62
63
|
const envOf = (ctx) => ctx.env || process.env;
|
|
63
64
|
|
|
65
|
+
/* Wawan 2026-11-04: "just default to normal claude session with -p". Both calls run as a background `claude -p` on the PM's own Claude login.
|
|
66
|
+
* The API key is used only when PMOS_NEXT_PROMPT_API=on opts in. */
|
|
64
67
|
function apiKey(ctx) {
|
|
65
68
|
const env = envOf(ctx);
|
|
66
|
-
if (
|
|
69
|
+
if (!/^(on|1|true|yes)$/i.test(String(env.PMOS_NEXT_PROMPT_API || ''))) return null;
|
|
67
70
|
try { return require('./anthropic-key.js').getKey(ctx.runtimeDir, env); } catch { return null; }
|
|
68
71
|
}
|
|
72
|
+
/* The API's health, per state folder, in memory: after a failed call PMOS stops calling the API for DOWN_MS and uses the background run,
|
|
73
|
+
* so a spent balance or a refused key does not fail every reply again. A server restart, or a new key saved, tries the API again. */
|
|
74
|
+
const DOWN_MS = 15 * 60 * 1000;
|
|
75
|
+
const apiDown = new Map(); // runtimeDir → { until, why, auth, key }
|
|
76
|
+
function downOf(ctx, key) {
|
|
77
|
+
const d = apiDown.get(ctx.runtimeDir);
|
|
78
|
+
if (!d) return null;
|
|
79
|
+
if (d.until <= Date.now() || d.key !== keyId(key)) { apiDown.delete(ctx.runtimeDir); return null; }
|
|
80
|
+
return d;
|
|
81
|
+
}
|
|
82
|
+
const keyId = (k) => (k ? crypto.createHash('sha1').update(String(k)).digest('hex').slice(0, 12) : '');
|
|
69
83
|
function prefs(ctx) {
|
|
70
|
-
const set = get(ctx, 'sharpen') || 'auto',
|
|
71
|
-
|
|
84
|
+
const set = get(ctx, 'sharpen') || 'auto', k = apiKey(ctx), down = k ? downOf(ctx, k) : null, api = !!k && !down;
|
|
85
|
+
const style = get(ctx, 'style') || 'engineer', rules = String(get(ctx, 'rules') || '');
|
|
86
|
+
return { next: get(ctx, 'next') !== false, sharpen: set !== 'off' && !(set === 'auto' && k && down), sharpenSet: set, style, rules: !!rules.trim(), rulesText: rules, api,
|
|
87
|
+
...(down ? { apiDown: { why: down.why, auth: down.auth } } : {}) };
|
|
72
88
|
}
|
|
73
89
|
function offWhy(ctx, job) {
|
|
74
90
|
const why = AG.off(job, { env: envOf(ctx), settingsDir: ctx.runtimeDir });
|
|
@@ -81,31 +97,55 @@ function offWhy(ctx, job) {
|
|
|
81
97
|
|
|
82
98
|
/* ── the call ────────────────────────────────────────────────────────────────────────────── */
|
|
83
99
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
100
|
+
// A model the account cannot use (404) tries the next one before the call counts as failed.
|
|
101
|
+
const MODELS = ['claude-sonnet-5-5', 'claude-sonnet-5', 'claude-haiku-5-5'];
|
|
102
|
+
async function viaApi(key, prompt, env, create = null) {
|
|
103
|
+
const make = create || ((o) => { const Anthropic = require('@anthropic-ai/sdk'); return new Anthropic({ apiKey: key }).messages.create(o); });
|
|
104
|
+
const want = String(env.PMOS_NEXT_PROMPT_MODEL || '').trim();
|
|
105
|
+
const models = want ? [want] : MODELS;
|
|
106
|
+
for (let i = 0; i < models.length; i++) {
|
|
107
|
+
try {
|
|
108
|
+
const res = await make({ model: models[i], max_tokens: 800, messages: [{ role: 'user', content: prompt }] });
|
|
109
|
+
return (res.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n');
|
|
110
|
+
} catch (e) { if (!(e && e.status === 404) || i === models.length - 1) throw e; }
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
89
113
|
}
|
|
90
|
-
|
|
114
|
+
/** A background read-only Claude run → its answer text, or null after `fail(why)` says why it gave none. */
|
|
115
|
+
function viaHeadless(ctx, prompt, origin, fail = () => {}) {
|
|
91
116
|
const headless = require('./headless.js');
|
|
92
|
-
if (typeof ctx.spawnSession !== 'function'
|
|
117
|
+
if (typeof ctx.spawnSession !== 'function') { fail('no background run here'); return Promise.resolve(null); }
|
|
118
|
+
if (headless.kindFor({ agent: 'claude', origin }) !== 'ask') { fail('the run would not be read-only'); return Promise.resolve(null); }
|
|
93
119
|
return new Promise((resolve) => {
|
|
94
|
-
let settled = false; const done = (v) => { if (
|
|
95
|
-
const t = setTimeout(() => done(null), TIMEOUT_MS);
|
|
120
|
+
let settled = false; const done = (v, why) => { if (settled) return; settled = true; clearTimeout(t); if (v == null) fail(why); resolve(v); };
|
|
121
|
+
const t = setTimeout(() => done(null, 'the run took longer than ' + Math.round(TIMEOUT_MS / 1000) + ' s'), TIMEOUT_MS);
|
|
96
122
|
let s = null;
|
|
97
123
|
try {
|
|
98
124
|
s = ctx.spawnSession({ name: 'chat: next prompt', agent: 'claude', origin, initialInput: prompt,
|
|
99
|
-
onDone: (d) => { const r = d && d.result; done(r && r.ok && !r.isError ? String(r.text || '') : null); } });
|
|
100
|
-
} catch { s = null; }
|
|
101
|
-
if (!s || s.state === 'exited') done(null);
|
|
125
|
+
onDone: (d) => { const r = d && d.result; done(r && r.ok && !r.isError ? String(r.text || '') : null, 'the run ended without an answer' + (d && d.note ? ': ' + String(d.note).slice(0, 80) : '')); } });
|
|
126
|
+
} catch (e) { s = null; }
|
|
127
|
+
if (!s || s.state === 'exited') done(null, 'the run did not start' + (s && s.note ? ': ' + String(s.note).slice(0, 80) : ''));
|
|
102
128
|
});
|
|
103
129
|
}
|
|
104
|
-
|
|
130
|
+
/** Why an API call failed, in words fit for the audit: the HTTP status and the error's type, never the prompt or the answer. */
|
|
131
|
+
function apiWhy(e) {
|
|
132
|
+
const st = e && (e.status || (e.response && e.response.status));
|
|
133
|
+
const kind = e && ((e.error && e.error.error && e.error.error.type) || e.name || e.code);
|
|
134
|
+
return ['api', st ? String(st) : null, kind ? String(kind).slice(0, 60) : null].filter(Boolean).join(' ');
|
|
135
|
+
}
|
|
136
|
+
/* One short Claude call. With a key it goes to the API; when that call fails (a spent balance, a model the account cannot use, no network),
|
|
137
|
+
* it does not end in silence: the same prompt goes to a background read-only Claude run, and the audit records why the API failed
|
|
138
|
+
* (`pmos ai log next-prompt`); for 15 minutes after, PMOS goes straight to the background run. `ctx.createMessage` replaces the SDK in tests. */
|
|
139
|
+
async function ask(ctx, prompt, { job, key, run }) {
|
|
105
140
|
if (typeof ctx.askClaude === 'function') return ctx.askClaude(prompt, { job, key });
|
|
106
141
|
const k = apiKey(ctx);
|
|
107
|
-
if (k
|
|
108
|
-
|
|
142
|
+
if (k && !downOf(ctx, k)) {
|
|
143
|
+
const failed = (why, auth) => { apiDown.set(ctx.runtimeDir, { until: Date.now() + DOWN_MS, why, auth: !!auth, key: keyId(k) }); AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'api-error', error: why }); };
|
|
144
|
+
try { const t = await viaApi(k, prompt, envOf(ctx), ctx.createMessage || null); if (t && String(t).trim()) return t; failed('api empty answer', false); }
|
|
145
|
+
catch (e) { failed(apiWhy(e), e && (e.status === 401 || e.status === 403)); }
|
|
146
|
+
}
|
|
147
|
+
return viaHeadless(ctx, prompt, `briefing-ask:next-prompt:${job}:${crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 10)}`,
|
|
148
|
+
(why) => AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'run-error', error: String(why).slice(0, 160) }));
|
|
109
149
|
}
|
|
110
150
|
|
|
111
151
|
/* ── what a card cites ───────────────────────────────────────────────────────────────────── */
|
|
@@ -182,7 +222,7 @@ async function next(ctx, b) {
|
|
|
182
222
|
const k = 'n:' + key + ':' + crypto.createHash('sha1').update(reply + '\n' + P.style + '\n' + P.rulesText).digest('hex');
|
|
183
223
|
return once(k, async () => {
|
|
184
224
|
AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'start' });
|
|
185
|
-
const answer = await ask(ctx, NP.nextPrompt({ pm: text(b.asked, 2000), reply, place, style: P.style, rules: P.rulesText, moves }), { job, key });
|
|
225
|
+
const answer = await ask(ctx, NP.nextPrompt({ pm: text(b.asked, 2000), reply, place, style: P.style, rules: P.rulesText, moves }), { job, key, run });
|
|
186
226
|
const cards = NP.parseNext(answer, { moves, place });
|
|
187
227
|
if (!cards.length) { AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'end', filed: 0, dropped: 0, ...(answer == null ? { error: 'no answer' } : {}) }); return { ok: true, on: true, cards: [] }; }
|
|
188
228
|
const rel = keep(ctx.runtimeDir, 'reply', reply);
|
|
@@ -200,7 +240,7 @@ async function sharpen(ctx, b) {
|
|
|
200
240
|
const k = 's:' + key + ':' + crypto.createHash('sha1').update(typed + '\n' + P.style + '\n' + P.rulesText).digest('hex');
|
|
201
241
|
return once(k, async () => {
|
|
202
242
|
AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'start' });
|
|
203
|
-
const answer = await ask(ctx, NP.sharpenPrompt({ text: typed, reply: text(b.reply, 3000), place: placeOf(b), style: P.style, rules: P.rulesText }), { job, key });
|
|
243
|
+
const answer = await ask(ctx, NP.sharpenPrompt({ text: typed, reply: text(b.reply, 3000), place: placeOf(b), style: P.style, rules: P.rulesText }), { job, key, run });
|
|
204
244
|
const out = NP.parseSharpen(answer, typed);
|
|
205
245
|
if (!out) { AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'end', filed: 0, dropped: 0, ...(answer == null ? { error: 'no answer' } : { same: true }) }); return { ok: true, on: true, text: null }; }
|
|
206
246
|
const first = typed.split('\n')[0], rel = keep(ctx.runtimeDir, 'typed', typed);
|
|
@@ -227,7 +267,7 @@ async function handle(req, res, url, ctx) {
|
|
|
227
267
|
if (req.method !== 'GET') return send(bad(405, 'GET or POST only'));
|
|
228
268
|
const P = prefs(ctx), env = envOf(ctx);
|
|
229
269
|
const next = P.next && !AG.off(NP.JOB_NEXT, { env, settingsDir: ctx.runtimeDir }), sh = P.sharpen && !AG.off(NP.JOB_SHARPEN, { env, settingsDir: ctx.runtimeDir });
|
|
230
|
-
return send({ status: 200, body: { ok: true, next, nextSet: P.next, sharpen: sh, sharpenSet: P.sharpenSet, style: P.style, rules: P.rules, rulesText: P.rulesText, api: P.api } });
|
|
270
|
+
return send({ status: 200, body: { ok: true, next, nextSet: P.next, sharpen: sh, sharpenSet: P.sharpenSet, style: P.style, rules: P.rules, rulesText: P.rulesText, api: P.api, ...(P.apiDown ? { apiDown: P.apiDown } : {}) } });
|
|
231
271
|
}
|
|
232
272
|
if (what !== 'next' && what !== 'sharpen') return send(bad(404, 'no such next-prompt route'));
|
|
233
273
|
if (req.method !== 'POST') return send(bad(405, 'POST only'));
|
|
@@ -245,4 +285,4 @@ async function handle(req, res, url, ctx) {
|
|
|
245
285
|
catch (e) { return send(bad(500, 'The suggestion failed: ' + String((e && e.message) || e).slice(0, 200))); }
|
|
246
286
|
}
|
|
247
287
|
|
|
248
|
-
module.exports = { prefixes: ['/api/next-prompt'], handle, VERSION, FIXTURE, prefs, setPref, lineRef, replayCases, next, sharpen };
|
|
288
|
+
module.exports = { prefixes: ['/api/next-prompt'], handle, VERSION, FIXTURE, MODELS, prefs, setPref, lineRef, replayCases, next, sharpen };
|
|
@@ -138,6 +138,10 @@ function applyOne(ctx, p, g, f, ini, x, choices, outbox) {
|
|
|
138
138
|
const d = x.data || {};
|
|
139
139
|
const r = require('./initiative-writer.js').setOutcome(p.w.root, ini, { metric: d.metric, horizon: d.horizon }, { actor: g.actor });
|
|
140
140
|
if (r.ok && !r.same) add({ op: 'outcome', from: r.from, to: { metric: r.metric, horizon: r.horizon } });
|
|
141
|
+
} else if (x.group === 'description') {
|
|
142
|
+
// the record's own description (initiative-description-field): ✓ writes its description line, Undo puts back what was there
|
|
143
|
+
const r = require('./initiative-writer.js').setDescription(p.w.root, ini, (x.data || {}).text, { actor: g.actor });
|
|
144
|
+
if (r.ok && !r.same) add({ op: 'description', from: r.from, to: r.text });
|
|
141
145
|
} else if (x.group === 'grill') {
|
|
142
146
|
// a grill finding (lib/grill.js): ✓ adds its next step as a task, with the finding and its sources in the notes
|
|
143
147
|
// ✎ on the finding: the PM's own words for the next step (choices[id]) replace Claude's
|
|
@@ -301,6 +305,7 @@ const WRITES = {
|
|
|
301
305
|
else if (w.op === 'prd') { try { const file = path.resolve(p.w.root, w.file), text = fs.readFileSync(file, 'utf8'); if (text.includes(w.to)) { fs.writeFileSync(file, text.split(w.to).join(w.from)); back++; } else kept++; } catch { kept++; } continue; }
|
|
302
306
|
else if (w.op === 'doc' || w.op === 'linked' || w.op === 'ran') { back++; continue; } // the fold leaves an undone batch out
|
|
303
307
|
else if (w.op === 'outbox') { kept++; continue; } // a drafted comment waits in the outbox, where the PM drops it
|
|
308
|
+
else if (w.op === 'description') { const r2 = require('./initiative-writer.js').restoreDescription(p.w.root, k.ini, w.from || '', w.to || '', { actor: g.actor }); if (r2.ok && !r2.kept) back++; else kept++; continue; } // a record edited since keeps its edit
|
|
304
309
|
else if (w.op === 'outcome') { const r2 = require('./initiative-writer.js').restoreOutcome(p.w.root, k.ini, w.from || {}, w.to || {}, { actor: g.actor }); if (r2.ok && !r2.kept) back++; else kept++; continue; } // a record edited since keeps its edit
|
|
305
310
|
if (r && r.body && r.body.ok) back++; else kept++;
|
|
306
311
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.520.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.520.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/sdk": "^1.3.0",
|
package/workspace/package.json
CHANGED