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 +83 -0
- package/bin/create-pmos.js +69 -11
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/workspace/bin/pmos.js +9 -2
- package/workspace/lib/layout.js +16 -0
- package/workspace/lib/related-docs.js +107 -0
- package/workspace/package.json +7 -3
- package/workspace/public/app.js +127 -4
- package/workspace/public/styles.css +34 -0
- package/workspace/server.js +244 -2
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
|
package/bin/create-pmos.js
CHANGED
|
@@ -140,24 +140,81 @@ function launchWorkspace(targetDir, viaGlobal) {
|
|
|
140
140
|
{ stdio: 'inherit', env: process.env }).status || 0;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
|
|
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?
|
|
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
|
-
|
|
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
|
|
155
|
-
|
|
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.
|
|
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": {
|
package/payload/KIT-VERSION
CHANGED
package/workspace/bin/pmos.js
CHANGED
|
@@ -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 === '
|
|
215
|
-
|
|
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
|
package/workspace/lib/layout.js
CHANGED
|
@@ -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
|
+
});
|
package/workspace/package.json
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.1
|
|
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": {
|
|
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": {
|
|
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",
|
package/workspace/public/app.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) || ' '}</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) || ' '}</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)}
|
package/workspace/server.js
CHANGED
|
@@ -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
|