create-pmos 0.519.0 → 0.521.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.519.0",
3
+ "version": "0.521.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -32,6 +32,32 @@
32
32
  "audit"
33
33
  ],
34
34
  "pmosChanges": [
35
+ {
36
+ "v": "0.521.0",
37
+ "at": "2026-11-04T02:17:43.880Z",
38
+ "f": 4,
39
+ "prs": [
40
+ {
41
+ "n": 1217,
42
+ "t": "Sharper prompt keeps to the composer column and starts lean",
43
+ "s": "Sharper prompt keeps to the composer column and starts lean",
44
+ "k": "fix"
45
+ }
46
+ ]
47
+ },
48
+ {
49
+ "v": "0.520.0",
50
+ "at": "2026-11-04T01:52:53.932Z",
51
+ "f": 4,
52
+ "prs": [
53
+ {
54
+ "n": 1216,
55
+ "t": "Every initiative has its own description",
56
+ "s": "Every initiative has its own description",
57
+ "k": "new"
58
+ }
59
+ ]
60
+ },
35
61
  {
36
62
  "v": "0.519.0",
37
63
  "at": "2026-11-04T01:26:27.710Z",
@@ -368,36 +394,6 @@
368
394
  "d": "The Timeline still cut long task names short. Now they wrap in full there too."
369
395
  }
370
396
  ]
371
- },
372
- {
373
- "v": "0.501.0",
374
- "at": "2026-11-03T04:49:22.234Z",
375
- "f": 4,
376
- "prs": [
377
- {
378
- "n": 1184,
379
- "t": "Home's list toggle always opens and closes the list",
380
- "s": "Home's list toggle always opens and closes the list",
381
- "k": "fix",
382
- "h": "Home's list opens and closes",
383
- "d": "On a wide Viewer, Home's list could fold away and refuse to open again. Now the toggle opens and closes it every time."
384
- }
385
- ]
386
- },
387
- {
388
- "v": "0.500.0",
389
- "at": "2026-11-03T04:25:59.021Z",
390
- "f": 4,
391
- "prs": [
392
- {
393
- "n": 1183,
394
- "t": "Delete a captured meeting-note point from its edit card",
395
- "s": "Delete a captured meeting-note point from its edit card",
396
- "k": "new",
397
- "h": "Delete a meeting point",
398
- "d": "A point captured from a meeting could not be removed. Now you can delete it from its edit card."
399
- }
400
- ]
401
397
  }
402
398
  ]
403
399
  }
package/payload/AGENTS.md CHANGED
@@ -33,7 +33,7 @@ It refuses frontmatter, fenced and indented code, table rows, headings, delibera
33
33
 
34
34
  | Hosted tool (as skills name them) | File-mode equivalent |
35
35
  |---|---|
36
- | `create_initiative` | write `pmos/state/initiatives/<id>.md` (frontmatter: id, title, parent_kr, stage, status, **type** — the D56 lane, default `feature` — plus `intent` for the chore lane, prd_path, rubric_path, created; plus `outcome_metric` + `outcome_horizon` — the D57 declaration for post-ship success criteria. No initiative goes without one: after you create it, and once its PRD exists, run `pmos outcomes propose <id>` and show the cited draft; write it with `pmos outcomes declare` only after the PM's yes) |
36
+ | `create_initiative` | write `pmos/state/initiatives/<id>.md` (frontmatter: id, title, parent_kr, stage, status, **type** — the D56 lane, default `feature` — plus `intent` for the chore lane, prd_path, rubric_path, created; plus `outcome_metric` + `outcome_horizon` — the D57 declaration for post-ship success criteria. No initiative goes without one: after you create it, and once its PRD exists, run `pmos outcomes propose <id>` and show the cited draft; write it with `pmos outcomes declare` only after the PM's yes; plus `description` — one paragraph that says what the initiative is, shown in About before any PRD exists: `pmos initiative describe <id>` lists the cited lines to draft it from, and `pmos initiative describe <id> "<text>"` writes it only after the PM's yes) |
37
37
  | `save_spec` | the PRD file itself at `pmos/planning/prd/<id>-prd.md` (set `prd_path` on the initiative file) |
38
38
  | `get_spec` / `get_planning_artifact` | read `pmos/planning/…` |
39
39
  | `get_skill` | read `pmos/skills/<name>.skill` |
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@ae404bea
1
+ kernel-source: pmos@2ac0d2f5
2
2
  assembled: 2026-11-04
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-11-04T01:26:24Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-04T02:17:40Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-11-04T01:26:24Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-04T02:17:40Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-11-04T01:26:24Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-04T02:17:40Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -1,4 +1,30 @@
1
1
  [
2
+ {
3
+ "v": "0.521.0",
4
+ "at": "2026-11-04T02:17:43.880Z",
5
+ "f": 4,
6
+ "prs": [
7
+ {
8
+ "n": 1217,
9
+ "t": "Sharper prompt keeps to the composer column and starts lean",
10
+ "s": "Sharper prompt keeps to the composer column and starts lean",
11
+ "k": "fix"
12
+ }
13
+ ]
14
+ },
15
+ {
16
+ "v": "0.520.0",
17
+ "at": "2026-11-04T01:52:53.932Z",
18
+ "f": 4,
19
+ "prs": [
20
+ {
21
+ "n": 1216,
22
+ "t": "Every initiative has its own description",
23
+ "s": "Every initiative has its own description",
24
+ "k": "new"
25
+ }
26
+ ]
27
+ },
2
28
  {
3
29
  "v": "0.519.0",
4
30
  "at": "2026-11-04T01:26:27.710Z",
@@ -31,6 +31,7 @@ This page lists every job, so you know what runs and why. Each row links to the
31
31
  | [New dates, done and tidy names](claude-proposes.md) | Each morning, and when a date passes | The tasks of the initiative, and the evidence that work happened | The initiative's ✦ | `PMOS_PROPOSE=off` |
32
32
  | [Grill](claude-proposes.md) | Before an initiative leaves a stage, when a forecast turns at risk, when scope or dates move a lot, and once a month on running work with late work. Also when you write "grill this" in the initiative's chat. | The initiative, your stored research, your meeting notes and the decisions that stand | "Claude grilled it" under the initiative's ✦ | Settings › Claude's checks: Off. `PMOS_GRILL=off` |
33
33
  | [You decided this before](ask-the-past.md) | You add an initiative that repeats an earlier decision | `DECISIONS.md`, the decision records, the PM journal, `planning/out-of-scope/` and the initiative records | The initiative's ✦ | `pmos ai off past-repeat` |
34
+ | [Initiative descriptions](descriptions.md) | Once a day. At most 3 drafts a day for each product. | The PRD's first Problem or Why paragraph, or the record's own first paragraph | "Description" under the initiative's ✦ | `pmos ai off descriptions`. `PMOS_DESCRIPTIONS=off` |
34
35
  | [Expected outcomes](outcomes.md) | Once a day. At most 3 drafts a day for each product. | The PRD's success criteria, or the anchored key result | "Expected outcome" under the initiative's ✦. Today when a check is due. | `pmos ai off outcomes`. `PMOS_OUTCOMES=off` |
35
36
  | [Metric drafts](metric-drafts.md) | A product with no North Star, the first 14 days of a quarter, a program with no goal, and an initiative that gets a PRD | The last 8 weeks of real readings | The page of that product, program or initiative | `PMOS_METRIC_DRAFT=off` |
36
37
  | [Horizon scan](horizon-scan.md) | Daily by default, at most once a day. Scan now: at most 3 a day. | The public web for your kept watch list, and your repository | Report documents and proposals you approve | Cadence: Off. `PMOS_SCAN=off` |
@@ -70,4 +71,4 @@ When you ask an agent session to do a piece of work, it can finish it, including
70
71
  - `pmos ai log [<job>]`: the last audit lines. Each dropped proposal shows why.
71
72
  - `pmos ai off [<job>]` and `pmos ai on [<job>]`: turn every AI job, or one job, off or on. `PMOS_AI=off` in the environment wins over the setting.
72
73
 
73
- The AI jobs are `past-repeat`, `decision-memory`, `outcomes`, `drift`, `rehearse`, `next-prompt` and `sharpen-prompt`. A background run cannot change the switch.
74
+ The AI jobs are `past-repeat`, `decision-memory`, `outcomes`, `descriptions`, `drift`, `rehearse`, `next-prompt` and `sharpen-prompt`. A background run cannot change the switch.
@@ -0,0 +1,43 @@
1
+ ---
2
+ type: Guide
3
+ title: "Initiative descriptions"
4
+ description: "Every initiative says what it is in About, before any PRD exists. Where the description is kept, how Claude drafts it, how you keep one, how a PRD enriches it, and pmos initiative describe."
5
+ timestamp: 2026-11-04
6
+ ---
7
+
8
+ # Initiative descriptions
9
+
10
+ Every initiative has a description: one paragraph that says what it is. About, in the initiative's mission control, shows it. An initiative needs one long before its PRD is written.
11
+
12
+ ## Where it is kept
13
+
14
+ The description is the `description:` line in the initiative's record. It is not in the PRD. One paragraph, up to 700 characters.
15
+
16
+ ## What About shows
17
+
18
+ - The record has a description: About shows it.
19
+ - The PRD says more than the description: About also shows the PRD's paragraph once, below it, marked **From the PRD**.
20
+ - The record has no description: About shows the PRD's first Problem, Why or Background paragraph, marked **From the PRD**.
21
+ - Neither: About says "No description yet" and shows **Ask Claude to draft it**.
22
+
23
+ ## Claude drafts it
24
+
25
+ Once a day Claude drafts a description for an initiative that has none, and files it under the initiative's ✦ in **Check these**. The draft is the PRD's own paragraph, or the first paragraph of the record that says what the work is. Each draft cites the line it comes from. When there is nothing to read, Claude makes no draft. At most 3 drafts a day for each product.
26
+
27
+ When a PRD is written later and says more than the description, Claude drafts **A fuller description from the PRD**.
28
+
29
+ - ✓ saves the draft to the record. Undo puts back what was there.
30
+ - ✕ drops it. Claude does not draft the same one again.
31
+
32
+ ## Ask Claude to draft it
33
+
34
+ **Ask Claude to draft it** opens Home's chat with a prompt. Claude reads what it can, shows you a draft, and saves it after you say yes.
35
+
36
+ ## Commands
37
+
38
+ - `pmos initiative describe <id>`: the description, and the lines Claude can draft it from.
39
+ - `pmos initiative describe <id> "<text>"`: save the description. Claude runs this only after your yes.
40
+
41
+ ## Turn it off
42
+
43
+ `pmos ai off descriptions` stops the daily drafts. `pmos ai on descriptions` starts them again. `PMOS_DESCRIPTIONS=off` also stops them.
@@ -56,6 +56,7 @@ PMOS has three places: **Home**, **Board** and **Intelligence**. The round menu
56
56
  - [Claude proposes: the one ✦ on each initiative](claude-proposes.md): what Claude finds when you add an initiative, new dates, done and tidy names for late tasks, ✓, ✕, Undo, and `pmos propose`.
57
57
  - [The next prompt, suggested and sharpened](next-prompt.md): what the chat suggests after a reply, and the sharper version of a rough prompt.
58
58
  - [Ask about the past](ask-the-past.md): why something was decided or dropped, answered in the initiative chat from the records; "You decided this before" on a new initiative.
59
+ - [Initiative descriptions](descriptions.md): what About says about an initiative, where it is kept, Claude's drafts, and `pmos initiative describe`.
59
60
  - [Expected outcomes](outcomes.md): what each initiative should change once it ships, the date to check it, and `pmos outcomes`.
60
61
  - [Rehearse a plan](rehearse.md): what a date change or a cut breaks or helps, before you approve it, and `pmos rehearse`.
61
62
  - [Your impact](your-impact.md): what counts as your impact, the weekly check in Home's chat, the switch, and `pmos impact`. Private to you.
@@ -111,6 +111,7 @@ Every measurement needs a source: the file the value comes from and its commit,
111
111
  | `pmos initiative drop <proposal>` | Closes a proposal. |
112
112
  | `pmos initiative list` | Shows the Roadmap in rank order, with each lifecycle stage, check-by date and the open proposals. |
113
113
  | `pmos initiative export <id>` | Writes the breakdown as markdown to the terminal. |
114
+ | `pmos initiative describe <id> ["<text>"]` | Shows the description and the lines Claude can draft it from. With text, saves the description. See [Initiative descriptions](descriptions.md). |
114
115
 
115
116
  Every command takes `--json` and `--root <dir>`. The command does not accept `--by` or `--via`. `pmos initiative now`, `next` and `later` are retired: they write nothing, say what to use instead and exit 2.
116
117
 
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "The next prompt, suggested and sharpened"
4
4
  description: "After a reply, the chat suggests what to say next. While you type a rough prompt, Claude offers a sharper version. What each card means, the styles, the settings, and the guardrails."
5
- timestamp: 2026-11-03
5
+ timestamp: 2026-11-04
6
6
  ---
7
7
 
8
8
  # The next prompt, suggested and sharpened
@@ -30,7 +30,7 @@ Each card names only the initiatives, tasks and PRs that the reply or your messa
30
30
 
31
31
  ## While you type
32
32
 
33
- When you stop typing for about 1.5 seconds on a rough prompt of three or more words, a small spinner card shows while Claude sharpens it. This takes a few seconds. Then one lit **Sharper** card shows. Tap it to put the sharper text in the box. ↶ in the box puts back what you wrote. When the sharper version says the same as yours, no card shows.
33
+ When you stop typing for about 2 seconds on a rough prompt of three or more words, one quiet line, **Sharpening…**, shows above the box while Claude sharpens it. This takes a few seconds, and you can keep typing. Then one lit **Sharper** card shows. If you typed on, or no answer comes in 30 seconds, nothing shows. Tap it to put the sharper text in the box. ↶ in the box puts back what you wrote. When the sharper version says the same as yours, no card shows.
34
34
 
35
35
  The sliders icon on the Sharper card sets how Claude sharpens:
36
36
 
@@ -38,14 +38,14 @@ The sliders icon on the Sharper card sets how Claude sharpens:
38
38
  - **Prompt engineer** (the default): the best prompt for the job. It names the real initiative or task and says what a good answer holds. A date move or a cut becomes a rehearsal that waits for your yes.
39
39
  - **Your own**: your own wording rules. You write them once in Settings.
40
40
 
41
- The same menu has the **Sharpen as I type** switch. Off means no spinner and no Sharper card.
41
+ The same menu has the **Sharpen as I type** switch. Off means no Sharpening… line and no Sharper card.
42
42
 
43
43
  ## Settings › Claude › Sharpen prompts
44
44
 
45
45
  | Row | What it does |
46
46
  |---|---|
47
47
  | Suggest the next prompt | On by default. Off: no cards after a reply. |
48
- | Sharpen as I type | On by default. Off: no spinner and no Sharper card. |
48
+ | Sharpen as I type | On by default. Off: no Sharpening… line and no Sharper card. |
49
49
  | Sharpen as | Polish, Prompt engineer or Your own. |
50
50
  | Your own rules | The rules for Your own, up to 1000 characters. |
51
51
 
@@ -163,7 +163,8 @@ function trail({ root, ids, rows = [], moves = [], now = Date.now(), io } = {})
163
163
  const evalNames = X.list ? X.list('planning/evals') : [];
164
164
  const rowBy = new Map(rows.map((r) => [r.id, r]));
165
165
  const fmCache = new Map();
166
- const fmOf = (id) => { if (!fmCache.has(id)) { const t = X.text('planning/initiatives/' + id + '.md'); fmCache.set(id, t == null ? null : { fm: frontmatter(t), text: t }); } return fmCache.get(id); };
166
+ // a kit product keeps its records in pmos/state/initiatives and its PRDs under pmos/ (initiative-description-field: About read none there)
167
+ const fmOf = (id) => { if (!fmCache.has(id)) { const t = X.text('planning/initiatives/' + id + '.md') ?? X.text('pmos/state/initiatives/' + id + '.md'); fmCache.set(id, t == null ? null : { fm: frontmatter(t), text: t }); } return fmCache.get(id); };
167
168
 
168
169
  // "usually N days": every record's ladder, and every run of board moves
169
170
  const samples = Object.fromEntries(STAGES.map((s) => [s, []]));
@@ -204,8 +205,10 @@ function trail({ root, ids, rows = [], moves = [], now = Date.now(), io } = {})
204
205
  const stage = wk ? null : stageKey(row.stage);
205
206
  const lane = String(row.lane || fm.lane || 'feature').toLowerCase();
206
207
  const evals = evalFiles(id, evalNames);
207
- const prdRel = pathOf(fm.prd_path), rubRel = pathOf(fm.rubric_path);
208
- const prdText = prdRel ? X.text(prdRel) : null, prdFm = frontmatter(prdText);
208
+ let prdRel = pathOf(fm.prd_path); const rubRel = pathOf(fm.rubric_path);
209
+ let prdText = prdRel ? X.text(prdRel) : null;
210
+ if (prdRel && prdText == null && !prdRel.startsWith('pmos/')) { const k = X.text('pmos/' + prdRel); if (k != null) { prdText = k; prdRel = 'pmos/' + prdRel; } }
211
+ const prdFm = frontmatter(prdText);
209
212
  const briefName = (X.list ? X.list('planning/briefs') : []).find((n) => n.startsWith(id) && n.endsWith('.md'));
210
213
  const jv = X.jira ? X.jira(id) : null;
211
214
  const tks = jv && jv.ok && Array.isArray(jv.tickets) ? jv.tickets : [];
@@ -276,6 +279,8 @@ function trail({ root, ids, rows = [], moves = [], now = Date.now(), io } = {})
276
279
  skipped, parts, next: parts ? (parts.find((p) => !p.done) || {}).name || null : null, you,
277
280
  links: { prd: prdRel, prdVersion: prdRel && (typeof prdFm.version === 'string' || typeof prdFm.version === 'number') && String(prdFm.version).trim() ? String(prdFm.version).trim().slice(0, 12) : null,
278
281
  rubric: rubRel, evaluation: evals[0] || null, epic: jv && jv.ok && jv.epic && jv.epic.key ? jv.epic.key : null },
282
+ // the record's own description (initiative-description-field): About shows it before the PRD's paragraph
283
+ desc: typeof fm.description === 'string' && fm.description.trim() ? fm.description.replace(/\\("|\\)/g, '$1').replace(/\s+/g, ' ').trim().slice(0, 700) : null,
279
284
  drafted, gate: jv && jv.ok ? jv.gate || null : null, drift: drift.map((t) => t.key), deps, theme: tkey, tickets,
280
285
  };
281
286
  }
@@ -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 };
@@ -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.
@@ -31,6 +31,11 @@ const ROOT = path.join(__dirname, '..'); // the workspace package: the files t
31
31
  const FIXTURE = path.join(ROOT, 'fixtures/next-prompt-replay.json');
32
32
  const KEEP = 30;
33
33
  const TIMEOUT_MS = 90000;
34
+ const SHARPEN_TIMEOUT_MS = 30000; // a sharper prompt that comes later than this is for words the PM has typed past: drop it
35
+ /* A background run for these two short jobs starts lean (hotfix 2026-11-04, Wawan: "Loading too long"): a fixed fast model at low effort,
36
+ * no MCP servers, no skills, only the PM's user settings (no project hooks such as a session-start script) and one read tool. Measured on
37
+ * one sharpen prompt: about 61,000 input tokens and 7 s before, about 10,000 and 5 s after; the rest is the CLI's own start. */
38
+ const LEAN_FLAGS = Object.freeze([{ flag: '--strict-mcp-config' }, { flag: '--disable-slash-commands' }, { flag: '--setting-sources', value: 'user' }, { flag: '--tools', value: 'Read' }]);
34
39
  const DEFAULT_MODEL = 'claude-sonnet-5-5';
35
40
 
36
41
  const text = (v, n) => (typeof v === 'string' ? v.slice(0, n) : '');
@@ -112,16 +117,18 @@ async function viaApi(key, prompt, env, create = null) {
112
117
  return null;
113
118
  }
114
119
  /** 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 = () => {}) {
120
+ function viaHeadless(ctx, prompt, origin, fail = () => {}, { timeoutMs = TIMEOUT_MS } = {}) {
116
121
  const headless = require('./headless.js');
117
122
  if (typeof ctx.spawnSession !== 'function') { fail('no background run here'); return Promise.resolve(null); }
118
123
  if (headless.kindFor({ agent: 'claude', origin }) !== 'ask') { fail('the run would not be read-only'); return Promise.resolve(null); }
119
124
  return new Promise((resolve) => {
120
125
  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);
126
+ const t = setTimeout(() => done(null, 'the run took longer than ' + Math.round(timeoutMs / 1000) + ' s'), timeoutMs);
122
127
  let s = null;
123
128
  try {
129
+ const model = String(envOf(ctx).PMOS_NEXT_PROMPT_MODEL || '').trim() || DEFAULT_MODEL;
124
130
  s = ctx.spawnSession({ name: 'chat: next prompt', agent: 'claude', origin, initialInput: prompt,
131
+ modelAsk: { explicit: { model, effort: 'low' } }, launchFlags: LEAN_FLAGS.map((f) => ({ ...f })),
125
132
  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
133
  } catch (e) { s = null; }
127
134
  if (!s || s.state === 'exited') done(null, 'the run did not start' + (s && s.note ? ': ' + String(s.note).slice(0, 80) : ''));
@@ -145,7 +152,8 @@ async function ask(ctx, prompt, { job, key, run }) {
145
152
  catch (e) { failed(apiWhy(e), e && (e.status === 401 || e.status === 403)); }
146
153
  }
147
154
  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) }));
155
+ (why) => AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'run-error', error: String(why).slice(0, 160) }),
156
+ { timeoutMs: job === NP.JOB_SHARPEN ? SHARPEN_TIMEOUT_MS : TIMEOUT_MS });
149
157
  }
150
158
 
151
159
  /* ── what a card cites ───────────────────────────────────────────────────────────────────── */
@@ -285,4 +293,4 @@ async function handle(req, res, url, ctx) {
285
293
  catch (e) { return send(bad(500, 'The suggestion failed: ' + String((e && e.message) || e).slice(0, 200))); }
286
294
  }
287
295
 
288
- module.exports = { prefixes: ['/api/next-prompt'], handle, VERSION, FIXTURE, MODELS, prefs, setPref, lineRef, replayCases, next, sharpen };
296
+ module.exports = { prefixes: ['/api/next-prompt'], handle, VERSION, FIXTURE, MODELS, LEAN_FLAGS, 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.519.0",
3
+ "version": "0.521.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.519.0",
9
+ "version": "0.521.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.519.0",
3
+ "version": "0.521.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",
@@ -176,7 +176,7 @@ body.cz-home { min-height: 100vh; overflow: hidden; }
176
176
  @media (min-width: 761px) {
177
177
  /* the log scrolls at the panel's edge, not at the column's (PM, 2026-10-27 "Bad layout": the scrollbar stood in the middle of the page);
178
178
  * its content and the header line keep to the same centred column as the box */
179
- body.cz-home:not(.hm-vw-open) .hm-foot { box-sizing: border-box; width: 100%; max-width: 880px; align-self: center; }
179
+ body.cz-home:not(.hm-vw-open) .hm-foot, body.cz-home:not(.hm-vw-open) .np { box-sizing: border-box; width: 100%; max-width: 880px; align-self: center; }
180
180
  body.cz-home:not(.hm-vw-open) .hm-log { box-sizing: border-box; width: 100%; padding-inline: max(0px, calc((100% - 880px) / 2)); }
181
181
  body.cz-home:not(.hm-vw-open) .hm-chd { padding-left: max(0px, calc((100% - 880px) / 2)); }
182
182
  @container (max-width: 968px) { .hm-reopen:not([hidden]) ~ .hm-log { padding-top: 46px; } }
@@ -810,13 +810,10 @@ form.hm-box textarea { min-height: 48px; border: 0; background: none; resize: no
810
810
  .np-use { display: flex; align-items: flex-start; gap: 10px; flex: 1; min-width: 0; padding: 11px 0 11px 14px; border: 0; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }
811
811
  .np-ib { flex: none; width: 36px; height: 36px; display: inline-grid; place-items: center; border: 0; border-radius: 999px; background: none; color: var(--cz-ink2); cursor: pointer; }
812
812
  .np-ib:hover, .np-ib[aria-expanded="true"] { background: var(--cz-well); }
813
- .np-spin { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 16px; background: var(--cz-bluebg); color: var(--cz-blue); }
813
+ .np-spin { display: flex; align-items: center; gap: 8px; padding: 2px 14px; font-size: var(--fs-meta); color: var(--cz-ink2); } /* quiet: one small line, not a card */
814
814
  .np-sp { display: inline-flex; animation: np-rot 1.1s linear infinite; }
815
815
  @keyframes np-rot { to { transform: rotate(360deg); } }
816
816
  @media (prefers-reduced-motion: reduce) { .np-sp { animation: none; } }
817
- .np-sk { flex: 1; display: flex; flex-direction: column; gap: 8px; }
818
- .np-sk i { display: block; height: 12px; border-radius: 6px; background: var(--cz-bluebg); }
819
- .np-sk i:first-child { width: 88%; } .np-sk i:last-child { width: 60%; }
820
817
  .np-menu { position: absolute; right: 0; bottom: calc(100% + 8px); z-index: 20; width: 310px; max-width: calc(100vw - 32px); box-sizing: border-box; padding: 8px; border-radius: 20px;
821
818
  background: var(--cz-card); box-shadow: 0 24px 60px -24px rgba(42,46,53,.45), 0 0 0 1px var(--cz-hair); display: flex; flex-direction: column; gap: 2px; }
822
819
  .np-mh { margin: 0; padding: 8px 12px 4px; font-size: var(--fs-1); font-weight: 800; color: var(--cz-ink2); }
@@ -7,6 +7,12 @@
7
7
  * aboutHtml(v, o, open) → the markup: the header press (data-iv-mc="ab") with its one-line summary, then the two columns when open
8
8
  * resetAbout() forget the descriptions read (for the tests)
9
9
  *
10
+ * The description (initiative-description-field): the record's own `description:` line (v.desc, from the board trail) wins. When the PRD's
11
+ * paragraph says more than it, the PRD's shows once below it, marked "From the PRD". With no line, the PRD's paragraph shows as before. With
12
+ * neither, "Ask Claude to draft it" opens Home chat with a prompt that names the id, so the kept text is saved to the record
13
+ * (`pmos initiative describe`). Claude also drafts it by itself under the ✦ (lib/description-props.js).
14
+ * saysMore(desc, prd) → true when the PRD's paragraph is fuller than the description and not in it yet. Pure.
15
+ *
10
16
  * The success measure is the one Claude drafts (pm/metric-drafts.js, GET /api/metric-drafts): its proposal keeps its own card and presses; a
11
17
  * kept measure is listed here as rows, each with its Now → Target. A row that also carries `base` (where it started) and `pace` (how far it
12
18
  * should be by today, 0 to 1) draws as a track with a tick at the pace and a verdict: On pace, Ahead of pace, Behind pace, Short of target,
@@ -21,7 +27,7 @@ const STALE_DAYS = 30;
21
27
  const MAX_CHARS = 700;
22
28
 
23
29
  /* ── the description ── */
24
- const plain = (s) => String(s).replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/[*_`]+/g, '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
30
+ 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();
25
31
  /** The description a PRD gives. */
26
32
  export function descOf(md) {
27
33
  let t = String(md || '').replace(/\r\n?/g, '\n');
@@ -64,6 +70,9 @@ export function want(ws, path, onChange) {
64
70
  return one;
65
71
  }
66
72
  const descFor = (ws, path) => READ.get((ws || '') + '|' + path) || null;
73
+ const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
74
+ /** The PRD says more: not already in the description, and fuller (the same rule as lib/description-props.js). */
75
+ export function 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; }
67
76
  export const resetAbout = () => { READ.clear(); OPEN.clear(); LASTWS = ''; };
68
77
  const OPEN = new Set(); // initiative ids whose description is open past three lines, for the session
69
78
 
@@ -96,7 +105,7 @@ const rowHtml = (r, o) => {
96
105
  const homeHref = (ws, id, q) => { const u = new URLSearchParams(); if (ws) u.set('ws', ws); u.set('ask', id); u.set('q', q); return '/premium.html?' + u.toString(); };
97
106
  /** The folded line: the first measure's dot and value, or the one next action. */
98
107
  function summaryOf(v, M, d, o) {
99
- const none = !d || d.state !== 'ok';
108
+ const none = !v.desc && (!d || d.state !== 'ok');
100
109
  if (M && M.mode === 'kept') {
101
110
  const r = M.rows[0], t = trackOf(r, o), st = t ? t.state : r.now == null ? 'unread' : '';
102
111
  const val = r.now == null && !r.nowTxt ? 'not read yet' : nowText(r) + (r.targetTxt ? ' → ' + r.targetTxt : '');
@@ -108,18 +117,24 @@ function summaryOf(v, M, d, o) {
108
117
  }
109
118
  export function aboutHtml(v, o, open) {
110
119
  const id = v.id || v.key || '', ws = o.ws || LASTWS;
111
- const prd = v.links && v.links.prd ? v.links.prd : '';
120
+ const prd = v.links && v.links.prd ? v.links.prd : v.prd || ''; // the trail's PRD path (v.prd); v.links is the task links on the Board
112
121
  const d = prd ? descFor(ws, prd) : null;
113
122
  const M = MD.measuresOf(id);
114
123
  v = { ...v, docState: !prd ? 'none' : d ? d.state : 'loading' };
115
124
  const sum = summaryOf(v, M, d, o);
116
- let desc;
117
- if (d && d.state === 'ok') {
118
- const long = d.text.length > 260, shut = long && !OPEN.has(id);
119
- desc = `<p class="ab-p${shut ? ' cl' : ''}">${esc(d.text)}</p>${long ? `<button type="button" class="ab-lk" data-ab-more="${esc(id)}" aria-expanded="${!shut}">${shut ? 'Show more' : 'Show less'}</button>` : ''}`
120
- + (o.doc ? `<button type="button" class="ab-lk" data-iv-doc="${esc(prd)}">Open the PRD ${SVG(P.go, 14, 2.2)}</button>` : '');
121
- } else if (prd && (!d || d.state === 'loading')) desc = '<p class="ab-p q">Reading the PRD…</p>';
122
- else desc = `<p class="ab-p q">No description yet.</p><a class="ab-lk" href="${esc(homeHref(ws, id, 'Draft the description of ' + (v.title || id) + ': '))}">${SVG(P.chat, 14)} Ask Claude to draft it</a>`;
125
+ let desc, fromPrd = false;
126
+ const para = (key, text, cls = '') => {
127
+ const long = text.length > 260, shut = long && !OPEN.has(key);
128
+ return `<p class="ab-p${cls}${shut ? ' cl' : ''}">${esc(text)}</p>${long ? `<button type="button" class="ab-lk" data-ab-more="${esc(key)}" aria-expanded="${!shut}">${shut ? 'Show more' : 'Show less'}</button>` : ''}`;
129
+ };
130
+ const openPrd = o.doc ? `<button type="button" class="ab-lk" data-iv-doc="${esc(prd)}">Open the PRD ${SVG(P.go, 14, 2.2)}</button>` : '';
131
+ const own = v.desc ? String(v.desc) : '';
132
+ if (own) {
133
+ // the record's own description wins; the PRD's paragraph shows once, below, only when it says more
134
+ desc = para(id, own) + (d && d.state === 'ok' && saysMore(own, d.text) ? `<h5 class="ab-from">From the PRD</h5>${para(id + '|prd', d.text)}${openPrd}` : '');
135
+ } else if (d && d.state === 'ok') { fromPrd = true; desc = para(id, d.text) + openPrd; }
136
+ else if (prd && (!d || d.state === 'loading')) desc = '<p class="ab-p q">Reading the PRD…</p>';
137
+ else desc = `<p class="ab-p q">No description yet.</p><a class="ab-lk" href="${esc(homeHref(ws, id, 'Draft the description of ' + (v.title || id) + ' (' + id + ') from what you can read, and save it to the initiative once I keep it: '))}">${SVG(P.chat, 14)} Ask Claude to draft it</a>`;
123
138
  let met;
124
139
  if (M && M.mode === 'open') met = MD.iniHtml(id, ws, v.title);
125
140
  else if (M && M.mode === 'kept') met = `<div class="ab-ml">${M.rows.map((r) => rowHtml(r, o)).join('')}</div>`
@@ -127,7 +142,7 @@ export function aboutHtml(v, o, open) {
127
142
  else met = `<p class="ab-p q">No success measure yet.</p><a class="ab-lk" href="${esc(homeHref(ws, id, 'Propose a success measure for ' + (v.title || id) + ': '))}">${SVG(P.chat, 14)} Ask Claude to propose one</a>`;
128
143
  const anchor = v.anchor ? `<span>Anchored to ${esc(v.anchor)}</span>` : '';
129
144
  return `<section class="iv-ab" aria-label="About"><button type="button" class="iv-ab-h" data-iv-mc="ab" aria-expanded="${!!open}"><b>About</b>${sum}<span class="chev" aria-hidden="true">${SVG(P.down, 16, 2)}</span></button>`
130
- + (open ? `<div class="iv-ab-b"><div class="ab-d"><h4>Description${d && d.state === 'ok' ? ' <span>From the PRD</span>' : ''}</h4>${desc}</div><div class="ab-m"><h4>Success measure ${anchor}</h4>${met}</div></div>` : '') + '</section>';
145
+ + (open ? `<div class="iv-ab-b"><div class="ab-d"><h4>Description${fromPrd ? ' <span>From the PRD</span>' : ''}</h4>${desc}</div><div class="ab-m"><h4>Success measure ${anchor}</h4>${met}</div></div>` : '') + '</section>';
131
146
  }
132
147
  /** The banner's one measure chip (R2): the first kept measure's dot and value, or a proposal's mark; '' when there is none. */
133
148
  export function chipHtml(v, o = {}) {
@@ -36,7 +36,7 @@ const md = (iso) => { const m = DAY.exec(String(iso || '')); return m ? MON[+m[2
36
36
  const span = (a, b) => (a && b && a !== b ? md(a) + ' – ' + md(b) : md(a || b));
37
37
  const plural = (n, a, b) => n + ' ' + (n === 1 ? a : b);
38
38
  const clip = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
39
- const GROUP = { outcome: 'Expected outcome', found: 'Found elsewhere', dates: 'Dates', status: 'Looks done', names: 'Names', links: 'Links', grill: 'Claude grilled it' };
39
+ const GROUP = { description: 'Description', outcome: 'Expected outcome', found: 'Found elsewhere', dates: 'Dates', status: 'Looks done', names: 'Names', links: 'Links', grill: 'Claude grilled it' };
40
40
 
41
41
  /* ── the chip and the live look ── */
42
42
  export function chipOf(p) {
@@ -94,10 +94,11 @@ const titleOf = (p) => {
94
94
  if (p.group === 'found') return p.type === 'stage' ? 'Stage: ' + ((p.data && p.data.stage) || '') : p.type === 'docs' ? plural(n, 'document', 'documents') : p.type === 'tasks' ? plural(n, 'task', 'tasks') : p.type === 'link' ? 'Already in another initiative' : p.type === 'maybe' ? 'Maybe this initiative' : plural(n, 'thing', 'things') + ' not placed';
95
95
  if (p.group === 'status') return plural(n, 'task looks', 'tasks look') + ' done';
96
96
  if (p.group === 'names') return 'Tidy ' + plural(n, 'name', 'names');
97
+ if (p.group === 'description') return p.type === 'enrich' ? 'A fuller description from the PRD' : 'What this initiative is';
97
98
  if (p.group === 'outcome') return p.type === 'date' ? 'A date to check the outcome' : 'What should change once it ships';
98
99
  return '';
99
100
  };
100
- const iconOf = (p) => (p.group === 'outcome' ? I.goal : p.group === 'found' ? (p.type === 'stage' ? I.stage : p.type === 'docs' ? I.doc : p.type === 'tasks' ? I.tasks : p.type === 'link' ? I.link : I.maybe)
101
+ const iconOf = (p) => (p.group === 'description' ? I.doc : p.group === 'outcome' ? I.goal : p.group === 'found' ? (p.type === 'stage' ? I.stage : p.type === 'docs' ? I.doc : p.type === 'tasks' ? I.tasks : p.type === 'link' ? I.link : I.maybe)
101
102
  : p.group === 'dates' ? I.cal : p.group === 'status' ? I.done : p.group === 'names' ? I.name : I.link);
102
103
  const ib = (attr, label, icon, cls = '') => `<button type="button" class="pp-ib${cls ? ' ' + cls : ''}" ${attr} aria-label="${esc(label)}" title="${esc(label)}">${SVG(icon, 16, 2.2)}</button>`;
103
104
  function itemHtml(p, x) {
@@ -110,8 +111,9 @@ function itemHtml(p, x) {
110
111
  } else if (p.group === 'dates') { t = x.title || x.task; m = (x.from && x.from.due ? md(x.from.due) + ' → ' : '') + span(x.to && x.to.start, x.to && x.to.due) + (x.jira ? ' · Jira: a drafted comment' : '') + (x.ms ? ' · ◇ a promise' : ''); }
111
112
  else if (p.group === 'status') { t = x.title || taskTitle(p.ini, x.task); m = x.jira ? 'Jira: a drafted comment' : ''; }
112
113
  else if (p.group === 'outcome') { t = x.title; m = x.due ? 'Check on ' + md(x.due) : ''; }
114
+ else if (p.group === 'description') { t = (p.data && p.data.text) || x.title; m = p.type === 'enrich' && p.data && p.data.from ? 'Now: ' + clip(p.data.from, 120) : ''; }
113
115
  else if (p.group === 'names') { t = x.to; m = 'Was: ' + (x.from || '') + (x.jira ? ' · Jira: a drafted comment' : ''); }
114
- return `<div class="pp-it"><span class="w"><b>${esc(clip(t, 90))}</b>${m ? `<small>${esc(m)}</small>` : ''}</span>${x.sig ? ib(`data-pp-dropit="${esc(x.sig)}"`, 'Leave this one out', I.x) : ''}</div>`;
116
+ return `<div class="pp-it"><span class="w"><b>${esc(clip(t, p.group === 'description' ? 700 : 90))}</b>${m ? `<small>${esc(m)}</small>` : ''}</span>${x.sig ? ib(`data-pp-dropit="${esc(x.sig)}"`, 'Leave this one out', I.x) : ''}</div>`;
115
117
  }
116
118
  function linkHtml(p, l) {
117
119
  const pick = S.choices[l.key] || l.rec;
@@ -322,7 +322,7 @@ export function iniView(r, ctx = {}) {
322
322
  summary.wf = r.wf || { id: PF_ID, name: 'Product feature', icon: 'feature' };
323
323
  summary.program = ctx.program || null;
324
324
  const today2 = (n) => String(n).padStart(2, '0');
325
- return { anchor: r.kr || '', vs: r.__vs || null, links: r.__vs && r.__vs.draft ? null : r.links || null, id: r.id || null, key: r.key, title: r.title, today: today.getFullYear() + '-' + today2(today.getMonth() + 1) + '-' + today2(today.getDate()), summary, stage, tasks, docs: ctx.docs != null ? ctx.docs : null, linked: linkedOf(r), jiraAt: jiraIn ? ctx.jiraAt || '' : '', jiraRead: ctx.jiraReadAt || '',
325
+ return { anchor: r.kr || '', vs: r.__vs || null, links: r.__vs && r.__vs.draft ? null : r.links || null, prd: (r.trail && r.trail.links && r.trail.links.prd) || null, desc: (r.trail && r.trail.desc) || r.desc || null, id: r.id || null, key: r.key, title: r.title, today: today.getFullYear() + '-' + today2(today.getMonth() + 1) + '-' + today2(today.getDate()), summary, stage, tasks, docs: ctx.docs != null ? ctx.docs : null, linked: linkedOf(r), jiraAt: jiraIn ? ctx.jiraAt || '' : '', jiraRead: ctx.jiraReadAt || '',
326
326
  // split or merge (batch 11 item 14): Claude's suggestion for it, and where its work went when it was merged
327
327
  suggest: r.suggest || null, merged: mergedOf(r),
328
328
  // what Claude proposes under the one blue ✦, and what a ✓ kept from elsewhere (batch 12 items 2, 3 and 4; pm/ini-props.js)
@@ -1041,6 +1041,7 @@ button.iv-mt.iv-wf { padding: 0 !important; margin: 0 !important; }
1041
1041
  @container iv (min-width: 760px) { .iv-ab-b { grid-template-columns: minmax(0, 1fr) minmax(0, 460px); gap: 32px; } }
1042
1042
  .ab-d h4, .ab-m h4 { margin: 0 0 6px; font-size: var(--fs-label); font-weight: var(--fw-strong); color: var(--cz-ink3); letter-spacing: .02em; display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; line-height: var(--lh-label); }
1043
1043
  .ab-d h4 span, .ab-m h4 span { font-weight: var(--fw-strong); }
1044
+ .ab-d h5.ab-from { margin: 12px 0 4px; font-size: var(--fs-label); font-weight: var(--fw-strong); color: var(--cz-ink3); line-height: var(--lh-label); }
1044
1045
  .ab-p { margin: 0; font-size: var(--fs-body); line-height: 1.55; max-width: 62ch; color: var(--cz-ink); }
1045
1046
  .ab-p.q { color: var(--cz-ink3); }
1046
1047
  .ab-p.cl { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
@@ -415,7 +415,7 @@ export function wireView(body, h) {
415
415
  const drafts = (id) => { const H = body.__iv || {}; if (id && VIEWED.has(id)) MD.want(((H.where && H.where()) || {}).ws || '', 'initiative', id, () => { if (idOf() === id && H.redraw) H.redraw(); }); };
416
416
  // the description: the PRD, read once per path through /api/doc; the view draws again when it arrives
417
417
  // About draws alone when its description arrives: a full redraw would close a task row the PM has open
418
- const about = (id) => { const H = body.__iv || {}, v = id && VIEWED.get(id), prd = v && v.links && v.links.prd;
418
+ const about = (id) => { const H = body.__iv || {}, v = id && VIEWED.get(id), prd = v && ((v.links && v.links.prd) || v.prd);
419
419
  if (prd) AB.want(((H.where && H.where()) || {}).ws || '', prd, () => { const at = idOf() === id && body.querySelector('.iv-ab'); if (!at) return; const M = MD.measuresOf(id); at.outerHTML = AB.aboutHtml(v, OPTS.get(id) || {}, mcState(id, { proposal: !!(M && M.mode === 'open') }).ab); }); };
420
420
  chip();
421
421
  G.attach(body, body.__iv); // the layout and the drafted tickets of the initiative shown (batch 11 item 20)
@@ -3,7 +3,8 @@
3
3
  * After a reply ends: up to two cards above the box, the one Claude recommends lit (white, blue ring, blue ✦), the other grey. A tap puts its
4
4
  * text in the box; nothing sends, and the PM sends with the box's own ↑. No ↵, edit or dismiss icon. No card when there is no clear next step.
5
5
  * The cards hide when the PM types or a new reply starts; they have no timeout.
6
- * While the PM types a rough prompt (3 or more words), after a 1.5 s pause: a spinner card while Claude sharpens it, then one lit Sharper card.
6
+ * While the PM types a rough prompt (3 or more words), after a 2 s pause: one quiet line (a small turning mark, "Sharpening…") while Claude
7
+ * sharpens it, then one lit Sharper card. Typing is never held up; an answer for words no longer in the box, or one later than 30 s, is dropped.
7
8
  * Its sliders icon opens the style menu: Sharpen as Polish, Prompt engineer (the default) or Your own, and the Sharpen as I type switch.
8
9
  * After a tap on the Sharper card, ↶ in the box puts back what the PM wrote (NpB 5).
9
10
  * Server: GET /api/next-prompt/prefs, POST /api/next-prompt/next and /sharpen (lib/routes-next-prompt.js, through lib/ai-guard.js); the style
@@ -15,7 +16,7 @@ const STAR = '<svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24"
15
16
  const SLIDERS = 'M4 7h9M17 7h3M4 17h3M11 17h9M13 4v6M7 14v6', CHECK = 'M5 12l5 5L20 7', UNDO = 'M9 14L4 9l5-5M4 9h11a5 5 0 0 1 0 10h-3', SPIN = 'M12 3a9 9 0 1 1-9 9';
16
17
  const ic = (d, w = 16) => `<svg aria-hidden="true" width="${w}" height="${w}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg>`;
17
18
  const STYLES = [['polish', 'Polish', 'Clearer and shorter, your meaning kept'], ['engineer', 'Prompt engineer', 'Builds the best prompt for the job'], ['own', 'Your own', 'Your wording rules, written once in Settings']];
18
- const PAUSE = 1500;
19
+ const PAUSE = 2000, WAIT = 30000;
19
20
  export const roughText = (t) => { const s = String(t || '').trim(); return !s.startsWith('/') && s.length >= 12 && s.split(/\s+/).filter(Boolean).length >= 3; };
20
21
 
21
22
  export function nextPromptUi({ el, host, box, redrawFoot }) {
@@ -36,7 +37,7 @@ export function nextPromptUi({ el, host, box, redrawFoot }) {
36
37
  if (N.mode === 'cards' && N.cards.length) {
37
38
  html = `<div class="np-list" role="group" aria-label="Suggested next prompts">${N.cards.map((c, i) => `<button type="button" class="np-card${c.rec ? ' rec' : ''}" data-np-use="${i}" aria-label="Use this prompt: ${esc(c.text)}"${c.rec ? ' title="Claude recommends this one"' : ''}>${STAR}<span class="np-t">${esc(c.text)}</span></button>`).join('')}</div>`;
38
39
  } else if (N.mode === 'spin') {
39
- html = `<div class="np-spin" role="status" aria-label="Claude is sharpening your prompt"><span class="np-sp">${ic(SPIN, 20)}</span><span class="np-sk"><i></i><i></i></span></div>`;
40
+ html = `<div class="np-spin" role="status"><span class="np-sp">${ic(SPIN, 14)}</span><span>Sharpening…</span></div>`;
40
41
  } else if (N.mode === 'sharp' && N.sharp) {
41
42
  const P = N.prefs || {}, st = P.style || 'engineer';
42
43
  const menu = N.menu ? `<div class="np-menu" role="menu" aria-label="Style of sharper prompts" data-esc-layer><p class="np-mh">Sharpen as</p>${STYLES.map(([k, l, d]) => `<button type="button" role="menuitemradio" aria-checked="${st === k}" class="np-mi" data-np-voice="${k}"><span class="np-ck">${st === k ? ic(CHECK) : ''}</span><span><b>${esc(l)}</b><em>${esc(k === 'own' && !P.rules ? 'Write your rules in Settings first' : d)}</em></span></button>`).join('')}`
@@ -58,7 +59,9 @@ export function nextPromptUi({ el, host, box, redrawFoot }) {
58
59
  const my = ++N.seq;
59
60
  N.mode = 'spin'; N.sharp = null; draw();
60
61
  const c = N.ctx || {};
62
+ const late = setTimeout(() => { if (my === N.seq && N.mode === 'spin') { N.seq++; N.mode = null; draw(); } }, WAIT); // no answer in time: the line goes quietly
61
63
  const r = await host.pmPost('/api/next-prompt/sharpen', { key: c.key || '', text: value, reply: c.reply || '', initiative: c.initiative || '', title: c.title || '' }).catch(() => null);
64
+ clearTimeout(late);
62
65
  const ta = box();
63
66
  if (my !== N.seq || !ta || ta.value !== value) return; // the PM typed on, or sent: this answer is for words no longer in the box
64
67
  if (r && r.ok && r.text) { N.mode = 'sharp'; N.sharp = r.text; } else { N.mode = null; }
@@ -3106,6 +3106,20 @@ const outcomeProps = require('./lib/outcome-props.js').create({
3106
3106
  every: TICK_MS,
3107
3107
  mayRun: () => { try { return scheduleOwner.mayRun(); } catch { return true; } },
3108
3108
  warn: (what, e) => warnWriteFail(what, e) });
3109
+ // initiative-description-field: once a day Claude drafts the description of each initiative that has none (or a fuller one once its PRD says
3110
+ // more) under the initiative's ✦, from the PRD's or the record's own words, cited (lib/description-props.js). No session and no model; through
3111
+ // the shared guard. A ✓ of the PM writes the record. Off with PMOS_DESCRIPTIONS=off or pmos ai off descriptions.
3112
+ const descriptionProps = require('./lib/description-props.js').create({
3113
+ workspaces: () => [...workspaces.values()],
3114
+ isLead: (w) => isPlanNeedsTree(w),
3115
+ stateDirFor: (w) => stateDirFor(w),
3116
+ boardOf: (w, id) => require('./lib/routes-boards.js').boardIndex(proposalCtx(), w).of(id),
3117
+ runtime: () => STATE,
3118
+ open: (now) => !proactiveLib.inQuietHours(now, proSettings().quiet),
3119
+ env: process.env,
3120
+ every: TICK_MS,
3121
+ mayRun: () => { try { return scheduleOwner.mayRun(); } catch { return true; } },
3122
+ warn: (what, e) => warnWriteFail(what, e) });
3109
3123
  // batch 16 item 6 ("Claude runs the OKRs"): Claude reads each goal with its recipe (lib/metric-read-runs.js) when its cadence comes round, files the
3110
3124
  // value with its source and time, says honestly what it could not read and drafts the fix. A broken limit opens one alert: a bell line only when
3111
3125
  // the goal's notify is push. At most 2 runs at once, one per product. PMOS_METRIC_READ=off.
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "initiative-description-field: every initiative has its own description. The record writer, the cited drafts, the guard, the daily filer under the ✦ and pmos initiative describe (lib/initiative-writer.js setDescription, lib/description-props.js, lib/proposals.js group description, lib/initiative-cli.js describe).",
3
+ "modules": ["descriptions.test.js"],
4
+ "seconds": { "descriptions.test.js": 3 }
5
+ }