create-pmos 0.9.0 → 0.10.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/README.md CHANGED
@@ -333,6 +333,71 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
333
333
  `okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
334
334
  exactly what you're running.
335
335
 
336
+ ## What's new in 0.10.0
337
+
338
+ **Editing a document now does something for the rest of them.**
339
+
340
+ 0.8.0 gave you an editor: change a markdown document in the workspace, see the diff as you type, and
341
+ have the change logged. This release adds the two halves that make that log worth keeping.
342
+
343
+ ### After you save, you are offered what your change puts out of step
344
+
345
+ Not a scan of your repository — a list built from **your change**. A document only appears if it has a
346
+ concrete tie to what you edited:
347
+
348
+ - it links to the document you changed, or names it in frontmatter (`prd_path`, `rubric_path`, …)
349
+ - it shares that document's `initiative_id`
350
+ - it **quotes text your change altered** — the strongest signal, because it is now asserting something
351
+ the source no longer says
352
+
353
+ Each candidate says which of those it is, so you can judge the suggestion before spending anything on
354
+ it. **"Seems related" is deliberately not a rule.** It would widen this to your whole repository and
355
+ put you back in front of a queue of an agent's opinions.
356
+
357
+ You tick which documents an agent may read. It drafts the edit that brings each back in line, and each
358
+ draft comes back as a diff to apply or skip. Applying uses the same guard as every other write here:
359
+ the text being replaced must appear exactly once, or the write is refused rather than guessed at.
360
+
361
+ **It never blocks.** The save completed before any of this. Dismiss the offer and nothing is queued,
362
+ nothing badges, nothing waits for you.
363
+
364
+ ### Your change log is now something an agent reads
365
+
366
+ Every save regenerates `change-context.md` in your repository's own state directory: the documents you
367
+ change most, the recent changes, and — the part that matters — **your own stated reasons**, in your
368
+ words. An agent reading it before working in your repo knows what you keep rewriting and why, so it
369
+ can stop producing the thing that provoked the rewrite.
370
+
371
+ That is why the editor asks for a reason at all. A reason nobody reads is just friction at save time.
372
+
373
+ Edits made by applying an alignment record **what they were aligned with**, so the log reads as a
374
+ chain rather than as unexplained changes appearing in files you did not open.
375
+
376
+ ### Also carried: the 0.9.1 upgrade fix
377
+
378
+ **0.9.1 was never published.** Its fix — `create-pmos workspace` self-upgrading instead of quietly
379
+ launching your old installed app — is in this release. If the workspace has looked unchanged across
380
+ recent versions, that is why; run `npx create-pmos@latest workspace` once and it will self-heal onto
381
+ the current app. Hard-refresh the browser afterwards to drop the cached stylesheet.
382
+
383
+ ## What's new in 0.9.1
384
+
385
+ **A fix so workspace upgrades actually reach you.** If you activated the Terminal Workspace in an
386
+ earlier version, `npx create-pmos@latest workspace` was quietly launching your *old* installed app —
387
+ so 0.9.0's restyle and whose-turn view never showed up. That was the bug, not a missing feature.
388
+
389
+ - **`create-pmos workspace` now self-upgrades.** It compares the bundled app's version against what
390
+ you have installed and **reinstalls when the bundle is newer** (then restarts the persistent server
391
+ so the new code actually serves). A same-version install still fast-launches — no needless
392
+ ~20-second native rebuild.
393
+ - **`create-pmos workspace --reinstall`** — force it any time.
394
+ - **`pmos upgrade`** — the same, straight from the installed command.
395
+ - If the UI still looks unchanged right after an upgrade, **hard-refresh the browser**
396
+ (Cmd/Ctrl+Shift+R) to drop the cached stylesheet.
397
+
398
+ So if 0.9.0 looked like "not much changed," this is why: the code shipped, but your old install kept
399
+ running. Run `npx create-pmos@latest workspace` once and it will self-heal onto the current app.
400
+
336
401
  ## What's new in 0.9.0
337
402
 
338
403
  **The Terminal Workspace now tells you whose turn it is.** The optional GUI got this release's real
@@ -140,24 +140,81 @@ function launchWorkspace(targetDir, viaGlobal) {
140
140
  { stdio: 'inherit', env: process.env }).status || 0;
141
141
  }
142
142
 
143
- function workspace(targetArg) {
143
+ // Compare two dotted numeric versions. Returns -1 if a<b, 0 if equal, 1 if a>b. Missing/non-numeric
144
+ // segments count as 0, so 'unknown' sorts oldest — the safe default (reinstall rather than run stale).
145
+ function cmpVer(a, b) {
146
+ const pa = String(a || '0').split('.').map((n) => parseInt(n, 10) || 0);
147
+ const pb = String(b || '0').split('.').map((n) => parseInt(n, 10) || 0);
148
+ for (let i = 0; i < 3; i++) {
149
+ const d = (pa[i] || 0) - (pb[i] || 0);
150
+ if (d) return d < 0 ? -1 : 1;
151
+ }
152
+ return 0;
153
+ }
154
+ // Version of the workspace app bundled in THIS package. It is kept in lockstep with the create-pmos
155
+ // release version in workspace/package.json, so a newer package means a newer bundled app.
156
+ function bundledWorkspaceVersion() {
157
+ try { return require(path.join(WORKSPACE, 'package.json')).version || null; }
158
+ catch { return null; }
159
+ }
160
+ // Version of the globally-installed `pmos-workspace`, or null if absent/unreadable. Read from the
161
+ // npm global root rather than the app's CLI — no dependency on the old binary supporting --version.
162
+ function installedWorkspaceVersion() {
163
+ const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
164
+ const r = spawnSync(npmCmd, ['root', '-g'], { encoding: 'utf8', shell: process.platform === 'win32' });
165
+ if (r.status !== 0 || !r.stdout) return null;
166
+ try { return require(path.join(r.stdout.trim(), 'pmos-workspace', 'package.json')).version || null; }
167
+ catch { return null; }
168
+ }
169
+ function installWorkspaceGlobal() {
170
+ const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
171
+ return spawnSync(npmCmd, ['install', '-g', WORKSPACE, '--no-audit', '--no-fund'],
172
+ { stdio: 'inherit', shell: process.platform === 'win32' }).status;
173
+ }
174
+ // A persistent server started by the OLD code keeps serving OLD assets after an upgrade — stop it so
175
+ // the next launch brings the server up on the freshly installed code. Best-effort; ignore failures.
176
+ function stopWorkspaceServer() {
177
+ try { spawnSync('pmos', ['stop'], { stdio: 'ignore', shell: process.platform === 'win32' }); }
178
+ catch { /* no server running, or pmos not launchable — nothing to stop */ }
179
+ }
180
+
181
+ function workspace(targetArg, opts) {
144
182
  workspaceOrDie();
145
183
  const targetDir = path.resolve(targetArg || '.');
184
+ const forceReinstall = !!(opts && opts.reinstall);
146
185
 
147
- // Already activated? Then this is just a launch — fast path, no reinstall.
186
+ // Already activated? Decide launch-vs-reinstall by VERSION, not merely by presence. The old
187
+ // behaviour fast-launched whenever `pmos` was on PATH, so every workspace improvement was invisible
188
+ // to anyone who had activated once — the installed app never advanced. Now: reinstall when forced,
189
+ // when the installed version can't be read, or when the bundled app is newer; otherwise fast-launch
190
+ // (no ~20s native rebuild for an already-current install).
148
191
  if (onPath('pmos')) {
149
- console.log('✅ workspace already activated — launching. (Type `pmos` directly next time.)');
192
+ const bundled = bundledWorkspaceVersion();
193
+ const installed = installedWorkspaceVersion();
194
+ const stale = forceReinstall || installed === null || (bundled != null && cmpVer(installed, bundled) < 0);
195
+ if (!stale) {
196
+ console.log(`✅ workspace already current (pmos-workspace ${installed}) — launching. (Type \`pmos\` directly next time.)`);
197
+ process.exit(launchWorkspace(targetDir, true));
198
+ }
199
+ console.log(forceReinstall
200
+ ? 'Reinstalling the Terminal Workspace app (--reinstall)…'
201
+ : `Upgrading the Terminal Workspace app (installed ${installed || 'unknown'} → bundled ${bundled})…`);
202
+ const status = installWorkspaceGlobal();
203
+ if (status === 0) {
204
+ stopWorkspaceServer(); // restart the persistent server on the new code
205
+ console.log(`\n✅ pmos-workspace is now ${bundled}. Launching — if the UI still looks unchanged, hard-refresh the browser (Cmd/Ctrl+Shift+R) to drop the cached stylesheet.`);
206
+ } else {
207
+ console.log('\n⚠ Reinstall did not complete cleanly — launching the previously installed copy.');
208
+ }
150
209
  process.exit(launchWorkspace(targetDir, true));
151
210
  }
152
211
 
212
+ // First-time activation (pmos not on PATH).
153
213
  console.log('Activating the PMOS Terminal Workspace (one-time). Installing node-pty (native) + putting `pmos` on PATH…');
154
- const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
155
- const g = spawnSync(npmCmd, ['install', '-g', WORKSPACE, '--no-audit', '--no-fund'],
156
- { stdio: 'inherit', shell: process.platform === 'win32' });
157
-
158
- if (g.status === 0 && onPath('pmos')) {
214
+ const status = installWorkspaceGlobal();
215
+ if (status === 0 && onPath('pmos')) {
159
216
  console.log('\n✅ `pmos` is now on your PATH. From any repo folder, just type: pmos');
160
- console.log(' pmos status · pmos stop · pmos doctor');
217
+ console.log(' pmos status · pmos stop · pmos doctor · pmos upgrade');
161
218
  process.exit(launchWorkspace(targetDir, true));
162
219
  }
163
220
 
@@ -177,14 +234,15 @@ if (args.includes('--help') || args.includes('-h')) {
177
234
  console.log('usage:');
178
235
  console.log(' create-pmos [target-dir] vendor the file-mode kit into a repo');
179
236
  console.log(' create-pmos --update [kit-or-repo] refresh only the kit kernel');
180
- console.log(' create-pmos workspace [repo-dir] install + launch the Terminal Workspace GUI');
237
+ console.log(' create-pmos workspace [repo-dir] install/upgrade + launch the Terminal Workspace GUI');
238
+ console.log(' create-pmos workspace --reinstall force-reinstall the workspace app (drop a stale global copy)');
181
239
  console.log(' create-pmos --version print version + bundled kernel provenance');
182
240
  } else if (args.includes('--version') || args.includes('-v')) {
183
241
  console.log(`create-pmos ${PKG.version}`);
184
242
  console.log(kitVersion());
185
243
  console.log(`workspace bundled: ${fs.existsSync(path.join(WORKSPACE, 'bin', 'pmos.js')) ? 'yes' : 'no'}`);
186
244
  } else if (positional[0] === 'workspace') {
187
- workspace(positional[1]);
245
+ workspace(positional[1], { reinstall: args.includes('--reinstall') });
188
246
  } else if (args.includes('--update')) {
189
247
  update(positional[0]);
190
248
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.9.0",
3
+ "version": "0.10.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. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@bb26298
1
+ kernel-source: pmos@1771814
2
2
  assembled: 2026-07-28
3
3
  mode: vendor
@@ -211,10 +211,17 @@ function doctor() {
211
211
  else if (cmd === 'ls' || cmd === 'list') code = await ls();
212
212
  else if (cmd === 'stop') code = await stop();
213
213
  else if (cmd === 'doctor') code = doctor();
214
- else if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
215
- say('usage: pmos [<path>|status|ls|stop|doctor]');
214
+ else if (cmd === 'upgrade') {
215
+ // Escape hatch: pull the latest bundled app and force-reinstall over this global copy. Delegates
216
+ // to the scaffolder (which owns the install path) rather than reinstalling itself in place.
217
+ const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
218
+ code = spawnSync(npx, ['-y', 'create-pmos@latest', 'workspace', '--reinstall', process.cwd()],
219
+ { stdio: 'inherit', shell: process.platform === 'win32' }).status || 0;
220
+ } else if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
221
+ say('usage: pmos [<path>|status|ls|stop|doctor|upgrade]');
216
222
  say(' pmos open the current repo as a workspace (starts the server if needed)');
217
223
  say(' pmos <path> open another repo as a workspace');
224
+ say(' pmos upgrade reinstall the workspace app from the latest published create-pmos');
218
225
  code = 0;
219
226
  } else if (cmd === 'up' || cmd === 'start' || cmd === 'open') {
220
227
  // `pmos open ../other-repo` — the path is the argument AFTER the verb
@@ -0,0 +1,107 @@
1
+ /* related-docs — which OTHER documents does this change put out of step?
2
+ * (workspace-doc-editor · R5/R6)
3
+ *
4
+ * Pure: takes the changed file, what changed in it, and a list of {path, text} for the repository's
5
+ * other documents. Returns candidates with a stated reason. No filesystem, no network, so the
6
+ * scoring rules are unit-testable directly and the endpoint stays a thin caller.
7
+ *
8
+ * R6 is the constraint that shapes everything here: **alignment is scoped by the change, not by the
9
+ * repository.** A pass that re-reads everything is a review by another name — the exact feature this
10
+ * one replaced, arriving through the back door. So a document only becomes a candidate if it has a
11
+ * concrete tie to the thing that changed:
12
+ *
13
+ * - it LINKS to the changed file, or names it in frontmatter (prd_path, rubric_path, …)
14
+ * - it shares the changed file's initiative_id
15
+ * - it QUOTES text the change removed or altered
16
+ *
17
+ * "Feels related" is deliberately not a rule. Semantic similarity would widen this to everything and
18
+ * put the PM back in front of a queue of an agent's opinions, which is what they said they did not
19
+ * want. If references miss something, that is a known and accepted cost, recorded in the PRD.
20
+ */
21
+ 'use strict';
22
+ (function (root, factory) {
23
+ const api = factory();
24
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
25
+ else root.RelatedDocs = api;
26
+ })(typeof self !== 'undefined' ? self : this, function () {
27
+
28
+ const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
29
+
30
+ /** `key: value` from a leading frontmatter block. Tolerates a missing block. */
31
+ function frontmatter(text) {
32
+ const m = /^---\n([\s\S]*?)\n---/.exec(String(text || ''));
33
+ if (!m) return {};
34
+ const out = {};
35
+ for (const line of m[1].split('\n')) {
36
+ const i = line.indexOf(':');
37
+ if (i < 1) continue;
38
+ out[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, '');
39
+ }
40
+ return out;
41
+ }
42
+
43
+ /* Phrases worth searching other documents for. Short or generic lines match everything and would
44
+ * turn precision into noise, so a line must be substantial to qualify — and only a few are used,
45
+ * because each one costs a scan of every document. */
46
+ function phrasesFrom(removedText, min = 24, max = 4) {
47
+ return String(removedText || '')
48
+ .split('\n')
49
+ .map((l) => l.replace(/^[\s>#*\-+\d.]+/, '').trim())
50
+ .filter((l) => l.length >= min && /[a-z]{4}/i.test(l))
51
+ .sort((a, b) => b.length - a.length)
52
+ .slice(0, max);
53
+ }
54
+
55
+ /**
56
+ * candidates({ changedPath, changedText, removed, docs })
57
+ * changedPath — repo-relative path of the document just saved
58
+ * changedText — its full text after the save (for its own frontmatter)
59
+ * removed — the text the save removed or replaced (drives the quote rule)
60
+ * docs — [{ path, text }] for the repository's other markdown documents
61
+ * → [{ path, reasons: [string], strength }] sorted strongest first. Never throws.
62
+ */
63
+ function candidates({ changedPath, changedText, removed, docs } = {}) {
64
+ const target = String(changedPath || '');
65
+ if (!target || !Array.isArray(docs)) return [];
66
+ const base = target.split('/').pop();
67
+ const mine = frontmatter(changedText);
68
+ const initiative = mine.initiative_id || mine.id || null;
69
+ const phrases = phrasesFrom(removed);
70
+ const out = [];
71
+
72
+ for (const d of docs) {
73
+ if (!d || typeof d.path !== 'string' || d.path === target) continue;
74
+ const text = String(d.text || '');
75
+ const reasons = [];
76
+
77
+ // 1. links to the changed file — by full repo path or by filename
78
+ if (text.includes(target) || new RegExp('\\(([^)]*/)?' + esc(base) + '\\)').test(text)) {
79
+ reasons.push('links to this document');
80
+ }
81
+ // 2. frontmatter points at it (prd_path, rubric_path, and anything else path-shaped)
82
+ const fm = frontmatter(text);
83
+ for (const [k, v] of Object.entries(fm)) {
84
+ if (typeof v === 'string' && v && (v === target || v.endsWith('/' + base))) {
85
+ reasons.push(`its ${k} is this document`);
86
+ }
87
+ }
88
+ // 3. same initiative — the artifacts of one piece of work move together
89
+ if (initiative && (fm.initiative_id === initiative || fm.id === initiative)) {
90
+ reasons.push(`same initiative (${initiative})`);
91
+ }
92
+ // 4. quotes text this change removed or altered — the strongest signal there is, because the
93
+ // other document is now asserting something the source no longer says
94
+ for (const ph of phrases) {
95
+ if (text.includes(ph)) { reasons.push(`quotes text this change altered: "${ph.slice(0, 60)}…"`); break; }
96
+ }
97
+
98
+ if (reasons.length) {
99
+ const quotes = reasons.some((r) => r.startsWith('quotes text'));
100
+ out.push({ path: d.path, reasons, strength: reasons.length + (quotes ? 2 : 0) });
101
+ }
102
+ }
103
+ return out.sort((a, b) => b.strength - a.strength || a.path.localeCompare(b.path));
104
+ }
105
+
106
+ return { candidates, frontmatter, phrasesFrom };
107
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.1.0",
3
+ "version": "0.9.1",
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",
@@ -2070,10 +2070,12 @@ function edRender() {
2070
2070
  $('edDiff').innerHTML = rows.join('') || '<div class="ed-none">whitespace only</div>';
2071
2071
  }
2072
2072
 
2073
- function edClose(reload) {
2073
+ async function edClose(reload) {
2074
2074
  ed.on = false; ed.path = null; ed.base = ''; ed.dirty = false;
2075
2075
  edControls();
2076
- if (reload && state.docPath) openDoc(state.docPath);
2076
+ // awaited: the reload replaces docBody, so anything that renders into it afterwards (the
2077
+ // alignment offer) must not race the replacement.
2078
+ if (reload && state.docPath) await openDoc(state.docPath);
2077
2079
  }
2078
2080
 
2079
2081
  async function edSave() {
@@ -2098,7 +2100,120 @@ async function edSave() {
2098
2100
  return;
2099
2101
  }
2100
2102
  flash(out.warning ? 'saved — but the change could not be logged' : `saved · +${out.change.added} −${out.change.removed}`);
2101
- edClose(true);
2103
+ const saved = ed.path;
2104
+ await edClose(true);
2105
+ alOffer(saved); // R7: offered, never blocking — the save is already done
2106
+ }
2107
+
2108
+
2109
+ // ── alignment (workspace-doc-editor R5–R7) ────────────────────────────────────────────────────
2110
+ // Strictly downstream of a save. Nothing here runs until the PM has changed something, and what it
2111
+ // looks at is scoped by that change — not the repository. It is offered, never blocking: the save
2112
+ // completed before any of this, and ignoring it costs nothing.
2113
+ async function alOffer(pth) {
2114
+ if (!pth) return;
2115
+ let out = {};
2116
+ try { out = await (await fetch(`/api/align/candidates?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(pth)}`)).json(); }
2117
+ catch { return; }
2118
+ if (!out.ok || out.empty || !out.candidates || !out.candidates.length) return;
2119
+ state.alCand = out;
2120
+ const n = out.candidates.length;
2121
+ const bar = document.createElement('div');
2122
+ bar.className = 'al-offer';
2123
+ bar.innerHTML = `<span>${n} other document${n === 1 ? '' : 's'} may now be out of step with that change.</span>
2124
+ <button data-al-see>see them</button><button class="q" data-al-no>not now</button>`;
2125
+ const body = $('docBody');
2126
+ body.prepend(bar);
2127
+ bar.querySelector('[data-al-no]').onclick = () => bar.remove();
2128
+ bar.querySelector('[data-al-see]').onclick = () => alShow();
2129
+ }
2130
+
2131
+ // The candidate list, each with the concrete reason it qualified. The PM chooses which to hand to
2132
+ // an agent — so even the drafting step is something they asked for, not something that happened.
2133
+ function alShow() {
2134
+ const c = state.alCand; if (!c) return;
2135
+ $('docBody').innerHTML = `<div class="al">
2136
+ <div class="al-lede">Your change to <span class="mono">${esc(c.change.file)}</span> may leave these out of step.</div>
2137
+ <div class="al-sub">Each one has a concrete tie to what you changed — a link, a shared initiative, or text it quotes. Considered ${c.considered} documents.</div>
2138
+ ${c.candidates.map((x, i) => `<label class="al-c">
2139
+ <input type="checkbox" data-al-pick="${i}" checked />
2140
+ <span><span class="al-p mono">${esc(x.path)}</span>
2141
+ <span class="al-why">${x.reasons.map((r) => esc(r)).join(' · ')}</span></span>
2142
+ </label>`).join('')}
2143
+ <div class="al-acts">
2144
+ <button data-al-go>Draft the edits</button>
2145
+ <button class="q" data-al-close>Close</button>
2146
+ <span class="al-note">An agent reads only the documents you tick, and only against this change.</span>
2147
+ </div></div>`;
2148
+ $('docBody').querySelector('[data-al-close]').onclick = () => openDoc(c.change.file);
2149
+ $('docBody').querySelector('[data-al-go]').onclick = async () => {
2150
+ const targets = [...$('docBody').querySelectorAll('[data-al-pick]')]
2151
+ .filter((b) => b.checked).map((b) => c.candidates[Number(b.dataset.alPick)].path);
2152
+ if (!targets.length) { flash('nothing ticked'); return; }
2153
+ let out = {};
2154
+ try {
2155
+ out = await (await fetch('/api/align/start', { method: 'POST', headers: { 'Content-Type': 'application/json' },
2156
+ body: JSON.stringify({ ws: state.wsId, path: c.change.file, change: c.change, targets }) })).json();
2157
+ } catch { out = { ok: false, error: 'could not reach the workspace server' }; }
2158
+ if (!out.ok) { flash(out.error || 'could not start'); return; }
2159
+ flash(`drafting against ${targets.length} document${targets.length === 1 ? '' : 's'} — watch the session, then open drafts`);
2160
+ if (out.sessionId) selectSession(out.sessionId);
2161
+ };
2162
+ }
2163
+
2164
+ // The drafts an agent produced, each an applyable diff. Apply uses the same anchored guard as every
2165
+ // other write here: the anchor must appear exactly once or the write is refused.
2166
+ async function alDrafts() {
2167
+ let out = {};
2168
+ try { out = await (await fetch(`/api/align/drafts?ws=${encodeURIComponent(state.wsId || '')}`)).json(); }
2169
+ catch { out = { ok: false, error: 'could not reach the workspace server' }; }
2170
+ showRail(); $('paneDoc').hidden = false; foldPane('doc', true);
2171
+ $('crumb').innerHTML = '<bdi>alignment drafts</bdi>';
2172
+ if (!out.ok) { $('docBody').innerHTML = `<div class="al"><div class="al-lede">${esc(out.error || 'could not read the drafts')}</div></div>`; return; }
2173
+ if (out.empty || !out.drafts.length) {
2174
+ $('docBody').innerHTML = `<div class="al"><div class="al-lede">No drafts yet.</div>
2175
+ <div class="al-sub">Edit a document, then ask for the alignment when it offers.</div></div>`;
2176
+ return;
2177
+ }
2178
+ state.alDrafts = out;
2179
+ $('docBody').innerHTML = `<div class="al">
2180
+ <div class="al-lede">${out.drafts.length} edit${out.drafts.length === 1 ? '' : 's'} to bring other documents back in line.</div>
2181
+ <div class="al-sub">Drafted against your change to <span class="mono">${esc(out.source || '')}</span>. Nothing is written until you apply it.</div>
2182
+ ${out.drafts.map((d, i) => `<div class="al-d" data-al-d="${i}">
2183
+ <div class="al-p mono">${esc(d.file)}</div>
2184
+ <div class="al-why">${esc(d.why || '')}</div>
2185
+ <div class="al-code">
2186
+ ${String(d.before || '').split('\n').map((l) => `<div class="ed-l del"><span class="g"></span><span class="t">${esc(l) || '&nbsp;'}</span></div>`).join('')}
2187
+ ${String(d.after || '').split('\n').map((l) => `<div class="ed-l add"><span class="g"></span><span class="t">${esc(l) || '&nbsp;'}</span></div>`).join('')}
2188
+ </div>
2189
+ <div class="al-acts"><button data-al-apply="${i}">Apply</button>
2190
+ <button class="q" data-al-skip="${i}">Skip</button></div>
2191
+ </div>`).join('')}</div>`;
2192
+ $('docBody').querySelectorAll('[data-al-skip]').forEach((b) => b.onclick = () => {
2193
+ $('docBody').querySelector(`[data-al-d="${b.dataset.alSkip}"]`).classList.add('done');
2194
+ });
2195
+ $('docBody').querySelectorAll('[data-al-apply]').forEach((b) => b.onclick = async () => {
2196
+ const d = state.alDrafts.drafts[Number(b.dataset.alApply)];
2197
+ b.disabled = true;
2198
+ let r = {};
2199
+ try {
2200
+ r = await (await fetch('/api/align/apply', { method: 'POST', headers: { 'Content-Type': 'application/json' },
2201
+ body: JSON.stringify({ ws: state.wsId, file: d.file, before: d.before, after: d.after, why: d.why, source: state.alDrafts.source }) })).json();
2202
+ } catch { r = { ok: false, error: 'could not reach the workspace server' }; }
2203
+ b.disabled = false;
2204
+ const card = $('docBody').querySelector(`[data-al-d="${b.dataset.alApply}"]`);
2205
+ if (!r.ok) {
2206
+ let e = card.querySelector('.al-err');
2207
+ if (!e) { e = document.createElement('div'); e.className = 'al-err'; card.appendChild(e); }
2208
+ e.textContent = r.error === 'stale' ? 'that text is no longer in the file — the draft is out of date'
2209
+ : r.error === 'ambiguous' ? (r.detail || 'that text appears more than once — refusing to guess')
2210
+ : (r.detail || r.error || 'refused');
2211
+ return;
2212
+ }
2213
+ card.classList.add('done');
2214
+ flash('aligned ' + d.file.split('/').pop());
2215
+ });
2216
+ refit();
2102
2217
  }
2103
2218
 
2104
2219
  $('docEdit').onclick = edOpen;
@@ -2602,6 +2717,7 @@ function openPalette() {
2602
2717
  const wss = state.workspaces.filter((w) => w.id !== state.wsId).map((w) => ({ kind: 'workspace', label: w.name, wsid: w.id }));
2603
2718
  // summon a P/D/M/R surface on demand — listed first so "initiatives"/"roadmap"/"okr"/"report" are reachable
2604
2719
  const views = SURFACES.map((v) => ({ kind: 'view', label: v.label, view: v.name }));
2720
+ views.push({ kind: 'align', label: 'alignment drafts' }); // downstream of a save, never resident
2605
2721
  palItems = [...views, ...wss, ...sess, ...inits, ...docs];
2606
2722
  $('palette').style.display = 'grid';
2607
2723
  const inp = $('palInput'); inp.value = ''; renderPalette('');
@@ -2627,6 +2743,7 @@ function choosePalette(n) {
2627
2743
  const it = items[n]; if (!it) return;
2628
2744
  closePalette();
2629
2745
  if (it.kind === 'view') openSurface(it.view);
2746
+ else if (it.kind === 'align') alDrafts();
2630
2747
  else if (it.kind === 'session') selectSession(it.id);
2631
2748
  else if (it.kind === 'workspace') selectWorkspace(it.wsid);
2632
2749
  else if (it.kind === 'initiative') {
@@ -1279,3 +1279,37 @@ line-height:1.35;cursor:pointer}
1279
1279
  .hist-when i{color:var(--destructive);font-style:normal;font-weight:600}
1280
1280
  .hist-note{margin-top:4px;font-size:var(--text-sm);color:var(--foreground);line-height:1.55}
1281
1281
  .hist-none{font-size:var(--text-sm);color:var(--muted-foreground);line-height:1.6}
1282
+
1283
+ /* ── alignment (workspace-doc-editor R5–R7) ──────────────────────────────────────────────────
1284
+ Downstream of a save and offered, never blocking. The offer is one line that can be dismissed;
1285
+ nothing accumulates and nothing badges.
1286
+ Tokens follow the radix-sera port: --brand is the blue for text/borders, --primary is for fills,
1287
+ and --accent is a muted hover surface, not a hue. Radii come from the scale — never a px. */
1288
+ .al-offer{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:12px 14px;padding:10px 12px;
1289
+ border-radius:var(--radius-md);background:var(--brand-wash);border:1px solid var(--brand);
1290
+ font-size:var(--text-sm);line-height:1.5;color:var(--foreground)}
1291
+ .al-offer button{font-size:var(--text-sm);color:var(--brand);padding:2px 0}
1292
+ .al-offer button:hover{text-decoration:underline}
1293
+ .al-offer button.q{color:var(--muted-foreground);margin-left:auto}
1294
+ .al{padding:16px 18px 40px;max-width:660px}
1295
+ .al-lede{font-size:var(--text-base);line-height:1.5;color:var(--foreground);margin-bottom:5px}
1296
+ .al-sub{font-size:var(--text-sm);line-height:1.6;color:var(--muted-foreground);margin-bottom:16px}
1297
+ .al-c{display:flex;gap:9px;align-items:flex-start;padding:9px 0;border-top:1px solid var(--border);cursor:pointer}
1298
+ .al-c:first-of-type{border-top:0}
1299
+ .al-c input{margin-top:3px;accent-color:var(--primary);width:15px;height:15px;flex:none}
1300
+ .al-p{font-size:var(--text-sm);color:var(--foreground);display:block;word-break:break-all}
1301
+ .al-why{display:block;margin-top:2px;font-size:var(--text-xs);color:var(--muted-foreground);line-height:1.5}
1302
+ .al-acts{display:flex;gap:15px;align-items:center;flex-wrap:wrap;margin-top:14px}
1303
+ .al-acts button{font-size:var(--text-sm);color:var(--brand);padding:3px 0;min-height:30px}
1304
+ .al-acts button:hover{text-decoration:underline}
1305
+ .al-acts button.q{color:var(--muted-foreground)}
1306
+ .al-acts button:disabled{color:var(--muted-foreground);text-decoration:none;cursor:default}
1307
+ .al-note{font-size:var(--text-xs);color:var(--muted-foreground)}
1308
+ .al-d{padding:13px 0;border-top:1px solid var(--border)}
1309
+ .al-d.done{opacity:.45}
1310
+ .al-d.done .al-code,.al-d.done .al-acts{display:none}
1311
+ .al-d.done .al-p::after{content:" · settled";color:var(--success);font-family:var(--sans);font-size:var(--text-xs)}
1312
+ .al-code{margin-top:9px;font-family:var(--mono);font-size:var(--text-xs);line-height:1.75;
1313
+ border-top:1px solid var(--border);border-bottom:1px solid var(--border);padding:6px 0}
1314
+ .al-err{margin-top:9px;padding:8px 11px;border-radius:var(--radius-md);border:1px solid var(--destructive);
1315
+ background:rgba(242,101,95,.08);font-size:var(--text-sm);line-height:1.5;color:var(--foreground)}
@@ -71,6 +71,7 @@ const consoleState = require('./lib/console-state'); // read-only console surf
71
71
  const fmParse = require('./lib/file-mode-parse'); // canonical file-mode parsers, shared with metrics.py (D64 D)
72
72
  const gitInfo = require('./lib/git-info'); // read-mostly branch awareness + select/create at session start
73
73
  const tmuxControl = require('./lib/tmux-control'); // tmux -CC protocol: the pane's raw stream, so xterm.js keeps its scrollback
74
+ const relatedDocs = require('./lib/related-docs'); // which documents a change puts out of step (R5/R6)
74
75
  const applyChange = require('./lib/apply-change'); // anchored + whole-document saves, refuse-on-stale
75
76
  const sessionState = require('./lib/session-state'); // the cadence×turn join — validated on BOTH sides
76
77
  // The "home" repo this server manages. In PMOS-self the app ships INSIDE its repo
@@ -302,6 +303,27 @@ function libraryTree(root) {
302
303
  if (loose.length) roots.push({ name: '(root)', path: '', dirs: [], docs: loose });
303
304
  return roots;
304
305
  }
306
+ // Every markdown document under the root, as repo-relative paths. Used by alignment to decide which
307
+ // documents a change might affect (R6) — it needs a flat list, and libraryTree returns a nested shape
308
+ // for the library lens. Bounded: a repository is not an excuse to read forever.
309
+ function markdownDocs(root, limit = 800) {
310
+ const out = [];
311
+ const walk = (dir, rel, depth) => {
312
+ if (out.length >= limit || depth > 8) return;
313
+ let ents = [];
314
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
315
+ for (const e of ents) {
316
+ if (out.length >= limit) return;
317
+ if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
318
+ const full = path.join(dir, e.name);
319
+ const r = rel ? rel + '/' + e.name : e.name;
320
+ if (e.isDirectory()) walk(full, r, depth + 1);
321
+ else if (preview.previewKind(e.name) === 'markdown') out.push(r);
322
+ }
323
+ };
324
+ walk(root, '', 0);
325
+ return out;
326
+ }
305
327
  // every top-level file (md + non-md), so the folder lens shows the real root, not a curated subset
306
328
  function looseFiles(root) {
307
329
  let ents = [];
@@ -1850,6 +1872,7 @@ async function handleRequest(req, res) {
1850
1872
  // a directory route — a <script src> for a file missing from this list 404s silently, and the
1851
1873
  // client feature that depends on it fails in a way no server-side test can see.
1852
1874
  if (p === '/lib/apply-change.js') return serveFile(res, path.join(__dirname, 'lib', 'apply-change.js'));
1875
+ if (p === '/lib/related-docs.js') return serveFile(res, path.join(__dirname, 'lib', 'related-docs.js'));
1853
1876
  if (p === '/styles.css') return serveFile(res, path.join(PUB, 'styles.css'));
1854
1877
  // xterm assets straight from node_modules (offline; no CDN)
1855
1878
  if (p === '/vendor/xterm.js') return serveFile(res, path.join(XROOT, 'xterm', 'lib', 'xterm.js'));
@@ -2247,12 +2270,156 @@ async function handleRequest(req, res) {
2247
2270
  const sink = path.join(dir, 'doc-changes.jsonl');
2248
2271
  try { fs.mkdirSync(dir, { recursive: true }); fs.appendFileSync(sink, JSON.stringify(rec) + '\n'); logged = sink; }
2249
2272
  catch (e) { warnWriteFail('doc-changes.jsonl (the change log)', e); }
2273
+ writeChangeContext(dir); // R8 — keep the agent-readable digest current
2250
2274
  }
2251
2275
  // The document IS saved at this point. A failed log is reported honestly rather than as success,
2252
2276
  // but it must not read as "your edit was lost".
2253
2277
  return json(res, { ok: true, saved: body.path, logged, change: { added: rec.added, removed: rec.removed, at_line: rec.at_line },
2254
2278
  warning: logged ? undefined : 'the document was saved but the change could not be logged' });
2255
2279
  }
2280
+ if (p === '/api/align/candidates') {
2281
+ // Which documents does the last change to THIS document put out of step? Scoped by the change
2282
+ // (R6): a document qualifies only by a concrete tie — it links here, its frontmatter points
2283
+ // here, it shares the initiative, or it quotes text the change altered. "Feels related" is not a
2284
+ // rule, because that is how this becomes a review queue again.
2285
+ const w = getWs(url.searchParams.get('ws'));
2286
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
2287
+ const rel = String(url.searchParams.get('path') || '');
2288
+ const full = path.resolve(w.root, rel);
2289
+ if (!rel || !insideRoot(w.root, full)) return json(res, { ok: false, error: 'outside workspace' }, 404);
2290
+
2291
+ const dir = stateDirFor(w);
2292
+ let last = null;
2293
+ try {
2294
+ const sink = dir ? path.join(dir, 'doc-changes.jsonl') : null;
2295
+ if (sink && fs.existsSync(sink)) {
2296
+ const rows = fs.readFileSync(sink, 'utf8').split('\n').filter(Boolean)
2297
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } })
2298
+ .filter((r) => r && r.file === rel);
2299
+ last = rows[rows.length - 1] || null;
2300
+ }
2301
+ } catch {}
2302
+ if (!last) return json(res, { ok: true, empty: true, reason: 'no recorded change to this document yet' });
2303
+
2304
+ let changedText = '';
2305
+ try { changedText = fs.readFileSync(full, 'utf8'); } catch {}
2306
+ const docs = [];
2307
+ for (const f of markdownDocs(w.root)) {
2308
+ if (f === rel) continue;
2309
+ const fp = path.resolve(w.root, f);
2310
+ if (!insideRoot(w.root, fp)) continue;
2311
+ try { docs.push({ path: f, text: fs.readFileSync(fp, 'utf8') }); } catch {}
2312
+ }
2313
+ const cands = relatedDocs.candidates({ changedPath: rel, changedText, removed: last.before, docs });
2314
+ return json(res, { ok: true, change: last, candidates: cands.slice(0, 12), considered: docs.length, total: cands.length });
2315
+ }
2316
+ if (p === '/api/align/start' && req.method === 'POST') {
2317
+ // Hand the change and its candidates to an agent, which DRAFTS the realigning edits (R5). The
2318
+ // prompt is assembled here so the absolute paths are facts, not guesses — a tenant product repo
2319
+ // has no workspace/ folder, and the agent must never invent one.
2320
+ let body = {};
2321
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
2322
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
2323
+ const w = getWs(body.ws);
2324
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
2325
+ const rel = String(body.path || '');
2326
+ if (!rel || !insideRoot(w.root, path.resolve(w.root, rel))) return json(res, { ok: false, error: 'outside workspace' }, 404);
2327
+ const targets = (Array.isArray(body.targets) ? body.targets : []).filter((t) => typeof t === 'string').slice(0, 12);
2328
+ if (!targets.length) return json(res, { ok: false, error: 'no documents named to align' }, 400);
2329
+
2330
+ const dir = stateDirFor(w, { create: true });
2331
+ if (!dir) return json(res, { ok: false, error: 'this workspace has no state directory to file drafts into' }, 500);
2332
+ const adir = path.join(dir, 'alignments');
2333
+ try { fs.mkdirSync(adir, { recursive: true }); }
2334
+ catch (e) { warnWriteFail('alignments directory', e); return json(res, { ok: false, error: 'could not create the alignments directory' }, 500); }
2335
+
2336
+ const c = body.change || {};
2337
+ const prompt = [
2338
+ 'A document in this repository was just changed by the PM. Your job is to bring the documents',
2339
+ 'listed below back into line with that change — nothing else.',
2340
+ '',
2341
+ `CHANGED: ${rel} (from line ${c.at_line || '?'})`,
2342
+ '',
2343
+ 'It used to say:', '```', String(c.before || '').slice(0, 4000), '```',
2344
+ 'It now says:', '```', String(c.after || '').slice(0, 4000), '```',
2345
+ c.note ? `The PM's reason: ${c.note}` : '',
2346
+ '',
2347
+ 'BRING THESE INTO LINE — read each, and only these:',
2348
+ ...targets.map((t) => ` - ${t}`),
2349
+ '',
2350
+ 'For each document that genuinely contradicts the change, produce the edit that fixes it.',
2351
+ 'A document that does not actually contradict it gets NO draft — say so and move on. Do not',
2352
+ 'improve, tidy, or critique anything: the only warrant you have is the change above.',
2353
+ '',
2354
+ `Write your drafts as JSON to: ${path.join(adir, 'draft-' + Date.now() + '.json')}`,
2355
+ 'Shape:',
2356
+ '{"source":"' + rel + '","at":"<ISO 8601>","drafts":[',
2357
+ ' {"file":"<path>","why":"<what in this document contradicts the change>",',
2358
+ ' "before":"<verbatim text from that file, appearing EXACTLY once>","after":"<replacement>"}',
2359
+ ']}',
2360
+ '',
2361
+ '`before` must be copied verbatim and must occur exactly once in its file — the apply path',
2362
+ 'refuses a stale or ambiguous anchor rather than guessing. An empty drafts array is a correct',
2363
+ 'and useful answer.',
2364
+ ].filter((l) => l !== '').join('\n');
2365
+
2366
+ const s = spawnSession({ name: 'align: ' + path.basename(rel), agent: 'claude', ws: w.id,
2367
+ origin: 'align:' + rel, initialInput: prompt + '\r' });
2368
+ const started = !!(s && s.pty);
2369
+ return json(res, { ok: started, sessionId: s && s.id, targets, sink: adir,
2370
+ error: started ? undefined : ((s && s.note) || 'could not start the session') });
2371
+ }
2372
+ if (p === '/api/align/drafts') {
2373
+ const w = getWs(url.searchParams.get('ws'));
2374
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
2375
+ const dir = stateDirFor(w);
2376
+ const adir = dir ? path.join(dir, 'alignments') : null;
2377
+ let files = [];
2378
+ try { files = adir ? fs.readdirSync(adir).filter((f) => f.endsWith('.json')).sort() : []; } catch {}
2379
+ if (!files.length) return json(res, { ok: true, empty: true });
2380
+ const pick = files[files.length - 1];
2381
+ let doc;
2382
+ try { doc = JSON.parse(fs.readFileSync(path.join(adir, pick), 'utf8')); }
2383
+ catch { return json(res, { ok: false, error: `the draft file '${pick}' is unreadable or not valid JSON` }, 500); }
2384
+ const drafts = Array.isArray(doc.drafts) ? doc.drafts.filter((d) => d && typeof d.file === 'string') : [];
2385
+ return json(res, { ok: true, id: pick.replace(/\.json$/, ''), source: doc.source || null, at: doc.at || null, drafts });
2386
+ }
2387
+ if (p === '/api/align/apply' && req.method === 'POST') {
2388
+ // Apply ONE drafted realignment. Same anchored guard as everything else that writes here: the
2389
+ // anchor must appear exactly once, or the write is refused rather than guessed at.
2390
+ let body = {};
2391
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
2392
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
2393
+ const w = getWs(body.ws);
2394
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
2395
+ const rel = String(body.file || '');
2396
+ const full = path.resolve(w.root, rel);
2397
+ if (!rel || !insideRoot(w.root, full)) return json(res, { ok: false, error: 'outside workspace' }, 404);
2398
+ if (preview.previewKind(path.basename(full)) !== 'markdown') return json(res, { ok: false, error: 'only markdown documents are editable here' }, 400);
2399
+ let current;
2400
+ try { current = fs.readFileSync(full, 'utf8'); }
2401
+ catch { return json(res, { ok: false, error: 'could not read the document' }, 404); }
2402
+
2403
+ const r = applyChange.applyChange(current, { before: body.before, after: body.after });
2404
+ if (!r.ok) return json(res, { ok: false, error: r.reason, detail: r.detail || null }, 409);
2405
+ try { fs.writeFileSync(full, r.text); }
2406
+ catch (e) { warnWriteFail('alignment apply (' + rel + ')', e); return json(res, { ok: false, error: 'could not write the document' }, 500); }
2407
+
2408
+ // An aligning edit is a change to that document too, and belongs in its log — attributed to the
2409
+ // change that caused it, so the log reads as a chain rather than as unexplained edits.
2410
+ const region = applyChange.changedRegion(body.before, body.after);
2411
+ const dir = stateDirFor(w, { create: true });
2412
+ if (dir) {
2413
+ const rec = { file: rel, at: nowISO(), pm: body.pm || 'wawan',
2414
+ note: 'aligned with ' + (body.source || 'an earlier change') + (body.why ? ' — ' + String(body.why).slice(0, 200) : ''),
2415
+ at_line: region.at_line, added: region.added, removed: region.removed,
2416
+ before: region.before, after: region.after, aligned_from: body.source || null };
2417
+ try { fs.appendFileSync(path.join(dir, 'doc-changes.jsonl'), JSON.stringify(rec) + '\n'); }
2418
+ catch (e) { warnWriteFail('doc-changes.jsonl (alignment)', e); }
2419
+ writeChangeContext(dir);
2420
+ }
2421
+ return json(res, { ok: true, wrote: rel });
2422
+ }
2256
2423
  if (p === '/api/doc/changes') {
2257
2424
  // The change log, read back (R8 — this is what an agent reads before working here).
2258
2425
  const w = getWs(url.searchParams.get('ws'));
@@ -2273,6 +2440,67 @@ async function handleRequest(req, res) {
2273
2440
  res.writeHead(404); res.end('not found');
2274
2441
  }
2275
2442
 
2443
+ // ── R8: the change log, written for an agent to read before it works here ──────────────────────
2444
+ // The jsonl is the record; this is the same material shaped so a coding agent picks it up without
2445
+ // being handed a parser. Regenerated on every save — cheap, and it means the file is never stale.
2446
+ // What makes it worth reading is the PM's own reasons: an agent that knows WHY a line kept getting
2447
+ // rewritten will stop writing the thing that provoked it.
2448
+ function writeChangeContext(dir) {
2449
+ if (!dir) return null;
2450
+ const sink = path.join(dir, 'doc-changes.jsonl');
2451
+ let rows = [];
2452
+ try {
2453
+ if (!fs.existsSync(sink)) return null;
2454
+ rows = fs.readFileSync(sink, 'utf8').split('\n').filter(Boolean)
2455
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
2456
+ } catch { return null; }
2457
+ if (!rows.length) return null;
2458
+
2459
+ const byFile = new Map();
2460
+ for (const r of rows) byFile.set(r.file, (byFile.get(r.file) || 0) + 1);
2461
+ const hot = [...byFile.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
2462
+ const withReasons = rows.filter((r) => r.note).slice(-20).reverse();
2463
+ const recent = rows.slice(-12).reverse();
2464
+
2465
+ const out = [
2466
+ '---', 'type: Reference',
2467
+ 'title: "Change context — what the PM has been editing here, and why"',
2468
+ 'timestamp: ' + nowISO().slice(0, 10), '---', '',
2469
+ '# Change context',
2470
+ '',
2471
+ '**Read this before writing documents in this repository.** It is generated from the PM\'s own',
2472
+ 'edits in the workspace — not a summary of the repo, and not a style guide someone wrote once.',
2473
+ 'If a line below shows the PM rewriting the same kind of thing repeatedly, that is a correction',
2474
+ 'you can avoid provoking again.',
2475
+ '',
2476
+ `Generated ${nowISO()} from ${rows.length} recorded change${rows.length === 1 ? '' : 's'}.`,
2477
+ '',
2478
+ '## Documents changed most often', '',
2479
+ ...hot.map(([f, n]) => `- \`${f}\` — ${n} change${n === 1 ? '' : 's'}`),
2480
+ '',
2481
+ ];
2482
+ if (withReasons.length) {
2483
+ out.push('## Why the PM changed things, in their words', '');
2484
+ for (const r of withReasons) {
2485
+ out.push(`- **${r.file}** — ${r.note}`);
2486
+ if (r.before && r.after) {
2487
+ out.push(' ```', ' - ' + String(r.before).split('\n')[0].slice(0, 160),
2488
+ ' + ' + String(r.after).split('\n')[0].slice(0, 160), ' ```');
2489
+ }
2490
+ }
2491
+ out.push('');
2492
+ }
2493
+ out.push('## Most recent changes', '');
2494
+ for (const r of recent) {
2495
+ out.push(`- \`${r.file}\` · +${r.added} −${r.removed} at line ${r.at_line} · ${String(r.at).slice(0, 16).replace('T', ' ')}` +
2496
+ (r.aligned_from ? ` · aligned with a change to \`${r.aligned_from}\`` : ''));
2497
+ }
2498
+ out.push('');
2499
+ const target = path.join(dir, 'change-context.md');
2500
+ try { fs.writeFileSync(target, out.join('\n')); return target; }
2501
+ catch { return null; }
2502
+ }
2503
+
2276
2504
  // ── Access guard (D62): loopback is token-free; every non-loopback peer needs the bearer token. ──
2277
2505
  // Peer-based: what matters is where the request came FROM, not which listener accepted it. With the
2278
2506
  // remote flag off there is no non-loopback listener, so this is transparent for local dev. Headers