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.
Files changed (38) hide show
  1. package/package.json +39 -47
  2. package/payload/AGENTS.md +1 -1
  3. package/payload/KIT-VERSION +1 -1
  4. package/payload/planning/evals/eval-calibration.md +1 -1
  5. package/payload/planning/okrs/health-budgets.md +1 -1
  6. package/payload/state/discoveries.md +1 -1
  7. package/workspace/board-columns-test.js +1 -1
  8. package/workspace/board-programs-test.js +26 -7
  9. package/workspace/briefing-premium-board-test.js +3 -3
  10. package/workspace/calm-timeline-test.js +2 -2
  11. package/workspace/changelog.json +38 -0
  12. package/workspace/guide/agent-capabilities.md +2 -1
  13. package/workspace/guide/descriptions.md +43 -0
  14. package/workspace/guide/index.md +3 -2
  15. package/workspace/guide/initiatives-roadmap.md +1 -0
  16. package/workspace/guide/next-prompt.md +4 -2
  17. package/workspace/guide/programs.md +2 -1
  18. package/workspace/guide/settings.md +1 -1
  19. package/workspace/lib/board-trail.js +8 -3
  20. package/workspace/lib/description-props.js +214 -0
  21. package/workspace/lib/headless.js +12 -2
  22. package/workspace/lib/initiative-cli.js +28 -2
  23. package/workspace/lib/initiative-writer.js +41 -1
  24. package/workspace/lib/proposals.js +13 -4
  25. package/workspace/lib/routes-next-prompt.js +67 -27
  26. package/workspace/lib/routes-proposals.js +5 -0
  27. package/workspace/package-lock.json +2 -2
  28. package/workspace/package.json +1 -1
  29. package/workspace/public/pm/bd-span.js +13 -17
  30. package/workspace/public/pm/cozy-settings.js +8 -7
  31. package/workspace/public/pm/ini-about.js +26 -11
  32. package/workspace/public/pm/ini-props.js +5 -3
  33. package/workspace/public/pm/ini-view-model.js +1 -1
  34. package/workspace/public/pm/ini-view.css +1 -0
  35. package/workspace/public/pm/ini-view.js +1 -1
  36. package/workspace/public/pm/premium-vw-guide.css +7 -2
  37. package/workspace/server.js +15 -1
  38. 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
- return { prop: { group, type, section: group === 'outcome' ? 'check' : section, title: one(input.title, LIM.title) || null, why, sources: s.v, items, data } };
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 ('auto' is on when Claude's runs go to the API, where a
6
- * call takes about 2 s; a background `claude -p` run takes 10 s or more, too slow to follow typing). sharpenSet: 'auto'|'on'|'off'.
7
- * style: 'polish'|'engineer'|'own'. rules: whether Your own rules are written. api: a key for the Claude API is set.
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
- * on the API with the PM's key when one is set (lib/anthropic-key.js, model PMOS_NEXT_PROMPT_MODEL or claude-sonnet-5-5), else a headless
14
- * read-only run (lib/headless.js kind 'ask', origin briefing-ask:next-prompt:…). The answer goes through the shared door, lib/ai-guard.js file():
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 (/^(off|0|false|no)$/i.test(String(env.PMOS_NEXT_PROMPT_API || ''))) return null;
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', api = !!apiKey(ctx), style = get(ctx, 'style') || 'engineer', rules = String(get(ctx, 'rules') || '');
71
- return { next: get(ctx, 'next') !== false, sharpen: set === 'on' || (set === 'auto' && api), sharpenSet: set, style, rules: !!rules.trim(), rulesText: rules, api };
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
- async function viaApi(key, prompt, env) {
85
- const Anthropic = require('@anthropic-ai/sdk');
86
- const api = new Anthropic({ apiKey: key });
87
- const res = await api.messages.create({ model: String(env.PMOS_NEXT_PROMPT_MODEL || '').trim() || DEFAULT_MODEL, max_tokens: 800, messages: [{ role: 'user', content: prompt }] });
88
- return (res.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n');
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
- function viaHeadless(ctx, prompt, origin) {
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' || headless.kindFor({ agent: 'claude', origin }) !== 'ask') return Promise.resolve(null);
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 (!settled) { settled = true; clearTimeout(t); resolve(v); } };
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
- async function ask(ctx, prompt, { job, key }) {
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) { try { return await viaApi(k, prompt, envOf(ctx)); } catch { return null; } }
108
- return viaHeadless(ctx, prompt, `briefing-ask:next-prompt:${job}:${crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 10)}`);
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.518.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.518.0",
9
+ "version": "0.520.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@anthropic-ai/sdk": "^1.3.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.518.0",
3
+ "version": "0.520.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",