create-pmos 0.9.0 → 0.10.1

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,89 @@ 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.1
337
+
338
+ **Two fixes found by installing 0.10.0 and using it, rather than by reading the code.**
339
+
340
+ - **Upgrades will keep reaching you.** The launcher decides whether to reinstall the workspace by
341
+ comparing the app version it bundles against the one you have. 0.10.0 shipped the *kit* at 0.10.0
342
+ but the bundled app still labelled **0.9.1** — so the next release would have compared equal,
343
+ decided you were current, and **silently skipped the upgrade**. Which is precisely the failure
344
+ 0.9.1 existed to fix. The two versions are now checked against each other by the test suite, so it
345
+ cannot drift again unnoticed.
346
+ - **Alignment no longer offers to edit its own notes.** The change-context digest quotes every change
347
+ you make, so it matched the "quotes text this change altered" rule on the very first save in any
348
+ repository — and topped the list of documents to realign. A generated file proposing to realign
349
+ itself. Machine-written state is now excluded from alignment entirely.
350
+
351
+ If you installed 0.10.0, run `npx create-pmos@latest workspace` once. Nothing was damaged by either
352
+ bug; the first would have cost you a future upgrade, and the second a confusing first suggestion.
353
+
354
+ ## What's new in 0.10.0
355
+
356
+ **Editing a document now does something for the rest of them.**
357
+
358
+ 0.8.0 gave you an editor: change a markdown document in the workspace, see the diff as you type, and
359
+ have the change logged. This release adds the two halves that make that log worth keeping.
360
+
361
+ ### After you save, you are offered what your change puts out of step
362
+
363
+ Not a scan of your repository — a list built from **your change**. A document only appears if it has a
364
+ concrete tie to what you edited:
365
+
366
+ - it links to the document you changed, or names it in frontmatter (`prd_path`, `rubric_path`, …)
367
+ - it shares that document's `initiative_id`
368
+ - it **quotes text your change altered** — the strongest signal, because it is now asserting something
369
+ the source no longer says
370
+
371
+ Each candidate says which of those it is, so you can judge the suggestion before spending anything on
372
+ it. **"Seems related" is deliberately not a rule.** It would widen this to your whole repository and
373
+ put you back in front of a queue of an agent's opinions.
374
+
375
+ You tick which documents an agent may read. It drafts the edit that brings each back in line, and each
376
+ draft comes back as a diff to apply or skip. Applying uses the same guard as every other write here:
377
+ the text being replaced must appear exactly once, or the write is refused rather than guessed at.
378
+
379
+ **It never blocks.** The save completed before any of this. Dismiss the offer and nothing is queued,
380
+ nothing badges, nothing waits for you.
381
+
382
+ ### Your change log is now something an agent reads
383
+
384
+ Every save regenerates `change-context.md` in your repository's own state directory: the documents you
385
+ change most, the recent changes, and — the part that matters — **your own stated reasons**, in your
386
+ words. An agent reading it before working in your repo knows what you keep rewriting and why, so it
387
+ can stop producing the thing that provoked the rewrite.
388
+
389
+ That is why the editor asks for a reason at all. A reason nobody reads is just friction at save time.
390
+
391
+ Edits made by applying an alignment record **what they were aligned with**, so the log reads as a
392
+ chain rather than as unexplained changes appearing in files you did not open.
393
+
394
+ ### Also carried: the 0.9.1 upgrade fix
395
+
396
+ **0.9.1 was never published.** Its fix — `create-pmos workspace` self-upgrading instead of quietly
397
+ launching your old installed app — is in this release. If the workspace has looked unchanged across
398
+ recent versions, that is why; run `npx create-pmos@latest workspace` once and it will self-heal onto
399
+ the current app. Hard-refresh the browser afterwards to drop the cached stylesheet.
400
+
401
+ ## What's new in 0.9.1
402
+
403
+ **A fix so workspace upgrades actually reach you.** If you activated the Terminal Workspace in an
404
+ earlier version, `npx create-pmos@latest workspace` was quietly launching your *old* installed app —
405
+ so 0.9.0's restyle and whose-turn view never showed up. That was the bug, not a missing feature.
406
+
407
+ - **`create-pmos workspace` now self-upgrades.** It compares the bundled app's version against what
408
+ you have installed and **reinstalls when the bundle is newer** (then restarts the persistent server
409
+ so the new code actually serves). A same-version install still fast-launches — no needless
410
+ ~20-second native rebuild.
411
+ - **`create-pmos workspace --reinstall`** — force it any time.
412
+ - **`pmos upgrade`** — the same, straight from the installed command.
413
+ - If the UI still looks unchanged right after an upgrade, **hard-refresh the browser**
414
+ (Cmd/Ctrl+Shift+R) to drop the cached stylesheet.
415
+
416
+ So if 0.9.0 looked like "not much changed," this is why: the code shipped, but your old install kept
417
+ running. Run `npx create-pmos@latest workspace` once and it will self-heal onto the current app.
418
+
336
419
  ## What's new in 0.9.0
337
420
 
338
421
  **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.1",
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@84f5b38
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
@@ -163,9 +163,25 @@
163
163
  return { layouts, currentId: DEFAULT_ID };
164
164
  }
165
165
 
166
+ /* What a GET /api/layouts response entitles the client to DO. Split out as a pure function
167
+ because the original inline condition conflated two very different states — "this workspace
168
+ has never stored a layout" and "I could not read what it stored" — and answered both by
169
+ migrating and then WRITING, silently replacing a real stored document whenever a read failed.
170
+ A read failure must never author a write.
171
+
172
+ 'migrate' — the server affirmatively says nothing is stored (first run here): R8 applies.
173
+ 'use' — a stored document came back: adopt it.
174
+ 'keep' — the answer is unusable (transport error, non-JSON, !ok, or no `stored` flag).
175
+ Hold the boot defaults in memory and WRITE NOTHING. */
176
+ function loadAction(doc) {
177
+ if (!doc || doc.ok !== true || typeof doc.stored !== 'boolean') return 'keep';
178
+ return doc.stored ? 'use' : 'migrate';
179
+ }
180
+
166
181
  return {
167
182
  SIDE, READER, SPLIT, FOLD_KEYS, STOCK_IDS, DEFAULT_ID,
168
183
  SIDE_MIN, SIDE_MAX, READ_MIN, READ_MAX, BOTTOM_H,
169
184
  isStock, stockLayout, stockLayouts, layout, sanitize, gridFor, hasEscape, migrateLegacy,
185
+ loadAction,
170
186
  };
171
187
  });
@@ -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,17 +1,21 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.1.0",
3
+ "version": "0.10.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",
7
- "bin": { "pmos": "bin/pmos.js" },
7
+ "bin": {
8
+ "pmos": "bin/pmos.js"
9
+ },
8
10
  "scripts": {
9
11
  "start": "node server.js",
10
12
  "test": "node test.js",
11
13
  "test:ui": "node ui-test.js",
12
14
  "doctor": "node doctor.js"
13
15
  },
14
- "engines": { "node": ">=18" },
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
15
19
  "dependencies": {
16
20
  "@xterm/addon-fit": "^0.11.0",
17
21
  "@xterm/addon-web-links": "^0.12.0",
@@ -1059,7 +1059,13 @@ async function loadLayouts() {
1059
1059
  const r = await fetch('/api/layouts?ws=' + encodeURIComponent(state.wsId || ''));
1060
1060
  doc = await r.json();
1061
1061
  } catch { doc = null; }
1062
- if (!doc || !doc.ok || !Array.isArray(doc.layouts) || !doc.layouts.length) {
1062
+ // A read failure must never author a write. loadAction() is the one place that decides, and it
1063
+ // only says 'migrate' when the SERVER affirmatively reports nothing stored — never because the
1064
+ // answer was unreadable. Anything unusable is 'keep': show the boot defaults, persist nothing,
1065
+ // and leave whatever is on disk exactly as it is until a load succeeds.
1066
+ const act = LAY.loadAction(doc);
1067
+ if (act === 'keep') return;
1068
+ if (act === 'migrate') {
1063
1069
  // R8 — a workspace with nothing stored inherits whatever the PM had already tuned by hand
1064
1070
  // in the pre-feature client, so the upgrade never costs them their rail width.
1065
1071
  const legacy = {};
@@ -2070,10 +2076,12 @@ function edRender() {
2070
2076
  $('edDiff').innerHTML = rows.join('') || '<div class="ed-none">whitespace only</div>';
2071
2077
  }
2072
2078
 
2073
- function edClose(reload) {
2079
+ async function edClose(reload) {
2074
2080
  ed.on = false; ed.path = null; ed.base = ''; ed.dirty = false;
2075
2081
  edControls();
2076
- if (reload && state.docPath) openDoc(state.docPath);
2082
+ // awaited: the reload replaces docBody, so anything that renders into it afterwards (the
2083
+ // alignment offer) must not race the replacement.
2084
+ if (reload && state.docPath) await openDoc(state.docPath);
2077
2085
  }
2078
2086
 
2079
2087
  async function edSave() {
@@ -2098,7 +2106,120 @@ async function edSave() {
2098
2106
  return;
2099
2107
  }
2100
2108
  flash(out.warning ? 'saved — but the change could not be logged' : `saved · +${out.change.added} −${out.change.removed}`);
2101
- edClose(true);
2109
+ const saved = ed.path;
2110
+ await edClose(true);
2111
+ alOffer(saved); // R7: offered, never blocking — the save is already done
2112
+ }
2113
+
2114
+
2115
+ // ── alignment (workspace-doc-editor R5–R7) ────────────────────────────────────────────────────
2116
+ // Strictly downstream of a save. Nothing here runs until the PM has changed something, and what it
2117
+ // looks at is scoped by that change — not the repository. It is offered, never blocking: the save
2118
+ // completed before any of this, and ignoring it costs nothing.
2119
+ async function alOffer(pth) {
2120
+ if (!pth) return;
2121
+ let out = {};
2122
+ try { out = await (await fetch(`/api/align/candidates?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(pth)}`)).json(); }
2123
+ catch { return; }
2124
+ if (!out.ok || out.empty || !out.candidates || !out.candidates.length) return;
2125
+ state.alCand = out;
2126
+ const n = out.candidates.length;
2127
+ const bar = document.createElement('div');
2128
+ bar.className = 'al-offer';
2129
+ bar.innerHTML = `<span>${n} other document${n === 1 ? '' : 's'} may now be out of step with that change.</span>
2130
+ <button data-al-see>see them</button><button class="q" data-al-no>not now</button>`;
2131
+ const body = $('docBody');
2132
+ body.prepend(bar);
2133
+ bar.querySelector('[data-al-no]').onclick = () => bar.remove();
2134
+ bar.querySelector('[data-al-see]').onclick = () => alShow();
2135
+ }
2136
+
2137
+ // The candidate list, each with the concrete reason it qualified. The PM chooses which to hand to
2138
+ // an agent — so even the drafting step is something they asked for, not something that happened.
2139
+ function alShow() {
2140
+ const c = state.alCand; if (!c) return;
2141
+ $('docBody').innerHTML = `<div class="al">
2142
+ <div class="al-lede">Your change to <span class="mono">${esc(c.change.file)}</span> may leave these out of step.</div>
2143
+ <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>
2144
+ ${c.candidates.map((x, i) => `<label class="al-c">
2145
+ <input type="checkbox" data-al-pick="${i}" checked />
2146
+ <span><span class="al-p mono">${esc(x.path)}</span>
2147
+ <span class="al-why">${x.reasons.map((r) => esc(r)).join(' · ')}</span></span>
2148
+ </label>`).join('')}
2149
+ <div class="al-acts">
2150
+ <button data-al-go>Draft the edits</button>
2151
+ <button class="q" data-al-close>Close</button>
2152
+ <span class="al-note">An agent reads only the documents you tick, and only against this change.</span>
2153
+ </div></div>`;
2154
+ $('docBody').querySelector('[data-al-close]').onclick = () => openDoc(c.change.file);
2155
+ $('docBody').querySelector('[data-al-go]').onclick = async () => {
2156
+ const targets = [...$('docBody').querySelectorAll('[data-al-pick]')]
2157
+ .filter((b) => b.checked).map((b) => c.candidates[Number(b.dataset.alPick)].path);
2158
+ if (!targets.length) { flash('nothing ticked'); return; }
2159
+ let out = {};
2160
+ try {
2161
+ out = await (await fetch('/api/align/start', { method: 'POST', headers: { 'Content-Type': 'application/json' },
2162
+ body: JSON.stringify({ ws: state.wsId, path: c.change.file, change: c.change, targets }) })).json();
2163
+ } catch { out = { ok: false, error: 'could not reach the workspace server' }; }
2164
+ if (!out.ok) { flash(out.error || 'could not start'); return; }
2165
+ flash(`drafting against ${targets.length} document${targets.length === 1 ? '' : 's'} — watch the session, then open drafts`);
2166
+ if (out.sessionId) selectSession(out.sessionId);
2167
+ };
2168
+ }
2169
+
2170
+ // The drafts an agent produced, each an applyable diff. Apply uses the same anchored guard as every
2171
+ // other write here: the anchor must appear exactly once or the write is refused.
2172
+ async function alDrafts() {
2173
+ let out = {};
2174
+ try { out = await (await fetch(`/api/align/drafts?ws=${encodeURIComponent(state.wsId || '')}`)).json(); }
2175
+ catch { out = { ok: false, error: 'could not reach the workspace server' }; }
2176
+ showRail(); $('paneDoc').hidden = false; foldPane('doc', true);
2177
+ $('crumb').innerHTML = '<bdi>alignment drafts</bdi>';
2178
+ if (!out.ok) { $('docBody').innerHTML = `<div class="al"><div class="al-lede">${esc(out.error || 'could not read the drafts')}</div></div>`; return; }
2179
+ if (out.empty || !out.drafts.length) {
2180
+ $('docBody').innerHTML = `<div class="al"><div class="al-lede">No drafts yet.</div>
2181
+ <div class="al-sub">Edit a document, then ask for the alignment when it offers.</div></div>`;
2182
+ return;
2183
+ }
2184
+ state.alDrafts = out;
2185
+ $('docBody').innerHTML = `<div class="al">
2186
+ <div class="al-lede">${out.drafts.length} edit${out.drafts.length === 1 ? '' : 's'} to bring other documents back in line.</div>
2187
+ <div class="al-sub">Drafted against your change to <span class="mono">${esc(out.source || '')}</span>. Nothing is written until you apply it.</div>
2188
+ ${out.drafts.map((d, i) => `<div class="al-d" data-al-d="${i}">
2189
+ <div class="al-p mono">${esc(d.file)}</div>
2190
+ <div class="al-why">${esc(d.why || '')}</div>
2191
+ <div class="al-code">
2192
+ ${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('')}
2193
+ ${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('')}
2194
+ </div>
2195
+ <div class="al-acts"><button data-al-apply="${i}">Apply</button>
2196
+ <button class="q" data-al-skip="${i}">Skip</button></div>
2197
+ </div>`).join('')}</div>`;
2198
+ $('docBody').querySelectorAll('[data-al-skip]').forEach((b) => b.onclick = () => {
2199
+ $('docBody').querySelector(`[data-al-d="${b.dataset.alSkip}"]`).classList.add('done');
2200
+ });
2201
+ $('docBody').querySelectorAll('[data-al-apply]').forEach((b) => b.onclick = async () => {
2202
+ const d = state.alDrafts.drafts[Number(b.dataset.alApply)];
2203
+ b.disabled = true;
2204
+ let r = {};
2205
+ try {
2206
+ r = await (await fetch('/api/align/apply', { method: 'POST', headers: { 'Content-Type': 'application/json' },
2207
+ body: JSON.stringify({ ws: state.wsId, file: d.file, before: d.before, after: d.after, why: d.why, source: state.alDrafts.source }) })).json();
2208
+ } catch { r = { ok: false, error: 'could not reach the workspace server' }; }
2209
+ b.disabled = false;
2210
+ const card = $('docBody').querySelector(`[data-al-d="${b.dataset.alApply}"]`);
2211
+ if (!r.ok) {
2212
+ let e = card.querySelector('.al-err');
2213
+ if (!e) { e = document.createElement('div'); e.className = 'al-err'; card.appendChild(e); }
2214
+ e.textContent = r.error === 'stale' ? 'that text is no longer in the file — the draft is out of date'
2215
+ : r.error === 'ambiguous' ? (r.detail || 'that text appears more than once — refusing to guess')
2216
+ : (r.detail || r.error || 'refused');
2217
+ return;
2218
+ }
2219
+ card.classList.add('done');
2220
+ flash('aligned ' + d.file.split('/').pop());
2221
+ });
2222
+ refit();
2102
2223
  }
2103
2224
 
2104
2225
  $('docEdit').onclick = edOpen;
@@ -2602,6 +2723,7 @@ function openPalette() {
2602
2723
  const wss = state.workspaces.filter((w) => w.id !== state.wsId).map((w) => ({ kind: 'workspace', label: w.name, wsid: w.id }));
2603
2724
  // summon a P/D/M/R surface on demand — listed first so "initiatives"/"roadmap"/"okr"/"report" are reachable
2604
2725
  const views = SURFACES.map((v) => ({ kind: 'view', label: v.label, view: v.name }));
2726
+ views.push({ kind: 'align', label: 'alignment drafts' }); // downstream of a save, never resident
2605
2727
  palItems = [...views, ...wss, ...sess, ...inits, ...docs];
2606
2728
  $('palette').style.display = 'grid';
2607
2729
  const inp = $('palInput'); inp.value = ''; renderPalette('');
@@ -2627,6 +2749,7 @@ function choosePalette(n) {
2627
2749
  const it = items[n]; if (!it) return;
2628
2750
  closePalette();
2629
2751
  if (it.kind === 'view') openSurface(it.view);
2752
+ else if (it.kind === 'align') alDrafts();
2630
2753
  else if (it.kind === 'session') selectSession(it.id);
2631
2754
  else if (it.kind === 'workspace') selectWorkspace(it.wsid);
2632
2755
  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
@@ -224,6 +225,9 @@ function wsList() {
224
225
  }));
225
226
  }
226
227
  const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'vendor', 'target', 'coverage', '.git', 'tmp', 'out']);
228
+ // The directories stateDirFor() resolves to. Their contents are machine-written runtime and curated
229
+ // records — never documents alignment should offer to edit.
230
+ const STATE_DIR_NAMES = new Set(['state', '.pmos']);
227
231
  const PLAIN_MAX_DEPTH = 3; // the curated (knowledge) view stays shallow
228
232
  const TREE_MAX_DEPTH = 6; // the folder lens is a real explorer, but bounded on huge/slow repos
229
233
  const TREE_MAX_NODES = 4000; // …and node-budgeted so a giant repo (or Google Drive) never hangs
@@ -302,6 +306,32 @@ function libraryTree(root) {
302
306
  if (loose.length) roots.push({ name: '(root)', path: '', dirs: [], docs: loose });
303
307
  return roots;
304
308
  }
309
+ // Every markdown document under the root, as repo-relative paths. Used by alignment to decide which
310
+ // documents a change might affect (R6) — it needs a flat list, and libraryTree returns a nested shape
311
+ // for the library lens. Bounded: a repository is not an excuse to read forever.
312
+ function markdownDocs(root, limit = 800) {
313
+ const out = [];
314
+ const walk = (dir, rel, depth) => {
315
+ if (out.length >= limit || depth > 8) return;
316
+ let ents = [];
317
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
318
+ for (const e of ents) {
319
+ if (out.length >= limit) return;
320
+ if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
321
+ const full = path.join(dir, e.name);
322
+ const r = rel ? rel + '/' + e.name : e.name;
323
+ // Machine-written state is not a document the PM maintains, and alignment must never propose
324
+ // realigning it. change-context.md in particular QUOTES every change, so it matches the
325
+ // quoted-text rule on the first save in any repository and would top the candidate list —
326
+ // a generated file suggesting itself, found by installing the published artifact and using it.
327
+ if (STATE_DIR_NAMES.has(e.name)) continue;
328
+ if (e.isDirectory()) walk(full, r, depth + 1);
329
+ else if (preview.previewKind(e.name) === 'markdown') out.push(r);
330
+ }
331
+ };
332
+ walk(root, '', 0);
333
+ return out;
334
+ }
305
335
  // every top-level file (md + non-md), so the folder lens shows the real root, not a curated subset
306
336
  function looseFiles(root) {
307
337
  let ents = [];
@@ -1488,11 +1518,17 @@ const LAYOUT_MAX = 40_000;
1488
1518
  function layoutPath(w) { return w ? path.join(LAYOUT_DIR, w.id + '.json') : null; }
1489
1519
  // Always returns a usable document: an absent, empty, truncated or hand-mangled file yields the
1490
1520
  // four stock layouts rather than an error, because the alternative is a window the PM cannot use.
1521
+ // `stored` is the client's ONLY safe signal that R8's one-time migration should run. sanitize()
1522
+ // always yields the four stock layouts, so the response body alone cannot distinguish "this
1523
+ // workspace has never stored anything" from "I could not read what it stored" — and the client
1524
+ // answers those two cases very differently (one writes, one must not). A file that exists but is
1525
+ // corrupt still counts as stored: this workspace has been through the layout system, so replaying
1526
+ // the pre-feature localStorage over it would be a second loss, not a recovery.
1491
1527
  function readLayouts(w) {
1492
1528
  const f = layoutPath(w);
1493
1529
  let raw = null;
1494
1530
  if (f) { try { raw = fs.readFileSync(f, 'utf8'); } catch { raw = null; } }
1495
- return layoutModel.sanitize(raw);
1531
+ return Object.assign(layoutModel.sanitize(raw), { stored: raw !== null });
1496
1532
  }
1497
1533
  function writeLayouts(w, doc) {
1498
1534
  const f = layoutPath(w);
@@ -1850,6 +1886,7 @@ async function handleRequest(req, res) {
1850
1886
  // a directory route — a <script src> for a file missing from this list 404s silently, and the
1851
1887
  // client feature that depends on it fails in a way no server-side test can see.
1852
1888
  if (p === '/lib/apply-change.js') return serveFile(res, path.join(__dirname, 'lib', 'apply-change.js'));
1889
+ if (p === '/lib/related-docs.js') return serveFile(res, path.join(__dirname, 'lib', 'related-docs.js'));
1853
1890
  if (p === '/styles.css') return serveFile(res, path.join(PUB, 'styles.css'));
1854
1891
  // xterm assets straight from node_modules (offline; no CDN)
1855
1892
  if (p === '/vendor/xterm.js') return serveFile(res, path.join(XROOT, 'xterm', 'lib', 'xterm.js'));
@@ -2099,7 +2136,7 @@ async function handleRequest(req, res) {
2099
2136
  const w = getWs(url.searchParams.get('ws'));
2100
2137
  if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
2101
2138
  const doc = readLayouts(w);
2102
- return json(res, { ok: true, layouts: doc.layouts, currentId: doc.currentId });
2139
+ return json(res, { ok: true, layouts: doc.layouts, currentId: doc.currentId, stored: doc.stored });
2103
2140
  }
2104
2141
  if (p === '/api/doc') {
2105
2142
  const w = getWs(url.searchParams.get('ws'));
@@ -2247,12 +2284,156 @@ async function handleRequest(req, res) {
2247
2284
  const sink = path.join(dir, 'doc-changes.jsonl');
2248
2285
  try { fs.mkdirSync(dir, { recursive: true }); fs.appendFileSync(sink, JSON.stringify(rec) + '\n'); logged = sink; }
2249
2286
  catch (e) { warnWriteFail('doc-changes.jsonl (the change log)', e); }
2287
+ writeChangeContext(dir); // R8 — keep the agent-readable digest current
2250
2288
  }
2251
2289
  // The document IS saved at this point. A failed log is reported honestly rather than as success,
2252
2290
  // but it must not read as "your edit was lost".
2253
2291
  return json(res, { ok: true, saved: body.path, logged, change: { added: rec.added, removed: rec.removed, at_line: rec.at_line },
2254
2292
  warning: logged ? undefined : 'the document was saved but the change could not be logged' });
2255
2293
  }
2294
+ if (p === '/api/align/candidates') {
2295
+ // Which documents does the last change to THIS document put out of step? Scoped by the change
2296
+ // (R6): a document qualifies only by a concrete tie — it links here, its frontmatter points
2297
+ // here, it shares the initiative, or it quotes text the change altered. "Feels related" is not a
2298
+ // rule, because that is how this becomes a review queue again.
2299
+ const w = getWs(url.searchParams.get('ws'));
2300
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
2301
+ const rel = String(url.searchParams.get('path') || '');
2302
+ const full = path.resolve(w.root, rel);
2303
+ if (!rel || !insideRoot(w.root, full)) return json(res, { ok: false, error: 'outside workspace' }, 404);
2304
+
2305
+ const dir = stateDirFor(w);
2306
+ let last = null;
2307
+ try {
2308
+ const sink = dir ? path.join(dir, 'doc-changes.jsonl') : null;
2309
+ if (sink && fs.existsSync(sink)) {
2310
+ const rows = fs.readFileSync(sink, 'utf8').split('\n').filter(Boolean)
2311
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } })
2312
+ .filter((r) => r && r.file === rel);
2313
+ last = rows[rows.length - 1] || null;
2314
+ }
2315
+ } catch {}
2316
+ if (!last) return json(res, { ok: true, empty: true, reason: 'no recorded change to this document yet' });
2317
+
2318
+ let changedText = '';
2319
+ try { changedText = fs.readFileSync(full, 'utf8'); } catch {}
2320
+ const docs = [];
2321
+ for (const f of markdownDocs(w.root)) {
2322
+ if (f === rel) continue;
2323
+ const fp = path.resolve(w.root, f);
2324
+ if (!insideRoot(w.root, fp)) continue;
2325
+ try { docs.push({ path: f, text: fs.readFileSync(fp, 'utf8') }); } catch {}
2326
+ }
2327
+ const cands = relatedDocs.candidates({ changedPath: rel, changedText, removed: last.before, docs });
2328
+ return json(res, { ok: true, change: last, candidates: cands.slice(0, 12), considered: docs.length, total: cands.length });
2329
+ }
2330
+ if (p === '/api/align/start' && req.method === 'POST') {
2331
+ // Hand the change and its candidates to an agent, which DRAFTS the realigning edits (R5). The
2332
+ // prompt is assembled here so the absolute paths are facts, not guesses — a tenant product repo
2333
+ // has no workspace/ folder, and the agent must never invent one.
2334
+ let body = {};
2335
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
2336
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
2337
+ const w = getWs(body.ws);
2338
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
2339
+ const rel = String(body.path || '');
2340
+ if (!rel || !insideRoot(w.root, path.resolve(w.root, rel))) return json(res, { ok: false, error: 'outside workspace' }, 404);
2341
+ const targets = (Array.isArray(body.targets) ? body.targets : []).filter((t) => typeof t === 'string').slice(0, 12);
2342
+ if (!targets.length) return json(res, { ok: false, error: 'no documents named to align' }, 400);
2343
+
2344
+ const dir = stateDirFor(w, { create: true });
2345
+ if (!dir) return json(res, { ok: false, error: 'this workspace has no state directory to file drafts into' }, 500);
2346
+ const adir = path.join(dir, 'alignments');
2347
+ try { fs.mkdirSync(adir, { recursive: true }); }
2348
+ catch (e) { warnWriteFail('alignments directory', e); return json(res, { ok: false, error: 'could not create the alignments directory' }, 500); }
2349
+
2350
+ const c = body.change || {};
2351
+ const prompt = [
2352
+ 'A document in this repository was just changed by the PM. Your job is to bring the documents',
2353
+ 'listed below back into line with that change — nothing else.',
2354
+ '',
2355
+ `CHANGED: ${rel} (from line ${c.at_line || '?'})`,
2356
+ '',
2357
+ 'It used to say:', '```', String(c.before || '').slice(0, 4000), '```',
2358
+ 'It now says:', '```', String(c.after || '').slice(0, 4000), '```',
2359
+ c.note ? `The PM's reason: ${c.note}` : '',
2360
+ '',
2361
+ 'BRING THESE INTO LINE — read each, and only these:',
2362
+ ...targets.map((t) => ` - ${t}`),
2363
+ '',
2364
+ 'For each document that genuinely contradicts the change, produce the edit that fixes it.',
2365
+ 'A document that does not actually contradict it gets NO draft — say so and move on. Do not',
2366
+ 'improve, tidy, or critique anything: the only warrant you have is the change above.',
2367
+ '',
2368
+ `Write your drafts as JSON to: ${path.join(adir, 'draft-' + Date.now() + '.json')}`,
2369
+ 'Shape:',
2370
+ '{"source":"' + rel + '","at":"<ISO 8601>","drafts":[',
2371
+ ' {"file":"<path>","why":"<what in this document contradicts the change>",',
2372
+ ' "before":"<verbatim text from that file, appearing EXACTLY once>","after":"<replacement>"}',
2373
+ ']}',
2374
+ '',
2375
+ '`before` must be copied verbatim and must occur exactly once in its file — the apply path',
2376
+ 'refuses a stale or ambiguous anchor rather than guessing. An empty drafts array is a correct',
2377
+ 'and useful answer.',
2378
+ ].filter((l) => l !== '').join('\n');
2379
+
2380
+ const s = spawnSession({ name: 'align: ' + path.basename(rel), agent: 'claude', ws: w.id,
2381
+ origin: 'align:' + rel, initialInput: prompt + '\r' });
2382
+ const started = !!(s && s.pty);
2383
+ return json(res, { ok: started, sessionId: s && s.id, targets, sink: adir,
2384
+ error: started ? undefined : ((s && s.note) || 'could not start the session') });
2385
+ }
2386
+ if (p === '/api/align/drafts') {
2387
+ const w = getWs(url.searchParams.get('ws'));
2388
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
2389
+ const dir = stateDirFor(w);
2390
+ const adir = dir ? path.join(dir, 'alignments') : null;
2391
+ let files = [];
2392
+ try { files = adir ? fs.readdirSync(adir).filter((f) => f.endsWith('.json')).sort() : []; } catch {}
2393
+ if (!files.length) return json(res, { ok: true, empty: true });
2394
+ const pick = files[files.length - 1];
2395
+ let doc;
2396
+ try { doc = JSON.parse(fs.readFileSync(path.join(adir, pick), 'utf8')); }
2397
+ catch { return json(res, { ok: false, error: `the draft file '${pick}' is unreadable or not valid JSON` }, 500); }
2398
+ const drafts = Array.isArray(doc.drafts) ? doc.drafts.filter((d) => d && typeof d.file === 'string') : [];
2399
+ return json(res, { ok: true, id: pick.replace(/\.json$/, ''), source: doc.source || null, at: doc.at || null, drafts });
2400
+ }
2401
+ if (p === '/api/align/apply' && req.method === 'POST') {
2402
+ // Apply ONE drafted realignment. Same anchored guard as everything else that writes here: the
2403
+ // anchor must appear exactly once, or the write is refused rather than guessed at.
2404
+ let body = {};
2405
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
2406
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
2407
+ const w = getWs(body.ws);
2408
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
2409
+ const rel = String(body.file || '');
2410
+ const full = path.resolve(w.root, rel);
2411
+ if (!rel || !insideRoot(w.root, full)) return json(res, { ok: false, error: 'outside workspace' }, 404);
2412
+ if (preview.previewKind(path.basename(full)) !== 'markdown') return json(res, { ok: false, error: 'only markdown documents are editable here' }, 400);
2413
+ let current;
2414
+ try { current = fs.readFileSync(full, 'utf8'); }
2415
+ catch { return json(res, { ok: false, error: 'could not read the document' }, 404); }
2416
+
2417
+ const r = applyChange.applyChange(current, { before: body.before, after: body.after });
2418
+ if (!r.ok) return json(res, { ok: false, error: r.reason, detail: r.detail || null }, 409);
2419
+ try { fs.writeFileSync(full, r.text); }
2420
+ catch (e) { warnWriteFail('alignment apply (' + rel + ')', e); return json(res, { ok: false, error: 'could not write the document' }, 500); }
2421
+
2422
+ // An aligning edit is a change to that document too, and belongs in its log — attributed to the
2423
+ // change that caused it, so the log reads as a chain rather than as unexplained edits.
2424
+ const region = applyChange.changedRegion(body.before, body.after);
2425
+ const dir = stateDirFor(w, { create: true });
2426
+ if (dir) {
2427
+ const rec = { file: rel, at: nowISO(), pm: body.pm || 'wawan',
2428
+ note: 'aligned with ' + (body.source || 'an earlier change') + (body.why ? ' — ' + String(body.why).slice(0, 200) : ''),
2429
+ at_line: region.at_line, added: region.added, removed: region.removed,
2430
+ before: region.before, after: region.after, aligned_from: body.source || null };
2431
+ try { fs.appendFileSync(path.join(dir, 'doc-changes.jsonl'), JSON.stringify(rec) + '\n'); }
2432
+ catch (e) { warnWriteFail('doc-changes.jsonl (alignment)', e); }
2433
+ writeChangeContext(dir);
2434
+ }
2435
+ return json(res, { ok: true, wrote: rel });
2436
+ }
2256
2437
  if (p === '/api/doc/changes') {
2257
2438
  // The change log, read back (R8 — this is what an agent reads before working here).
2258
2439
  const w = getWs(url.searchParams.get('ws'));
@@ -2273,6 +2454,67 @@ async function handleRequest(req, res) {
2273
2454
  res.writeHead(404); res.end('not found');
2274
2455
  }
2275
2456
 
2457
+ // ── R8: the change log, written for an agent to read before it works here ──────────────────────
2458
+ // The jsonl is the record; this is the same material shaped so a coding agent picks it up without
2459
+ // being handed a parser. Regenerated on every save — cheap, and it means the file is never stale.
2460
+ // What makes it worth reading is the PM's own reasons: an agent that knows WHY a line kept getting
2461
+ // rewritten will stop writing the thing that provoked it.
2462
+ function writeChangeContext(dir) {
2463
+ if (!dir) return null;
2464
+ const sink = path.join(dir, 'doc-changes.jsonl');
2465
+ let rows = [];
2466
+ try {
2467
+ if (!fs.existsSync(sink)) return null;
2468
+ rows = fs.readFileSync(sink, 'utf8').split('\n').filter(Boolean)
2469
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
2470
+ } catch { return null; }
2471
+ if (!rows.length) return null;
2472
+
2473
+ const byFile = new Map();
2474
+ for (const r of rows) byFile.set(r.file, (byFile.get(r.file) || 0) + 1);
2475
+ const hot = [...byFile.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
2476
+ const withReasons = rows.filter((r) => r.note).slice(-20).reverse();
2477
+ const recent = rows.slice(-12).reverse();
2478
+
2479
+ const out = [
2480
+ '---', 'type: Reference',
2481
+ 'title: "Change context — what the PM has been editing here, and why"',
2482
+ 'timestamp: ' + nowISO().slice(0, 10), '---', '',
2483
+ '# Change context',
2484
+ '',
2485
+ '**Read this before writing documents in this repository.** It is generated from the PM\'s own',
2486
+ 'edits in the workspace — not a summary of the repo, and not a style guide someone wrote once.',
2487
+ 'If a line below shows the PM rewriting the same kind of thing repeatedly, that is a correction',
2488
+ 'you can avoid provoking again.',
2489
+ '',
2490
+ `Generated ${nowISO()} from ${rows.length} recorded change${rows.length === 1 ? '' : 's'}.`,
2491
+ '',
2492
+ '## Documents changed most often', '',
2493
+ ...hot.map(([f, n]) => `- \`${f}\` — ${n} change${n === 1 ? '' : 's'}`),
2494
+ '',
2495
+ ];
2496
+ if (withReasons.length) {
2497
+ out.push('## Why the PM changed things, in their words', '');
2498
+ for (const r of withReasons) {
2499
+ out.push(`- **${r.file}** — ${r.note}`);
2500
+ if (r.before && r.after) {
2501
+ out.push(' ```', ' - ' + String(r.before).split('\n')[0].slice(0, 160),
2502
+ ' + ' + String(r.after).split('\n')[0].slice(0, 160), ' ```');
2503
+ }
2504
+ }
2505
+ out.push('');
2506
+ }
2507
+ out.push('## Most recent changes', '');
2508
+ for (const r of recent) {
2509
+ out.push(`- \`${r.file}\` · +${r.added} −${r.removed} at line ${r.at_line} · ${String(r.at).slice(0, 16).replace('T', ' ')}` +
2510
+ (r.aligned_from ? ` · aligned with a change to \`${r.aligned_from}\`` : ''));
2511
+ }
2512
+ out.push('');
2513
+ const target = path.join(dir, 'change-context.md');
2514
+ try { fs.writeFileSync(target, out.join('\n')); return target; }
2515
+ catch { return null; }
2516
+ }
2517
+
2276
2518
  // ── Access guard (D62): loopback is token-free; every non-loopback peer needs the bearer token. ──
2277
2519
  // Peer-based: what matters is where the request came FROM, not which listener accepted it. With the
2278
2520
  // remote flag off there is no non-loopback listener, so this is transparent for local dev. Headers