create-pmos 0.188.0 → 0.189.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 (32) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/c3-report-setup-test.js +1 -1
  7. package/workspace/c3-scan-fixture.js +247 -5
  8. package/workspace/c3-scan-test.js +453 -106
  9. package/workspace/guide/horizon-scan.md +45 -36
  10. package/workspace/lib/headless.js +19 -5
  11. package/workspace/lib/pmos-verbs.js +2 -2
  12. package/workspace/lib/route-modules.js +1 -1
  13. package/workspace/lib/routes-boards.js +29 -12
  14. package/workspace/lib/routes-scan.js +224 -44
  15. package/workspace/lib/scan-cli.js +143 -45
  16. package/workspace/lib/scan-run.js +242 -87
  17. package/workspace/lib/scan.js +623 -79
  18. package/workspace/package-lock.json +2 -2
  19. package/workspace/package.json +1 -1
  20. package/workspace/public/pm/c3-doc-sheet.js +7 -1
  21. package/workspace/public/pm/c3-initiatives.js +5 -4
  22. package/workspace/public/pm/c3-scan-api.js +167 -0
  23. package/workspace/public/pm/c3-scan-next.js +248 -0
  24. package/workspace/public/pm/c3-scan-setup.js +317 -0
  25. package/workspace/public/pm/c3-scan-words.js +34 -0
  26. package/workspace/public/pm/c3-scan.css +58 -9
  27. package/workspace/public/pm/c3-scan.js +36 -208
  28. package/workspace/public/pm/c3-toast.js +1 -0
  29. package/workspace/server.js +7 -4
  30. package/workspace/skills/horizon-scan-repo.skill +26 -10
  31. package/workspace/skills/horizon-scan-research.skill +44 -0
  32. package/workspace/skills/horizon-scan-web.skill +12 -5
@@ -1,73 +1,82 @@
1
1
  ---
2
2
  type: Guide
3
- title: "The Horizon scan: the agent looks outside your initiatives"
4
- description: "What the Horizon scan does, how often it runs, the watch list you keep, where its findings land, what it can and cannot do, and the pmos scan commands."
5
- timestamp: 2026-10-12
3
+ title: "The Horizon scan: reports and proposals for the whole portfolio"
4
+ description: "What the portfolio Horizon scan does, its settings and schedule, Scan now and its limits, the report documents, the proposals you approve, what it can and cannot do, and the pmos scan commands."
5
+ timestamp: 2026-10-13
6
6
  ---
7
7
 
8
- # The Horizon scan: the agent looks outside your initiatives
8
+ # The Horizon scan: reports and proposals for the whole portfolio
9
9
 
10
- The Initiative Driver works on initiatives that already exist. The Horizon scan looks the other way. It looks for opportunities you have not asked for yet. It reads the public web for similar products, changes in your domain and approaches other teams use. It reads your repository for what keeps coming up and no initiative covers.
10
+ The Initiative Driver works on initiatives that already exist. The Horizon scan looks the other way. It reads the public web for similar products, changes in your domain and approaches other teams use. It reads your repository for what keeps coming up and no initiative covers.
11
11
 
12
- Every finding is an ask in Triage. A scan never creates an initiative. You accept or decline each ask, as you do for any ask.
12
+ One scan covers the whole portfolio. It writes **report documents** and then **proposals** for what to do next. A scan never creates an initiative on its own. Nothing happens until you press Approve.
13
13
 
14
- ## How often it runs
14
+ ## Settings and schedule
15
15
 
16
- Each board has its own setting: **Daily**, **Weekly** or **Off**. Daily is the default.
16
+ The settings belong to the workspace, not to a board:
17
17
 
18
- - **Daily.** Every working day, Monday to Friday, at 09:00 local time.
19
- - **Weekly.** Every Monday at 09:00.
20
- - **Off.** No scan runs on that board.
18
+ - **Cadence.** **Daily** (the default), **Weekly** or **Off**.
19
+ - **Repository.** On or off. When off, a scan reads the web only.
20
+ - **Report types.** Benchmark, competitive, insight and productivity. All are on by default.
21
21
 
22
- If the workspace was not running at 09:00, the scan starts at the first check that day inside your working hours. It never starts outside your working hours or inside your quiet hours. A board gets at most one scan a day. Only one scan runs at a time across the workspace. `PMOS_DRIVER=off` or `PMOS_SCAN=off` starts no scan at all.
23
-
24
- Open the board, press **Board settings**, and find **Horizon scan** below Agent. The block shows the last scan and the next one.
22
+ A scheduled scan runs at most once a day, inside your working hours, never inside your quiet hours. Only one scan runs at a time. `PMOS_DRIVER=off` or `PMOS_SCAN=off` starts no scan at all. Board settings only show that the scan runs for the whole portfolio, with a link to the setup.
25
23
 
26
24
  ## The watch list
27
25
 
28
- The web part of a scan searches for what is on the watch list of the board, and for nothing else. An item has a kind and a name:
26
+ One watch list serves all boards. An item has a kind (`product`, `domain` or `approach`), a name, a reason and the boards it relates to. An old per-board list is folded into this list, and its board stays on its items.
27
+
28
+ The suggest run reads the initiatives of all boards and files up to 8 suggestions. For each one, press **Keep**, **Edit** or **Remove**. You can also add your own item. Only kept items are scanned.
29
29
 
30
- - **product.** A similar product or a reference product.
31
- - **domain.** A topic that changes often.
32
- - **approach.** A way of working that other teams use.
30
+ ## Scan now
33
31
 
34
- A board that has no watch list gets one **suggest run**. The agent reads the initiatives of the board and files up to 8 suggestions. Each one names the initiative it came from. Suggestions appear in Board settings. For each one, press **Keep**, **Edit** or **Remove**. You can also add your own item.
32
+ **Scan now** starts a scan by hand. A picker lets you choose the kept items, the repository and the report types.
35
33
 
36
- Only items you kept or added are scanned. A board with no kept item is not scanned.
34
+ - At most 3 manual scans a day, and one scan at a time.
35
+ - A manual scan may start outside working hours. It never starts inside quiet hours.
36
+ - A running scan shows its steps (web, repository, reports). **Stop** ends the runs and keeps what was already filed.
37
37
 
38
38
  ## What a scan does
39
39
 
40
40
  A scan is two short background runs, in this order.
41
41
 
42
- 1. **The web run.** It gets the kept watch list and nothing else. It cannot read your repository, your initiatives or your notes. It searches the public web and reads public pages. It files up to 5 findings. Each one has a title, a one-line reason and the public page the agent read.
43
- 2. **The repository run.** It reads your repository, your notes and the Jira themes in them. It has no way to reach the web. It files up to 3 findings of its own: recurring friction, an ask repeated in notes or meetings, a Jira theme with no initiative, or a stalled bet. It also marks which findings, from both runs, match an initiative that already exists.
42
+ 1. **The web run.** It gets the selected items and the report types, and nothing else. It cannot read your repository, your initiatives or your notes. It writes the web sections of the benchmark, competitive and insight reports. Each claim carries the URL the run read.
43
+ 2. **The repository run.** It reads your repository, your notes and the initiatives, and the web sections. It has no way to reach the web. It writes the repository sections and the productivity report (which is repository only), and files the proposals.
44
+
45
+ ## The report documents
46
+
47
+ Each selected type gives one markdown document at `<state>/reports/scan-<type>/<YYYY-MM-DD>.md`. A second scan on the same day adds `-2`, then `-3`. The frontmatter has `type: Reference`, `title`, `timestamp`, `source: scan:portfolio`, `made_by: agent`, `output: google-doc`, `scan`, `report` and `origin`. The Documents list in Reports reads them with no change.
48
+
49
+ Every claim cites a source: a URL the web run read, a path, a Jira key or a note. Each document ends with a **What next** section that lists its proposals.
50
+
51
+ ## Proposals
52
+
53
+ The repository run files up to 5 proposals for each report. Each one names the finding it came from and its evidence.
44
54
 
45
- The agent skips a finding it already filed on this board. A finding repeats an earlier one when it has the same source page, or when its title has the same significant words. The scan counts the skips.
55
+ - **create.** A title, a board, a lane, an anchor and an intent. Approve makes the initiative through the initiative writer in the first stage of the board's workflow, and adds the report as evidence.
56
+ - **research.** A question, its sources and a depth. Approve starts a research run: a web run, then a repository run. It writes a follow-up report linked to its parent, with its own proposals.
57
+ - **attach.** Names an existing initiative. Approve adds the finding to it as evidence.
46
58
 
47
- ## Where the findings land
59
+ You can **Approve**, **Change** the fields first, or **Dismiss** with a one-line reason. A dismissal counts as a correction, and a later scan does not raise a proposal whose title has the same significant words. **Undo** takes back a dismissal or a change: the proposal waits for you again, and a new line in the scan ledger records it. The correction a dismissal wrote stays. Approve has no Undo, because it made a real initiative or started runs.
48
60
 
49
- - **Triage.** Each new finding is an ask on its board. The row says **Found by agent**. It shows the reason, the sources and either "matches" an initiative or "no initiative yet". Accept, Ask for more and Decline work as for any ask. A declined scan ask counts as a correction.
50
- - **Briefing.** The **Horizon scan** card shows the scan of the day: when it ran, which boards, how many asks were found, how many match an initiative and how many are new. Each finding has **Open in Triage**. The card says how many public pages the agent read and how many findings it skipped.
61
+ If the initiatives are still being read, Approve for attach and research answers that and you press it again.
51
62
 
52
63
  ## What it can and cannot do
53
64
 
54
65
  - It can search and read public web pages, and read your repository.
55
- - It can file a finding. That is the only thing it writes.
66
+ - It can write report documents and file proposals. Those are the only things it writes.
56
67
  - It cannot send anything: no mail, no message, no comment, no ticket.
57
68
  - It cannot read a page behind a login.
58
- - It cannot create an initiative, accept an ask or change a record. Only you can.
69
+ - It cannot create an initiative or change a record. Only you can.
59
70
  - The run that reads the web never sees your repository. The run that reads your repository never reaches the web.
60
- - It cannot be started from the page. It runs on its schedule.
61
71
 
62
- Findings are proposals. The agent can be wrong. Check the sources before you accept.
72
+ The agent can be wrong. Check the sources before you approve.
63
73
 
64
74
  ## The commands
65
75
 
66
- The agent uses `pmos scan file` to file one finding, `pmos scan match` to mark a finding that matches an initiative, `pmos scan suggest` to file a watch list suggestion and `pmos scan list` to read the findings of one scan. `pmos scan status` shows the cadence, the watch list and the last scan of each board. The other commands are yours, at an interactive terminal:
76
+ The agent uses `pmos scan file`, `pmos scan section`, `pmos scan match`, `pmos scan propose`, `pmos scan suggest` and `pmos scan list`. `pmos scan status` shows the setting, the watch list and the last scan. The other commands are yours, at an interactive terminal:
67
77
 
68
- - `pmos scan cadence <daily|weekly|off> --board <id>` sets how often a board is scanned.
69
- - `pmos scan watch keep <item> --board <id>` keeps a suggestion. `pmos scan watch remove <item> --board <id>` removes an item.
70
- - `pmos scan watch edit <item> --board <id> --name "<new name>"` changes an item.
71
- - `pmos scan watch add --board <id> --kind product --name "<what to watch>"` adds an item.
78
+ - `pmos scan cadence <daily|weekly|off>` sets how often the portfolio is scanned.
79
+ - `pmos scan setting` changes the repository switch and the report types.
80
+ - `pmos scan watch keep|remove|edit|add ...` manages the watch list. `--boards a,b` sets the boards of an item.
72
81
 
73
- The ledger is `scan.jsonl` in the state folder of the repository (`pmos/state`, `state` or `.pmos`). Each action adds one line. Nothing is changed or removed.
82
+ The ledger is `scan.jsonl` in the state folder (`pmos/state`, `state` or `.pmos`). Each action adds one line. Nothing is changed or removed.
@@ -3,7 +3,7 @@
3
3
  * (initiative workspace-headless-jobs; PM decision 2026-10-11: keep Jev, no direct Claude API, background
4
4
  * jobs that need no human run headless).
5
5
  *
6
- * kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | 'ask' | 'drive' | 'scan-web' | 'scan-repo' | 'report-setup' | 'report-find' | null (null: open a pane, as before)
6
+ * kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | 'ask' | 'drive' | 'scan-web' | 'scan-repo' | 'scan-research-web' | 'scan-research-repo' | 'report-setup' | 'report-find' | null (null: open a pane, as before)
7
7
  * argsFor(kind, { modelArgs, addDirs }) → the argv after `claude`
8
8
  * parseResult(stdout) → { ok, text, isError, turns, durationMs, costUsd, denied[] }
9
9
  * logLines(result, exitCode) → the lines the read-only log pane shows at the end
@@ -51,6 +51,13 @@ const DRIVE_ORIGINS = Object.freeze(['drive:']);
51
51
  // the repository and fetches nothing. Neither has both untrusted input and a way out (PRD: trust boundary).
52
52
  const SCAN_WEB_ORIGINS = Object.freeze(['scan-web:']);
53
53
  const SCAN_REPO_ORIGINS = Object.freeze(['scan-repo:', 'scan-suggest:']);
54
+ // horizon-scan-portfolio: an approved research proposal is a second, smaller scan with the same split. Its web run (origin `scan-research-web:<ws>:<scan>`)
55
+ // gets the approved question and the public sources only. Its repository run (origin `scan-research-repo:<ws>:<scan>`) writes the follow-up's repository
56
+ // sections and files its proposals. The kinds are separate so the research web run holds one verb less than the scan web run (no findings) and so a test can
57
+ // read the argv of each.
58
+ const SCAN_RESEARCH_WEB_ORIGINS = Object.freeze(['scan-research-web:']);
59
+ const SCAN_RESEARCH_REPO_ORIGINS = Object.freeze(['scan-research-repo:']);
60
+ const WEB_KINDS = Object.freeze(['scan-web', 'scan-research-web']); // kinds that read untrusted pages: no directory, no Read, Glob or Grep
54
61
  // report-workspace: the Ask box run (origin `report-setup:<ask>`) and the weekly finder run (origin `report-find:<run>`).
55
62
  const REPORT_SETUP_ORIGINS = Object.freeze(['report-setup:']);
56
63
  const REPORT_FIND_ORIGINS = Object.freeze(['report-find:']);
@@ -63,10 +70,15 @@ const TOOLS = Object.freeze({
63
70
  drive: Object.freeze(['Read', 'Glob', 'Grep', 'Bash(pmos driver *)', 'Bash(pmos initiative list *)', 'Bash(pmos initiative export *)',
64
71
  'Bash(git log *)', 'Bash(ls *)', 'Bash(date *)']),
65
72
  // The web run: WebSearch, WebFetch and the one verb that files a finding. No Read, no Glob, no Grep, no repository path in its prompt or its add-dirs.
66
- 'scan-web': Object.freeze(['WebSearch', 'WebFetch', 'Bash(pmos scan file *)']),
73
+ // `pmos scan section` writes a section of a report document; the web run gives each web claim a URL it read.
74
+ 'scan-web': Object.freeze(['WebSearch', 'WebFetch', 'Bash(pmos scan file *)', 'Bash(pmos scan section *)']),
67
75
  // The repository run: read and search the repository, `pmos scan` (its PM-only verbs refuse an agent session), the initiative list and git log.
68
76
  // No WebSearch, no WebFetch, no curl, no git push: it reads the web run's findings, which are untrusted text, so it has nothing that sends or fetches.
69
77
  'scan-repo': Object.freeze(['Read', 'Glob', 'Grep', 'Bash(pmos scan *)', 'Bash(pmos initiative list *)', 'Bash(git log *)']),
78
+ // The research web run: the approved question and its public sources. WebSearch, WebFetch and the one verb that writes a section. No Read, Glob or Grep, no directory.
79
+ 'scan-research-web': Object.freeze(['WebSearch', 'WebFetch', 'Bash(pmos scan section *)']),
80
+ // The research repository run: the same tools as the scan repository run. It has no web tool and nothing that sends.
81
+ 'scan-research-repo': Object.freeze(['Read', 'Glob', 'Grep', 'Bash(pmos scan *)', 'Bash(pmos initiative list *)', 'Bash(git log *)']),
70
82
  // The Ask box run: read and search, the one verb that files a proposal, the read-only list of definitions and git log. No Write, no Edit, no web,
71
83
  // nothing that sends. The PM's earlier copies are read through a connector only where the PM's own settings allow it (as for `drive`).
72
84
  'report-setup': Object.freeze(['Read', 'Glob', 'Grep', 'Bash(pmos report propose *)', 'Bash(pmos report definitions *)', 'Bash(git log *)']),
@@ -91,6 +103,8 @@ function kindFor({ agent, origin, skill, env } = {}) {
91
103
  if (DRIVE_ORIGINS.some((p) => o.startsWith(p))) return 'drive';
92
104
  if (SCAN_WEB_ORIGINS.some((p) => o.startsWith(p))) return 'scan-web';
93
105
  if (SCAN_REPO_ORIGINS.some((p) => o.startsWith(p))) return 'scan-repo';
106
+ if (SCAN_RESEARCH_WEB_ORIGINS.some((p) => o.startsWith(p))) return 'scan-research-web';
107
+ if (SCAN_RESEARCH_REPO_ORIGINS.some((p) => o.startsWith(p))) return 'scan-research-repo';
94
108
  if (REPORT_SETUP_ORIGINS.some((p) => o.startsWith(p))) return 'report-setup';
95
109
  if (REPORT_FIND_ORIGINS.some((p) => o.startsWith(p))) return 'report-find';
96
110
  if (skill && HEADLESS_SKILLS.includes(String(skill))) return 'skill';
@@ -101,8 +115,8 @@ function argsFor(kind, { modelArgs = [], addDirs = [] } = {}) {
101
115
  const tools = TOOLS[kind];
102
116
  if (!tools) throw new Error('unknown headless kind: ' + kind);
103
117
  const argv = ['-p', '--output-format', 'json', '--no-session-persistence', ...modelArgs, '--allowedTools', tools.join(',')];
104
- // The web run gets no directory at all, whatever the caller passes: it must not be able to read the repository.
105
- if (kind !== 'scan-web') for (const d of addDirs) if (d) argv.push('--add-dir', String(d));
118
+ // A web run gets no directory at all, whatever the caller passes: it must not be able to read the repository.
119
+ if (!WEB_KINDS.includes(kind)) for (const d of addDirs) if (d) argv.push('--add-dir', String(d));
106
120
  return argv;
107
121
  }
108
122
 
@@ -144,4 +158,4 @@ function logLines(r, exitCode) {
144
158
  return out;
145
159
  }
146
160
 
147
- module.exports = { HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, DRIVE_ORIGINS, SCAN_WEB_ORIGINS, SCAN_REPO_ORIGINS, REPORT_SETUP_ORIGINS, REPORT_FIND_ORIGINS, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, logLines, switchedOff };
161
+ module.exports = { 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, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, logLines, switchedOff };
@@ -59,9 +59,9 @@ const VERBS = Object.freeze({
59
59
  // Not a pm-os-absorb verb (run: null): initiative initiative-driver added it through the same table.
60
60
  driver: Object.freeze({ module: 'driver-cli.js', run: null, guide: 'initiative-driver.md',
61
61
  help: 'pmos driver propose|status|keep|return|drop|pause|resume|mode … — the Initiative Driver: drafts for you to keep, send back or drop (pmos driver help)' }),
62
- // Not a pm-os-absorb verb (run: null): initiative horizon-scan added it through the same table.
62
+ // Not a pm-os-absorb verb (run: null): initiative horizon-scan added it through the same table; horizon-scan-portfolio added section, propose, setting and --boards.
63
63
  scan: Object.freeze({ module: 'scan-cli.js', run: null, guide: 'horizon-scan.md',
64
- help: 'pmos scan file|list|match|suggest|status|cadence|watch … — the Horizon scan: what the agent found outside the initiatives, and the watch list (pmos scan help)' }),
64
+ help: 'pmos scan file|section|propose|list [--sections]|match|suggest [--boards]|status|setting|cadence|watch [--boards] … — the Horizon scan: the portfolio watch list, the findings, the report sections and the proposals the PM approves, changes or dismisses (pmos scan help)' }),
65
65
  launcher: Object.freeze({ module: 'launcher-cli.js', run: null, guide: 'launcher.md',
66
66
  help: 'pmos launcher on|off|status — a PMOS app and a login item, so the workspace starts without a terminal' }),
67
67
  });
@@ -69,7 +69,7 @@ const MODULES = Object.freeze([
69
69
  'routes-meeting-focus', // console3 Meeting focus: the meeting, its note's talk, the companion's notes (GET); topic corrections and Ask PMOS quietly (page press)
70
70
  'routes-stage-tools', // stage-tools-records: the records the PM edits per stage, what PMOS holds per stage, the sprint burndown
71
71
  'routes-driver', // initiative-driver: the Agent strip's data, Keep, Send back, Drop, Pause and the board mode (driver.jsonl)
72
- 'routes-scan', // horizon-scan: a board's watch list, cadence and last scan, the day's scan for the Briefing, the PM's watch and cadence presses (scan.jsonl)
72
+ 'routes-scan', // horizon-scan-portfolio: one scan for the workspace: the watch list and setting, Scan now and Stop, report documents and their proposals (approve, change, dismiss, undo), plus the first scan's per-board and Briefing reads (scan.jsonl)
73
73
  'routes-report-setups', // report-workspace: recurring reports, the Ask box, the finder, the report documents and their export (report-setups.jsonl)
74
74
  ]);
75
75
  const PM_DIR = path.join(__dirname, '..', 'public', 'pm');
@@ -128,6 +128,30 @@ function appendScanAsk(ctx, w, boardId, f, by) {
128
128
  return { ok: true, existing: false, id };
129
129
  }
130
130
 
131
+ /** A new initiative on a board: initiative-writer makes the record (the only writer of one), then `tie` appends the boards-ledger line that puts it in the FIRST stage
132
+ * of the board's workflow, and the initiative's own workflow line when its default template differs from the board's. Triage's accept (asks/accept) and the Horizon
133
+ * scan's approved proposals (lib/routes-scan.js) both come through here, so the placement is one piece of code.
134
+ * `tie({ file, board, initiative, stage })` appends the line that names the initiative on the board (ask-accept, or assign and move).
135
+ * → { ok, initiative: { id, path }, stage, board } | { ok: false, status, error, field } */
136
+ function createAndPlace(ctx, w, boardId, input, actor, tie) {
137
+ const fr = frameFor(ctx, w);
138
+ const board = fr.boards.find((b) => b.id === boardId);
139
+ if (!board) return { ok: false, status: 404, error: `no board ${boardId} in this workspace` };
140
+ const file = ledgerFile(ctx, w);
141
+ if (!file) return { ok: false, status: 409, error: NO_STATE };
142
+ const r = writer.createInitiative(w.root, input, { actor });
143
+ if (!r.ok) return { ok: false, status: r.status || 400, error: r.error, field: r.field || null };
144
+ const tpl = board.defTpl && B.INI_TEMPLATES[board.defTpl] ? board.defTpl : null;
145
+ const ownWf = tpl && tpl !== board.template ? B.INI_TEMPLATES[tpl].stages.map((x) => ({ name: x.name, cat: x.cat, hint: x.hint || '' })) : null;
146
+ const stage = (ownWf || board.workflow)[0].name;
147
+ tie({ file, board, initiative: r.id, stage });
148
+ if (ownWf) append(file, actor, { kind: 'ini-workflow', board: board.id, id: r.id, template: tpl, stages: ownWf, moved: null });
149
+ rowsLib.invalidate(w.root);
150
+ rowCache.delete(w.root);
151
+ if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg(w.id);
152
+ return { ok: true, initiative: { id: r.id, path: r.path }, stage, board: board.id };
153
+ }
154
+
131
155
  // ── boards of a workspace ─────────────────────────────────────────────────────────────────────────
132
156
  const isSelf = (ctx, w) => !!(ctx.selfRoot && w && path.resolve(w.root) === path.resolve(ctx.selfRoot));
133
157
 
@@ -677,17 +701,10 @@ const WRITES = {
677
701
  if (!p.file) return fail(ctx, res, 409, NO_STATE);
678
702
  const input = { title: body.title, lane: body.lane, anchor: body.anchor };
679
703
  for (const k of ['intent', 'id']) if (body[k] != null && body[k] !== '') input[k] = body[k];
680
- const r = writer.createInitiative(p.w.root, input, { actor: g.actor });
681
- if (!r.ok) return fail(ctx, res, r.status || 400, r.error, { field: r.field || null });
682
- const tpl = p.board.defTpl && B.INI_TEMPLATES[p.board.defTpl] ? p.board.defTpl : null;
683
- const ownWf = tpl && tpl !== p.board.template ? B.INI_TEMPLATES[tpl].stages.map((x) => ({ name: x.name, cat: x.cat, hint: x.hint || '' })) : null;
684
- const stage = (ownWf || p.board.workflow)[0].name;
685
- append(p.file, g.actor, { kind: 'ask-accept', board: p.board.id, ask: a.id, initiative: r.id, stage });
686
- if (ownWf) append(p.file, g.actor, { kind: 'ini-workflow', board: p.board.id, id: r.id, template: tpl, stages: ownWf, moved: null });
687
- rowsLib.invalidate(p.w.root);
688
- rowCache.delete(p.w.root);
689
- if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg(p.w.id);
690
- ctx.json(res, { ok: true, initiative: { id: r.id, path: r.path }, stage });
704
+ const made = createAndPlace(ctx, p.w, p.board.id, input, g.actor,
705
+ ({ file, initiative, stage }) => append(file, g.actor, { kind: 'ask-accept', board: p.board.id, ask: a.id, initiative, stage }));
706
+ if (!made.ok) return fail(ctx, res, made.status || 400, made.error, { field: made.field || null });
707
+ ctx.json(res, { ok: true, initiative: made.initiative, stage: made.stage });
691
708
  },
692
709
  '/api/boards/asks/more': (ctx, res, body, g) => {
693
710
  const p = postFrame(ctx, res, body);
@@ -992,4 +1009,4 @@ function meetingBoards(ctx) {
992
1009
  return { list, detail };
993
1010
  }
994
1011
 
995
- module.exports = { prefixes: ['/api/boards'], handle, move, boardIndex, appendScanAsk, frameFor, boardIdOf, meetingBoards, boardsOf, baseRows, boardRow, portfolio, SELF_REFUSAL, NO_STATE };
1012
+ module.exports = { prefixes: ['/api/boards'], handle, move, boardIndex, appendScanAsk, scanDeclineSignal, createAndPlace, ledgerFile, append, frameFor, boardIdOf, meetingBoards, boardsOf, baseRows, boardRow, portfolio, SELF_REFUSAL, NO_STATE };