create-pmos 0.19.0 → 0.20.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.19.0",
3
+ "version": "0.20.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@181425a
1
+ kernel-source: pmos@1d54a27
2
2
  assembled: 2026-07-31
3
3
  mode: vendor
@@ -26,13 +26,22 @@
26
26
  * this endpoint's `frontmatter` can agree with its own `ladder`.
27
27
  *
28
28
  * ── The declaration stores ───────────────────────────────────────────────────────────────────
29
- * `artifact-links.jsonl` and `initiative-links.jsonl` are APPEND-ONLY records of what the PM
30
- * declared, never of anything PMOS read. PMOS holds no credential and makes no outbound request
31
- * (PRD, "External sync"), so a link row is a statement of intent — "this artifact corresponds to
32
- * that page" — and the surface that renders it must say that nothing was read and nothing will be
33
- * written. `connectors()` below is the honest answer to "is a connector configured": always none.
34
- * It is a function rather than a constant so that the day a connector genuinely exists there is
35
- * exactly one place that has to start telling a different truth.
29
+ * `artifact-links.jsonl` and `initiative-links.jsonl` are APPEND-ONLY records of external
30
+ * references. PMOS holds no credential and makes no outbound request (PRD, "External sync") — that
31
+ * has not changed and must not. What a row may now say about itself has: every row carries a
32
+ * `state` from a closed three-word vocabulary (`declared` · `resolved` · `unreachable`) and the
33
+ * provenance behind it, so a reference the PM typed and a reference a session actually read are
34
+ * different facts on disk instead of the same row wearing the word `connected`.
35
+ *
36
+ * `resolved` is the only state that claims anything about the destination, and it cannot be
37
+ * asserted — only evidenced. See `provenance()`: a row claiming `resolved` without the profile that
38
+ * resolved it is REFUSED. That refusal is the mechanical form of this whole module's promise, and
39
+ * it is why the day a connector genuinely exists there is exactly one place that has to start
40
+ * telling a different truth — and no way for anything else to start telling it early.
41
+ *
42
+ * `connectors()` below answers "what could be looked up, and by whom" from the agent profiles on
43
+ * disk. It used to return a constant `[]`. A constant cannot be wrong, which is exactly why it
44
+ * could not be trusted: it said "none" on a machine whose profiles declared a Jira MCP.
36
45
  *
37
46
  * The files land beside the repo's other append-only records, resolved through the workflow
38
47
  * engine's own `layout(root)`: `pmos/state/` in a kit repo — exactly as the PRD names it — and
@@ -52,19 +61,110 @@ const INITIATIVE_LINKS = 'initiative-links.jsonl';
52
61
  /** The destinations the surface can declare. A kind outside this set is refused, not stored. */
53
62
  const LINK_KINDS = ['confluence', 'jira', 'google'];
54
63
 
64
+ /**
65
+ * The closed vocabulary a link row may use about itself. Three states, and the word `connected` is
66
+ * deliberately not among them.
67
+ *
68
+ * declared — the PM named this reference. Nothing was checked. Carries no claim about the
69
+ * destination beyond the PM's own say-so.
70
+ * resolved — a lookup performed BY A SESSION that holds the PM's MCP returned this record.
71
+ * Carries the destination's own title and the moment it was read.
72
+ * unreachable — a lookup was attempted and did not succeed. Carries the reason it failed.
73
+ *
74
+ * `connected` was the word this surface used before, and it is retired rather than re-worded: PMOS
75
+ * is never connected to anything — a session is — so the word had no honest referent here. It is
76
+ * also the word that let a row the PM had merely declared read as a row PMOS had verified.
77
+ */
78
+ const LINK_STATES = ['declared', 'resolved', 'unreachable'];
79
+
80
+ /**
81
+ * How a reference came to be in the store. This is the field that makes the store cleanable: the
82
+ * rows written by the fabricating build carry neither, so they are distinguishable from real ones
83
+ * forever rather than only until someone forgets.
84
+ *
85
+ * typed — a human wrote the reference into the form. The only origin a `declared` row can have.
86
+ * resolved — a session searched the destination and returned this record.
87
+ */
88
+ const LINK_ORIGINS = ['typed', 'resolved'];
89
+
90
+ /**
91
+ * Which MCP server name on a profile makes which destination resolvable. The names on the left are
92
+ * what a profile descriptor actually declares (`mcp: [jira, confluence, ...]`); the kinds on the
93
+ * right are this store's closed vocabulary. Anything a profile declares that is not in here simply
94
+ * does not make a destination resolvable — an unknown server name is not evidence of anything.
95
+ */
96
+ const MCP_FOR_KIND = {
97
+ jira: ['jira', 'atlassian'],
98
+ confluence: ['confluence', 'atlassian'],
99
+ google: ['gworkspace', 'google-workspace', 'gdrive', 'google-drive'],
100
+ };
101
+
55
102
  /** An initiative id is a FILENAME, never a path. Same guard the handoff surface uses. */
56
103
  const ID_RE = /^[A-Za-z0-9][\w.-]*$/;
57
104
  const validId = (id) => typeof id === 'string' && id.length <= 120 && ID_RE.test(id);
58
105
 
59
106
  /**
60
- * No connector is configured, on every code path, because PMOS holds no credential. Returned as a
61
- * list so the client can render "which destinations are live" without a second shape to learn; it
62
- * is empty, and the note says why.
107
+ * Which destinations can be RESOLVED right now, and by what.
108
+ *
109
+ * This used to return a constant `[]` with a note explaining that PMOS holds no credential. The
110
+ * note was true and the list was not an answer — it said "none" on a machine where the PM's own
111
+ * agent profile declares a Jira MCP, and it would have gone on saying "none" after a real
112
+ * connection existed. A constant cannot be wrong, which is exactly why it cannot be trusted.
113
+ *
114
+ * The honest answer is derived, and it is careful about WHOSE capability it is describing. PMOS
115
+ * still holds no credential and still makes no outbound request — that has not changed and must
116
+ * not. What has changed is that PMOS can now see which agent PROFILES declare an MCP server for a
117
+ * destination, and a session started on such a profile can perform a lookup with the PM's own auth
118
+ * (D53: the agent layer acts, PMOS records). So `configured` does not mean "PMOS is connected"; it
119
+ * means "a session on this profile could look this up for you", and every entry names the profile
120
+ * so the claim is attributable rather than ambient.
121
+ *
122
+ * `declared: true` on every entry is the load-bearing hedge. A profile DECLARES an MCP server in a
123
+ * file; PMOS has not reached it, cannot reach it, and will not pretend the declaration is a
124
+ * handshake. A profile that lists a Jira MCP whose token expired last week still reads as
125
+ * resolvable here, and the resulting failure surfaces where it actually happens — as an
126
+ * `unreachable` row carrying the reason, written by the session that tried.
63
127
  */
64
- function connectors() {
128
+ function connectors(homeDir) {
129
+ const empty = (note) => ({ configured: [], declaredBy: [], note });
130
+ const home = typeof homeDir === 'string' && homeDir ? homeDir : null;
131
+ if (!home) {
132
+ return empty('no home directory to read agent profiles from — PMOS cannot say what is resolvable, so it says nothing.');
133
+ }
134
+
135
+ let read;
136
+ try { read = require('./profiles').readProfiles(home); }
137
+ catch { return empty('the profile reader is unavailable — PMOS cannot say what is resolvable, so it says nothing.'); }
138
+
139
+ // ABSENT and BROKEN are different facts here for the same reason they are in the profile reader:
140
+ // "you have declared no profiles" is calm and actionable, "PMOS could not look" is a failure the
141
+ // PM has to go fix, and collapsing them would hide the second behind the first.
142
+ if (!read.ok) {
143
+ return empty(`agent profiles could not be read — ${read.error || 'unknown error'}. Nothing is claimed about what is resolvable.`);
144
+ }
145
+ if (!read.profiles || !read.profiles.length) {
146
+ return empty('no agent profile declares an MCP server for these destinations — a reference you add here is one you typed, and PMOS will say so.');
147
+ }
148
+
149
+ const configured = [];
150
+ for (const kind of LINK_KINDS) {
151
+ const names = MCP_FOR_KIND[kind] || [];
152
+ for (const prof of read.profiles) {
153
+ const mcp = Array.isArray(prof.mcp) ? prof.mcp : [];
154
+ const hit = mcp.find((s) => names.includes(String(s).toLowerCase()));
155
+ if (!hit) continue;
156
+ configured.push({ kind, profile: prof.id, server: hit, declared: true });
157
+ break; // one profile per kind is enough to offer the lookup
158
+ }
159
+ }
160
+
161
+ if (!configured.length) {
162
+ return empty('no agent profile declares an MCP server for these destinations — a reference you add here is one you typed, and PMOS will say so.');
163
+ }
65
164
  return {
66
- configured: [],
67
- note: 'no connector configured — nothing was read, and nothing will be written.',
165
+ configured,
166
+ declaredBy: [...new Set(configured.map((c) => c.profile))],
167
+ note: 'a session on the named profile can look these up with your own credentials. PMOS itself holds none and reads nothing.',
68
168
  };
69
169
  }
70
170
 
@@ -244,6 +344,58 @@ function appendDeclaration(stateDir, name, row) {
244
344
  }
245
345
  }
246
346
 
347
+ /**
348
+ * The provenance half of every link row, and the one rule that makes the vocabulary mean something.
349
+ *
350
+ * THE RULE: `resolved` cannot be asserted, only evidenced. A row may claim `state: 'resolved'` only
351
+ * if it also carries the profile that resolved it and the moment it did. A caller that says
352
+ * "resolved" and cannot say by whom is not describing a lookup, and the store refuses it rather
353
+ * than recording an unsupported claim — which is exactly the failure this whole initiative exists
354
+ * to remove, arriving through the front door instead of through a fabricating helper.
355
+ *
356
+ * The consequence worth stating plainly: a reference the PM TYPED can never be stored as
357
+ * `resolved`, no matter what the client sends. `origin: 'typed'` forces `declared`. That is what
358
+ * makes the three states worth reading — a `resolved` row is a fact about the destination, and
359
+ * nothing else in the system can mint one.
360
+ */
361
+ function provenance(body, at) {
362
+ const origin = String((body && body.origin) || 'typed').trim().toLowerCase();
363
+ if (!LINK_ORIGINS.includes(origin)) {
364
+ return { error: `origin must be one of: ${LINK_ORIGINS.join(', ')}` };
365
+ }
366
+ let state = String((body && body.state) || (origin === 'resolved' ? 'resolved' : 'declared')).trim().toLowerCase();
367
+ if (!LINK_STATES.includes(state)) {
368
+ return { error: `state must be one of: ${LINK_STATES.join(', ')}` };
369
+ }
370
+ const profile = body && body.profile ? String(body.profile).slice(0, 120) : null;
371
+ const reason = body && body.reason ? String(body.reason).slice(0, 300) : null;
372
+ const title = body && body.title ? String(body.title).slice(0, 300) : null;
373
+
374
+ // A typed reference is the PM's word, and the PM's word is a declaration. Silently downgrading
375
+ // would be the friendlier-looking choice and the wrong one: a client that asked to store a typed
376
+ // reference AS resolved has a bug, and a store that quietly corrects it hides the bug while the
377
+ // next caller writes the same line.
378
+ if (origin === 'typed' && state === 'resolved') {
379
+ return { error: 'a typed reference cannot be stored as resolved — nothing read it. Send origin: resolved with the profile that did.' };
380
+ }
381
+ if (state === 'resolved' && !profile) {
382
+ return { error: 'a resolved reference must name the profile whose session resolved it' };
383
+ }
384
+ if (state === 'unreachable' && !reason) {
385
+ return { error: 'an unreachable reference must carry the reason the lookup failed' };
386
+ }
387
+ return {
388
+ origin,
389
+ state,
390
+ profile,
391
+ reason,
392
+ title,
393
+ // Only a row that actually read something carries a read time. Absent means "nothing read it",
394
+ // which is a fact the surface renders rather than a gap it fills in.
395
+ resolved_at: state === 'resolved' ? (at || new Date().toISOString()) : null,
396
+ };
397
+ }
398
+
247
399
  /** Validate + normalise an artifact-link declaration. Returns `{ok:false,error}` for a bad shape. */
248
400
  function artifactLinkRow(body, { at } = {}) {
249
401
  const artifact_path = String((body && body.artifact_path) || '').trim();
@@ -255,6 +407,8 @@ function artifactLinkRow(body, { at } = {}) {
255
407
  if (!target) return { ok: false, error: 'target is required' };
256
408
  const initiative_id = body && body.initiative_id ? String(body.initiative_id) : null;
257
409
  if (initiative_id && !validId(initiative_id)) return { ok: false, error: 'that initiative id is not a record name' };
410
+ const prov = provenance(body, at);
411
+ if (prov.error) return { ok: false, error: prov.error };
258
412
  return {
259
413
  ok: true,
260
414
  row: {
@@ -263,10 +417,13 @@ function artifactLinkRow(body, { at } = {}) {
263
417
  artifact_path,
264
418
  kind,
265
419
  target: target.slice(0, 300),
266
- // Stated on every row, not implied by its absence: this is a DECLARATION. Nothing was read
267
- // from the destination and nothing was written to it.
268
- declared: true,
269
- connector: null,
420
+ ...prov,
421
+ // Kept, and no longer the whole story. `declared: true` said "PMOS read nothing" back when
422
+ // that was true of every row; now it is true of exactly the rows whose state says so, and
423
+ // the state is the field to read. Retained because rows on disk carry it and a reader that
424
+ // drops a field silently changes what the history means.
425
+ declared: prov.state !== 'resolved',
426
+ connector: prov.state === 'resolved' ? prov.profile : null,
270
427
  },
271
428
  };
272
429
  }
@@ -280,20 +437,95 @@ function initiativeLinkRow(body, { at } = {}) {
280
437
  if (!validId(initiative_id)) return { ok: false, error: 'that initiative id is not a record name' };
281
438
  if (!kind) return { ok: false, error: 'kind is required' };
282
439
  if (!target) return { ok: false, error: 'a target reference is required' };
440
+ const prov = provenance(body, at);
441
+ if (prov.error) return { ok: false, error: prov.error };
283
442
  return {
284
443
  ok: true,
285
444
  row: {
286
445
  at: at || new Date().toISOString(),
287
446
  initiative_id,
288
447
  kind: kind.slice(0, 40),
289
- label: (label || target).slice(0, 200),
448
+ // The destination's OWN title when a lookup returned one; otherwise what the PM typed, and
449
+ // failing that the reference itself. Never a label built out of the initiative slug — that
450
+ // was the fabrication, and a label assembled from local strings is one whatever it is called.
451
+ label: (prov.title || label || target).slice(0, 200),
290
452
  target: target.slice(0, 300),
291
- declared: true,
292
- connector: null,
453
+ ...prov,
454
+ declared: prov.state !== 'resolved',
455
+ connector: prov.state === 'resolved' ? prov.profile : null,
293
456
  },
294
457
  };
295
458
  }
296
459
 
460
+ /**
461
+ * Collapse an append-only store into what the surface shows: ONE row per subject and destination
462
+ * kind, the latest winning.
463
+ *
464
+ * Both halves of that matter and they pull in opposite directions, which is why this is a read-time
465
+ * derivation rather than a write-time replace. The store is a history of what the PM declared and
466
+ * every row stays on disk — that is the whole reason it is append-only. The SURFACE is a statement
467
+ * of what is true now, and six clicks on one button are not six references; they are one reference
468
+ * declared six times. Before this, the store was right and the surface showed all six.
469
+ *
470
+ * `subject` is the artifact path for an artifact link and the initiative id for a reference, so a
471
+ * PRD linked to Jira and the initiative linked to Jira are different pairs and neither supersedes
472
+ * the other. Rows are ordered by their recorded time with file order as the tiebreak, because two
473
+ * rows can share a timestamp and file order is the only thing that then says which was written second.
474
+ */
475
+ function linkView(rows) {
476
+ const list = Array.isArray(rows) ? rows : [];
477
+ const seen = new Map();
478
+ list.forEach((row, i) => {
479
+ if (!row || typeof row !== 'object') return;
480
+ const subject = row.artifact_path || row.initiative_id || '';
481
+ const key = subject + '|' + String(row.kind || '');
482
+ const prev = seen.get(key);
483
+ if (!prev) { seen.set(key, { row, i }); return; }
484
+ const a = String(prev.row.at || ''), b = String(row.at || '');
485
+ if (b > a || (b === a && i > prev.i)) seen.set(key, { row, i });
486
+ });
487
+ return [...seen.values()]
488
+ .sort((x, y) => x.i - y.i)
489
+ .map(({ row }) => ({
490
+ ...row,
491
+ // Rows written before provenance existed carry neither an origin nor a state. They are NOT
492
+ // rewritten to look like typed references — that would launder exactly the fabricated rows
493
+ // this initiative was opened to make identifiable. They are reported as what they are.
494
+ origin: row.origin || null,
495
+ state: row.state || (row.origin ? 'declared' : 'unattributed'),
496
+ }));
497
+ }
498
+
499
+ /**
500
+ * The task a session performs to resolve a reference. Composed here, on the server, because it is
501
+ * the thing the whole design turns on and a string built in a click handler is a string no test
502
+ * ever sees.
503
+ *
504
+ * Note what it asks for and what it does not. It asks the session to SEARCH and to report what it
505
+ * found, including finding nothing; it does not ask it to create, publish, or push anything, and it
506
+ * names the recording endpoint so the session records through the same validated path the PM's own
507
+ * typing goes through. There is no branch here in which PMOS makes a request.
508
+ */
509
+ function lookupTask({ kind, subject, hint, profile } = {}) {
510
+ const k = String(kind || '').toLowerCase();
511
+ if (!LINK_KINDS.includes(k)) return { ok: false, error: `kind must be one of: ${LINK_KINDS.join(', ')}` };
512
+ const what = k === 'jira' ? 'Jira issue or epic' : k === 'confluence' ? 'Confluence page' : 'Google Doc';
513
+ return {
514
+ ok: true,
515
+ profile: profile || null,
516
+ text: [
517
+ `Find the ${what} that corresponds to "${String(subject || '').slice(0, 200)}".`,
518
+ hint ? `The PM's hint about which one: ${String(hint).slice(0, 200)}` : null,
519
+ '',
520
+ `Use your ${k} MCP tools to SEARCH. Do not create, publish, or modify anything.`,
521
+ 'Report the candidates you found — key/id, title, and URL for each — and let the PM pick.',
522
+ 'If you find nothing, say so plainly. If the lookup fails, report the error verbatim.',
523
+ 'Do not guess an identifier, and do not construct one from the name above — a made-up',
524
+ 'reference is worse here than no reference, which is the defect this replaced.',
525
+ ].filter((l) => l !== null).join('\n'),
526
+ };
527
+ }
528
+
297
529
  /* ── the assembled payload ──────────────────────────────────────────────────────────────────── */
298
530
 
299
531
  /**
@@ -305,11 +537,13 @@ function initiativeLinkRow(body, { at } = {}) {
305
537
  * artifacts are still listed, because the library can see them and claiming otherwise would hide
306
538
  * real files), `{ok:true,…}` otherwise.
307
539
  */
308
- function assemble({ id, rec, ladder, links, index, sessions, workspaces, anchor, artifactLinks, initiativeLinks }) {
540
+ function assemble({ id, rec, ladder, links, index, sessions, workspaces, anchor, artifactLinks, initiativeLinks, homeDir }) {
309
541
  if (!rec.ok) return { ok: false, id, error: rec.error };
310
542
  const artifacts = artifactsFor(index, id);
311
543
  const sess = sessionsFor({ sessions, workspaces, id });
312
- const mine = (rows, pred) => (rows && rows.ok ? rows.rows.filter(pred) : []);
544
+ // linkView LAST, so superseding happens across this initiative's rows only — collapsing the whole
545
+ // store first would let another initiative's link to the same path win a pair that is not its own.
546
+ const mine = (rows, pred) => linkView((rows && rows.ok ? rows.rows.filter(pred) : []));
313
547
  return {
314
548
  ok: true,
315
549
  id,
@@ -334,12 +568,13 @@ function assemble({ id, rec, ladder, links, index, sessions, workspaces, anchor,
334
568
  artifacts_error: artifactLinks && artifactLinks.ok ? null : (artifactLinks && artifactLinks.error) || null,
335
569
  links_error: initiativeLinks && initiativeLinks.ok ? null : (initiativeLinks && initiativeLinks.error) || null,
336
570
  },
337
- connectors: connectors(),
571
+ connectors: connectors(homeDir),
338
572
  };
339
573
  }
340
574
 
341
575
  module.exports = {
342
- ARTIFACT_LINKS, INITIATIVE_LINKS, LINK_KINDS,
576
+ ARTIFACT_LINKS, INITIATIVE_LINKS, LINK_KINDS, LINK_STATES, LINK_ORIGINS, MCP_FOR_KIND,
343
577
  validId, connectors, readRecord, parseTasks, artifactsFor, sessionsFor, anchorFor,
344
578
  storeDir, readDeclarations, appendDeclaration, artifactLinkRow, initiativeLinkRow, assemble,
579
+ linkView, lookupTask,
345
580
  };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.19.0",
9
+ "version": "0.20.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.19.0",
3
+ "version": "0.20.1",
4
4
  "private": true,
5
- "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
5
+ "description": "PMOS Terminal Workspace \u2014 local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
7
7
  "bin": {
8
8
  "pmos": "bin/pmos.js"
@@ -10,7 +10,7 @@
10
10
  "scripts": {
11
11
  "start": "node server.js",
12
12
  "test": "node test.js && npm run test:modules",
13
- "test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node no-network.test.js && node fixture-parity.test.mjs && node start-policy.test.js",
13
+ "test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node no-network.test.js && node fixture-parity.test.mjs && node start-policy.test.js",
14
14
  "test:ui": "node ui-test.js",
15
15
  "test:parity": "node design-parity.js --states",
16
16
  "test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
@@ -29,7 +29,7 @@
29
29
  "playwright-core": "^1.61.1",
30
30
  "ws": "^8.18.0"
31
31
  },
32
- "//devDependencies": "design-parity's pixel axis only. Kept OUT of dependencies deliberately: `create-pmos workspace` installs this package with `npm install -g`, which takes dependencies and not devDependencies, so an adopter installing a local PM tool does not also install a screenshot differ. design-parity.js requires both optionally and degrades to text+style+geometry when they are absent, so the parity check still runs — it just stops measuring pixels, and says so.",
32
+ "//devDependencies": "design-parity's pixel axis only. Kept OUT of dependencies deliberately: `create-pmos workspace` installs this package with `npm install -g`, which takes dependencies and not devDependencies, so an adopter installing a local PM tool does not also install a screenshot differ. design-parity.js requires both optionally and degrades to text+style+geometry when they are absent, so the parity check still runs \u2014 it just stops measuring pixels, and says so.",
33
33
  "devDependencies": {
34
34
  "pixelmatch": "^7.1.0",
35
35
  "pngjs": "^7.0.0"
@@ -40,6 +40,12 @@
40
40
  rather than only in a browser. pm/mobile.js reads it from the global; the module graph does not
41
41
  import it, because a second copy of the reducer is exactly the drift it was extracted to stop. -->
42
42
  <script src="/lib/mobile-nav.js"></script>
43
+ <!-- ONE classifier, both sides (workspace-reader-preview). What the reader may do with a file —
44
+ markdown, escaped text, an HTML page, an image — is decided by this module, which server.js
45
+ requires for its read endpoints and the doc tab reads here as window.PreviewKind. A second
46
+ opinion about what an .html file is would mean the endpoint and the pane disagreeing about the
47
+ same document. Classic script (a UMD), like the three above. -->
48
+ <script src="/lib/preview-kind.js"></script>
43
49
  <link rel="preload" href="/vendor/ibm-plex-mono-500.woff2" as="font" type="font/woff2" crossorigin />
44
50
  </head>
45
51
  <body>
@@ -969,6 +969,18 @@ export async function recordArtifactLink({ artifact_path, slug, kind, target })
969
969
  return out;
970
970
  }
971
971
 
972
+ /**
973
+ * panel-detail.js — `lookupTask({kind, subject, hint, profile})`.
974
+ *
975
+ * Asks the server to compose the task a SESSION will run to resolve a reference. It returns text.
976
+ * Nothing here contacts Jira, Confluence or Google, and nothing here can: the workspace holds no
977
+ * credential (D53 — the agent layer acts with the PM's own MCP, PMOS records the result).
978
+ */
979
+ export async function lookupTask({ kind, subject, hint, profile }) {
980
+ if (FIXTURE) return { ok: false, error: 'fixture mode composes no task' };
981
+ return postJson('/api/initiative/lookup-task', { kind, subject, hint, profile });
982
+ }
983
+
972
984
  /** panel-detail.js — `recordInitiativeLink({slug, kind, label, path, st})` → `initiative-links.jsonl`. */
973
985
  export async function recordInitiativeLink({ slug, kind, label, path }) {
974
986
  if (FIXTURE) return { ok: true };
@@ -790,7 +790,30 @@ export const artDiffText = dest => 'repo file is the source. read ' + (dest ? de
790
790
  export const artSyncPushed = dest => ({ color: 'var(--success)', text: 'pushed · repo → ' + dest.kind + ' · in sync 12:41' });
791
791
  export const artSyncPulled = dest => ({ color: 'var(--warning)', text: 'pulled · ' + dest.kind + ' → repo · file updated' });
792
792
 
793
- /** addLink (lines 1757–1766) — the link a source button appends. */
793
+ /* addLink (lines 1757–1766) — PROTOTYPE TRANSCRIPTION ONLY. It has no caller, and must not gain one.
794
+ *
795
+ * This is the helper the whole workspace-external-linking initiative was opened about. It returns
796
+ * `{kind:'jira epic', label:'ATL-501 · <slug>', path:'jira://ATL-501', st:'connected'}` — a Jira key
797
+ * counted up from the constant 501, a label built from the initiative slug, and the word `connected`
798
+ * on a row nothing had read. panel-detail.js called it on every + click and wrote the result to
799
+ * `initiative-links.jsonl`, so PMOS accumulated references to epics that do not exist, rendered in
800
+ * the same visual grammar it uses for facts.
801
+ *
802
+ * WHY IT IS STILL HERE, WHICH IS A REAL TENSION AND NOT AN OVERSIGHT. The initiative's rubric asks
803
+ * for the fabricating helpers to be REMOVED rather than bypassed, and deleting this function is the
804
+ * literal reading. But this module is a TRANSCRIPTION of the prototype, and fixture-parity.test.mjs
805
+ * exists to pin it line-for-line — deleting the function deletes that suite's assertion, and the
806
+ * same rubric forbids weakening an assertion to make a change pass. Both rules are right and they
807
+ * point opposite ways.
808
+ *
809
+ * The property that actually matters is not this function's existence; it is that nothing in real
810
+ * mode can reach it. So it stays, as the pinned record of what the prototype drew, and the guarantee
811
+ * is enforced mechanically instead of by deletion: pm-detail-test.js asserts that no module outside
812
+ * this file references `makeLink`. If a later change wires it back up, that test fails and names it.
813
+ *
814
+ * The seeded rows below (`ATL-482` and its siblings) are the same case and are correct: a prototype
815
+ * draws specimens. They are rendered in FIXTURE mode only and never reach the real store.
816
+ */
794
817
  export function makeLink(slug, src, existing = []) {
795
818
  if (src === 'jira') {
796
819
  const num = 501 + existing.filter(x => x.kind === 'jira epic').length;
@@ -151,12 +151,33 @@ function hasConnector(kind) {
151
151
  return c.some((x) => (typeof x === 'string' ? x : x && x.kind) === kind
152
152
  && (typeof x === 'string' || x.configured !== false));
153
153
  }
154
+ // The server's shape: `{configured:[{kind, profile, ...}], note}`. Checked BEFORE the plain-map
155
+ // branch below, which would otherwise read `c['jira']` on this object, find undefined, and report
156
+ // "no connector" on a machine whose profiles declare one — a false negative dressed as caution.
157
+ if (c && Array.isArray(c.configured)) return c.configured.some((x) => x && x.kind === kind);
154
158
  if (c && typeof c === 'object') return !!c[kind];
155
159
  return false;
156
160
  }
157
161
 
158
162
  const NO_CONNECTOR = 'no connector configured — nothing was read, and nothing will be written.';
159
163
 
164
+ /**
165
+ * Why the two sync approvals are inert in real mode even when a destination IS resolvable.
166
+ *
167
+ * They are publishing and read-back, and both are out of scope by name (PRD non-goals 1–2).
168
+ * Publishing puts PMOS content in two places and needs a fork/drift ruling the PM has not made;
169
+ * read-back has no surviving architecture at all, because D53's inbound half was a hosted-Supabase
170
+ * webhook and D64 deprecated that path. The buttons' old results — `pushed · repo → jira · in sync
171
+ * 12:41` — were a hardcoded clock time presented as a sync confirmation, which is the same defect
172
+ * as the fabricated identifier one level up.
173
+ *
174
+ * Deleting the buttons was the other option and is worse: the PM would be left wondering whether
175
+ * PMOS forgot how to sync. An inert control that states the reason is a surface telling the truth
176
+ * about its own limits.
177
+ */
178
+ const SYNC_OUT_OF_SCOPE = 'publishing and read-back are not built. PMOS records which record this '
179
+ + 'corresponds to; it does not write to that record, and it has read nothing from it.';
180
+
160
181
  /* ── the sticky detail header (lines 332–335) ───────────────────────────────────────────────
161
182
  * Built once and re-used, so panel 3's chrome keeps its identity across renders the way panel 2's
162
183
  * header and search box do.
@@ -276,24 +297,89 @@ function phaseBlock(slug, rec) {
276
297
  }
277
298
 
278
299
  /* ── 5a · LINKS (lines 380–398, derivation 1757–1766, 1797–1798) ────────────────────────────
279
- * External references — NOT repo files. Sessions add them automatically; the three + buttons let
280
- * the PM add one by hand, and adding one opens its preview immediately so the reference is
281
- * verified by looking at it rather than by trusting the row. Multiple links of a kind are allowed:
282
- * the Jira numbering counts the ones already filed rather than replacing them.
300
+ * External references — NOT repo files.
301
+ *
302
+ * WHAT CHANGED HERE, AND WHY IT IS THE POINT. This used to append a reference the moment a + button
303
+ * was clicked, built out of the initiative slug and a counter: `ATL-501`, then `ATL-502`. The PM was
304
+ * never asked which epic. The row then labelled itself `connected`, and after a reload the same row
305
+ * read `declared · not connected` — one row, two labels, and the flattering one was the one visible
306
+ * at the moment of the click. Six clicks recorded six references to six epics that did not exist.
307
+ *
308
+ * A + button now opens a FORM. Nothing is recorded until the PM supplies the reference or a session
309
+ * resolves it, because a reference nobody chose is not a reference — it is a guess written down in
310
+ * a place the PM will later trust. `makeLink` is gone rather than bypassed: a helper that can
311
+ * fabricate an identifier is a helper someone re-wires later.
312
+ */
313
+ /**
314
+ * Hand the lookup to a session that can actually perform it.
315
+ *
316
+ * This is the seam the whole initiative turns on, so it is worth being explicit about what it does
317
+ * NOT do: it does not contact Jira, Confluence or Google, and it does not record a link. It asks
318
+ * the server to compose a task and shows that task to the PM to run in a session on the named
319
+ * profile — the session holds the credential, PMOS does not (D53). The session then records the
320
+ * resolved reference through the same validated endpoint the PM's own typing goes through, where
321
+ * it will be refused unless it names the profile that read it.
322
+ *
323
+ * The alternative — having the workspace perform the search itself — is the design D53 rejected and
324
+ * that `no-network.test.js` fails by construction. There is no version of this function that calls out.
283
325
  */
326
+ function askSessionToResolve(slug, kind, resolver, hint) {
327
+ const data = sibling('data', './data.js');
328
+ if (!data || typeof data.lookupTask !== 'function') return;
329
+ Promise.resolve(data.lookupTask({ kind, subject: slug, hint, profile: resolver.profile }))
330
+ .then((out) => {
331
+ if (!out || out.ok === false) {
332
+ setState((s) => ({ linkTask: { ...s.linkTask, [slug]: { error: (out && out.error) || 'the task could not be composed' } } }));
333
+ return;
334
+ }
335
+ setState((s) => ({ linkTask: { ...s.linkTask, [slug]: { text: out.text, profile: resolver.profile, kind } } }));
336
+ })
337
+ .catch((e) => setState((s) => ({ linkTask: { ...s.linkTask, [slug]: { error: String((e && e.message) || e) } } })));
338
+ }
339
+
340
+ function linkStateStyle(st) {
341
+ if (st === 'resolved') return { color: 'var(--success)', label: 'resolved' };
342
+ if (st === 'unreachable') return { color: 'var(--warning)', label: 'unreachable' };
343
+ if (st === 'unattributed') return { color: 'var(--muted-foreground)', label: 'unattributed' };
344
+ return { color: 'var(--muted-foreground)', label: 'declared' };
345
+ }
346
+
347
+ /**
348
+ * The one sentence a link row is allowed to say about itself, built from what was actually observed.
349
+ * Every branch names its evidence or admits it has none — there is deliberately no branch that
350
+ * returns a bare status word, because a bare status word is what `connected` was.
351
+ */
352
+ function linkStateText(lk) {
353
+ const st = lk.state || (lk.origin ? 'declared' : 'unattributed');
354
+ if (st === 'resolved') {
355
+ const when = String(lk.resolved_at || '').slice(11, 16);
356
+ return 'resolved by ' + (lk.profile || 'a session') + (when ? ' · read ' + when : '');
357
+ }
358
+ if (st === 'unreachable') return 'unreachable · ' + (lk.reason || 'the lookup failed');
359
+ if (st === 'unattributed') return 'recorded before provenance — origin unknown';
360
+ return 'declared · nothing read it';
361
+ }
362
+
284
363
  function linksRow(slug) {
285
364
  const rec = records()[slug] || {};
286
365
  const manual = (state.initLinks || {})[slug] || [];
287
366
  const links = [...(rec.links || []), ...manual];
288
-
289
- const addLink = (src) => {
290
- const link = FX.makeLink(slug, src, manual);
367
+ const openSrc = (state.linkForm || {})[slug] || null;
368
+
369
+ /**
370
+ * Record what the PM typed. `origin: 'typed'` is sent explicitly and the server will refuse to
371
+ * store it as anything stronger — a typed reference is the PM's word, and the row says so.
372
+ */
373
+ const submitTyped = (src, ref) => {
374
+ const target = String(ref || '').trim();
375
+ if (!target) return;
376
+ const link = { kind: src, label: target, path: target, target, origin: 'typed', state: 'declared' };
291
377
  setState(
292
- (s) => ({ initLinks: { ...s.initLinks, [slug]: [...((s.initLinks || {})[slug] || []), link] } }),
293
- () => {
294
- declare('recordInitiativeLink', { slug, ...link });
295
- openDoc(slug, link.path, link.kind.toUpperCase());
296
- },
378
+ (s) => ({
379
+ initLinks: { ...s.initLinks, [slug]: [...((s.initLinks || {})[slug] || []), link] },
380
+ linkForm: { ...s.linkForm, [slug]: null },
381
+ }),
382
+ () => declare('recordInitiativeLink', { slug, ...link }),
297
383
  );
298
384
  };
299
385
 
@@ -304,13 +390,80 @@ function linksRow(slug) {
304
390
  },
305
391
  el('span', { style: 'flex:none;width:76px;font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--info)' }, iv(lk.kind)),
306
392
  el('span', { style: 'font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(lk.label)),
307
- el('span', { style: 'margin-left:auto;flex:none;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, iv(lk.st)),
393
+ // A row that CARRIES provenance states what was observed; a row that does not keeps whatever
394
+ // status copy it came with. The branch is on the row, not on the mode, which is the point: the
395
+ // prototype's seeded rows (`auto · session`) render unchanged so the parity comparator still
396
+ // sees a like-for-like surface, while every row this surface actually records — in either mode —
397
+ // is held to the three-word vocabulary. Branching on FIXTURE instead would have left a real row
398
+ // in a fixture-mode session rendering `undefined`.
399
+ (lk.state || lk.origin)
400
+ ? el('span', {
401
+ style: 'margin-left:auto;flex:none;font-family:var(--mono);font-size:10px;color:'
402
+ + linkStateStyle(lk.state || 'declared').color,
403
+ }, iv(linkStateText(lk)))
404
+ : el('span', { style: 'margin-left:auto;flex:none;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, iv(lk.st)),
308
405
  ));
309
406
 
407
+ // Which destinations a session could actually look up, and under which profile. Derived by the
408
+ // server from the PM's agent profiles — never a constant, and never a claim that PMOS is connected.
409
+ const conn = (state.connectors && state.connectors.configured) || [];
410
+ const resolverFor = (kind) => conn.find((c) => c.kind === kind) || null;
411
+
412
+ const form = openSrc ? (() => {
413
+ const resolver = resolverFor(openSrc);
414
+ const task = (state.linkTask || {})[slug] || null;
415
+ const inputId = 'linkref-' + slug + '-' + openSrc;
416
+ const read = () => {
417
+ const node = document.getElementById(inputId);
418
+ return node ? node.value : '';
419
+ };
420
+ return el('div', { style: 'margin-top:6px;padding:9px 11px;border:1px solid var(--border);background:var(--background)' },
421
+ el('div', { style: 'font-size:11.5px;color:var(--muted-foreground);line-height:1.5;margin-bottom:7px' },
422
+ iv(resolver
423
+ ? 'paste the reference, or ask ' + resolver.profile + ' to find it — that session holds your '
424
+ + openSrc + ' MCP. PMOS itself reads nothing.'
425
+ : 'paste the reference. No agent profile declares a ' + openSrc
426
+ + ' MCP, so nothing here can look it up for you — what you type is recorded as your own declaration.')),
427
+ el('input', {
428
+ id: inputId,
429
+ placeholder: openSrc === 'jira' ? 'ATL-482 or its URL' : 'the page or document URL',
430
+ style: 'width:100%;font-family:var(--mono);font-size:11.5px;padding:6px 8px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
431
+ }),
432
+ el('div', { style: 'display:flex;gap:6px;margin-top:8px;flex-wrap:wrap' },
433
+ el('button', {
434
+ onClick: () => submitTyped(openSrc, read()),
435
+ style: 'font-family:var(--mono);font-size:10.5px;padding:5px 10px;min-height:28px;background:var(--brand);color:oklch(0.987 0.002 197.1);font-weight:500',
436
+ }, 'record this reference'),
437
+ resolver && el('button', {
438
+ onClick: () => askSessionToResolve(slug, openSrc, resolver, read()),
439
+ style: 'font-family:var(--mono);font-size:10.5px;padding:5px 10px;min-height:28px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
440
+ hover: 'border-color:var(--ring)',
441
+ }, 'ask ', iv(resolver.profile), ' to find it'),
442
+ el('button', {
443
+ onClick: () => setState((s) => ({ linkForm: { ...s.linkForm, [slug]: null } })),
444
+ style: 'font-family:var(--mono);font-size:10.5px;padding:5px 10px;min-height:28px;background:transparent;border:1px solid var(--border);color:var(--muted-foreground)',
445
+ }, 'cancel'),
446
+ ),
447
+ task && (task.error
448
+ ? el('div', { style: 'margin-top:8px;font-size:11.5px;color:var(--warning);line-height:1.5' },
449
+ iv('the lookup task could not be composed — ' + task.error))
450
+ : el('div', { style: 'margin-top:8px' },
451
+ el('div', { style: 'font-size:11px;color:var(--muted-foreground);line-height:1.5;margin-bottom:5px' },
452
+ iv('run this in a session on ' + task.profile + '. it records the reference itself, and PMOS'
453
+ + ' will refuse the result unless it names the profile that read it.')),
454
+ el('pre', {
455
+ style: 'font-family:var(--mono);font-size:10.5px;line-height:1.5;white-space:pre-wrap;'
456
+ + 'padding:8px 9px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground);margin:0',
457
+ }, iv(task.text)))),
458
+ );
459
+ })() : null;
460
+
310
461
  const addRow = el('div', { style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:4px' },
311
462
  el('span', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)' }, 'link'),
312
463
  FX.LINK_SRCS.map((ls) => el('button', {
313
- onClick: () => addLink(ls.src),
464
+ onClick: () => setState((s) => ({
465
+ linkForm: { ...s.linkForm, [slug]: (s.linkForm || {})[slug] === ls.src ? null : ls.src },
466
+ })),
314
467
  style: 'display:flex;gap:5px;align-items:center;font-family:var(--mono);font-size:10.5px;padding:4px 9px;min-height:28px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
315
468
  hover: 'border-color:var(--ring)',
316
469
  }, '+ ', iv(ls.l))),
@@ -321,6 +474,7 @@ function linksRow(slug) {
321
474
  el('div', { style: 'display:flex;flex-direction:column;gap:2px;min-width:0' },
322
475
  rows,
323
476
  addRow,
477
+ form,
324
478
  el('span', { style: 'font-size:11px;color:var(--muted-foreground);margin-top:3px;line-height:1.5' },
325
479
  'references — not in the repo. sessions add these automatically; link one yourself when you need it.'),
326
480
  ),
@@ -354,19 +508,69 @@ function artifactRow(slug, a) {
354
508
 
355
509
  if (!dest) {
356
510
  // Unlinked: the three destinations it could be linked to.
511
+ // In FIXTURE mode the prototype's own destination copy is drawn, so the parity comparator still
512
+ // sees a like-for-like surface. In REAL mode a click opens a FORM: `linkArtDest` built the
513
+ // destination out of the artifact's own FILENAME — `PM / <basename>`, `ATL epic · <basename>` —
514
+ // which is a name for a page nobody created, recorded as though the PM had chosen it.
515
+ const openHere = (state.artLinkForm || {})[key] || null;
516
+ const inputId = 'artref-' + key.replace(/[^A-Za-z0-9]/g, '-');
517
+ const conn = (state.connectors && state.connectors.configured) || [];
518
+
519
+ const submit = (kind) => {
520
+ const node = document.getElementById(inputId);
521
+ const target = String((node && node.value) || '').trim();
522
+ if (!target) return;
523
+ const d = { kind, target, origin: 'typed', state: 'declared' };
524
+ setState((s) => ({
525
+ artLinks: { ...s.artLinks, [key]: d },
526
+ artLinkForm: { ...s.artLinkForm, [key]: null },
527
+ }));
528
+ declare('recordArtifactLink', { artifact_path: a.path, slug, kind, target, origin: 'typed', state: 'declared' });
529
+ };
530
+
357
531
  const linkRow = el('div', { style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:3px 0 0' },
358
532
  el('span', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)' }, 'link'),
359
533
  FX.ART_LINK_SRCS.map((ls) => el('button', {
360
534
  onClick: () => {
361
- const d = FX.linkArtDest(key, ls.kind);
362
- setState((s) => ({ artLinks: { ...s.artLinks, [key]: d } }));
363
- declare('recordArtifactLink', { artifact_path: a.path, slug, kind: d.kind, target: d.target });
535
+ if (FIXTURE) {
536
+ const d = FX.linkArtDest(key, ls.kind);
537
+ setState((s) => ({ artLinks: { ...s.artLinks, [key]: d } }));
538
+ return;
539
+ }
540
+ setState((s) => ({
541
+ artLinkForm: { ...s.artLinkForm, [key]: (s.artLinkForm || {})[key] === ls.kind ? null : ls.kind },
542
+ }));
364
543
  },
365
544
  style: 'font-family:var(--mono);font-size:10px;padding:3px 8px;min-height:24px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
366
545
  hover: 'border-color:var(--ring)',
367
546
  }, '+ ', iv(ls.l))),
368
547
  );
369
- return el('div', { style: 'min-width:0' }, head, linkRow);
548
+
549
+ const form = openHere ? el('div', { style: 'margin-top:6px;padding:8px 10px;border:1px solid var(--border);background:var(--background)' },
550
+ el('div', { style: 'font-size:11.5px;color:var(--muted-foreground);line-height:1.5;margin-bottom:6px' },
551
+ iv(conn.some((c) => c.kind === openHere)
552
+ ? 'paste the ' + openHere + ' reference this file corresponds to. a session on '
553
+ + (conn.find((c) => c.kind === openHere) || {}).profile + ' can find it for you — PMOS reads nothing.'
554
+ : 'paste the ' + openHere + ' reference this file corresponds to. nothing here can look it up,'
555
+ + ' so what you type is recorded as your own declaration.')),
556
+ el('input', {
557
+ id: inputId,
558
+ placeholder: openHere === 'jira' ? 'ATL-482 or its URL' : 'the page or document URL',
559
+ style: 'width:100%;font-family:var(--mono);font-size:11px;padding:5px 7px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
560
+ }),
561
+ el('div', { style: 'display:flex;gap:6px;margin-top:7px;flex-wrap:wrap' },
562
+ el('button', {
563
+ onClick: () => submit(openHere),
564
+ style: 'font-family:var(--mono);font-size:10px;padding:4px 9px;min-height:26px;background:var(--brand);color:oklch(0.987 0.002 197.1);font-weight:500',
565
+ }, 'record'),
566
+ el('button', {
567
+ onClick: () => setState((s) => ({ artLinkForm: { ...s.artLinkForm, [key]: null } })),
568
+ style: 'font-family:var(--mono);font-size:10px;padding:4px 9px;min-height:26px;background:transparent;border:1px solid var(--border);color:var(--muted-foreground)',
569
+ }, 'cancel'),
570
+ ),
571
+ ) : null;
572
+
573
+ return el('div', { style: 'min-width:0' }, head, linkRow, form);
370
574
  }
371
575
 
372
576
  // Linked: the destination pill, the sync button, and the persisted result of the last approval.
@@ -384,7 +588,9 @@ function artifactRow(slug, a) {
384
588
  let panel = null;
385
589
  if (syncOpen) {
386
590
  const live = hasConnector(dest.kind);
387
- const inert = !live;
591
+ // FIXTURE mode draws the prototype's two working approvals so the comparator sees a like-for-like
592
+ // surface. REAL mode never enables them: they are publishing and read-back, both out of scope.
593
+ const inert = FIXTURE ? !live : true;
388
594
  const decide = (make) => () => {
389
595
  if (inert) return;
390
596
  setState((s) => ({
@@ -395,7 +601,7 @@ function artifactRow(slug, a) {
395
601
  const off = inert ? ';opacity:.5;cursor:not-allowed' : '';
396
602
  panel = el('div', { style: 'margin-top:6px;padding:9px 11px;border:1px solid var(--border);background:var(--background)' },
397
603
  el('div', { style: 'font-size:12px;line-height:1.5;color:var(--muted-foreground)' },
398
- iv(live ? FX.artDiffText(dest) : NO_CONNECTOR)),
604
+ iv(FIXTURE ? (live ? FX.artDiffText(dest) : NO_CONNECTOR) : SYNC_OUT_OF_SCOPE)),
399
605
  el('div', { style: 'display:flex;gap:6px;margin-top:8px;flex-wrap:wrap' },
400
606
  el('button', {
401
607
  onClick: decide(FX.artSyncPushed), disabled: inert,
@@ -1,8 +1,13 @@
1
1
  /* pm/tab-doc.js — the document tab body: view · raw · edit · diff, with save, revert and a dirty flag.
2
2
  *
3
3
  * A port of the prototype's doc pane (template lines 869–927, derivation 2020–2045). The four modes
4
- * are the whole design: `view` renders the markdown, `raw` shows the file tinted by line kind,
4
+ * are the whole design: `view` renders the document, `raw` shows the file tinted by line kind,
5
5
  * `edit` is the buffer, and `diff` is a real LCS line diff of the buffer against what is on disk.
6
+ *
7
+ * `view` renders the markdown — EXCEPT for an HTML file, which is a page, not prose. Handing one to
8
+ * the markdown parser prints its own markup back at the PM: the document read as its source code.
9
+ * So an `.html` document renders, in `view`, as the page it is; `raw` is the one click that still
10
+ * shows the code. See `htmlFrame` for why rendering it is not a new privilege.
6
11
  * The diff is COMPUTED, never narrated — a "2 sections differ" that nothing read is the watermelon
7
12
  * this repo has a word for, and the same rule that governs artifact sync governs this pane.
8
13
  *
@@ -31,6 +36,7 @@ const iv = (v) => el('span', null, v === null || v === undefined ? '' : String(v
31
36
  const disk = new Map(); // path -> text read from this machine
32
37
  const pending = new Set(); // paths whose read is in flight
33
38
  const saveErr = new Map(); // path -> why the last save was refused
39
+ const unread = new Set(); // paths whose read FAILED — what `disk` holds for them is a stub
34
40
 
35
41
  let dataMod = null;
36
42
  import('./data.js').then((m) => { dataMod = m; }).catch(() => { dataMod = null; });
@@ -77,9 +83,12 @@ function requestDoc(tab) {
77
83
  : fetch('/api/doc?ws=' + encodeURIComponent(wsId()) + '&path=' + encodeURIComponent(path))
78
84
  .then((r) => (r.ok ? r.text() : Promise.reject(new Error('HTTP ' + r.status))));
79
85
  Promise.resolve(load).then(
80
- (text) => done(typeof text === 'string' ? text : String(text == null ? '' : text)),
81
- (e) => done(stub(tab, 'This file could not be read: ' + (e && e.message ? e.message : e)
82
- + '\n\nNothing was rendered from it. That is the failure being stated rather than an empty page shown.')),
86
+ (text) => { unread.delete(path); done(typeof text === 'string' ? text : String(text == null ? '' : text)); },
87
+ (e) => {
88
+ unread.add(path);
89
+ done(stub(tab, 'This file could not be read: ' + (e && e.message ? e.message : e)
90
+ + '\n\nNothing was rendered from it. That is the failure being stated rather than an empty page shown.'));
91
+ },
83
92
  );
84
93
  }
85
94
 
@@ -180,6 +189,71 @@ function rawBody(text) {
180
189
  }));
181
190
  }
182
191
 
192
+ /* ── the HTML page body ────────────────────────────────────────────────────────────────────────
193
+ *
194
+ * `previewKind` is the single classifier both sides of this app already trust (server.js requires
195
+ * it; index.html loads it as a classic script), so "what counts as HTML" is decided in exactly one
196
+ * place and cannot drift from the read endpoints. If it somehow did not load, `isHtmlDoc` answers
197
+ * false and the pane behaves exactly as it did before — a missing classifier degrades to the old
198
+ * view, never to an unclassified render.
199
+ */
200
+ const isHtmlDoc = (p) => {
201
+ const PK = typeof window !== 'undefined' ? window.PreviewKind : null;
202
+ if (!PK || typeof PK.previewKind !== 'function') return false;
203
+ return PK.previewKind(String(p || '')) === 'html';
204
+ };
205
+
206
+ /* A STUB IS NOT THE DOCUMENT, AND MUST NOT BE RENDERED AS ONE. When a read is in flight or has
207
+ * failed, `docText` hands back this pane's own prose — "reading …", or the failure with its reason.
208
+ * Rendering that inside a frame would bury the one sentence the PM needs behind an iframe boundary,
209
+ * and an .html file that could not be read would look like an empty page: the exact "never a blank
210
+ * page" rule this pane already keeps for every other kind. So a page is drawn only from text that
211
+ * genuinely came from disk (or from an edit saved in this window). A reference URI never renders —
212
+ * nothing was read from it to render. */
213
+ function rendersAsPage(tab) {
214
+ const p = tab.path;
215
+ if (!isHtmlDoc(p) || isReference(p)) return false;
216
+ if (state.savedDocs[p] != null) return true;
217
+ if (FIXTURE) return true;
218
+ return disk.has(p) && !unread.has(p);
219
+ }
220
+
221
+ /* Rendering an HTML document adds no privilege over showing it. The frame is fully sandboxed —
222
+ * `sandbox=""` grants nothing, so scripts and same-origin access are both denied — and the injected
223
+ * CSP is `default-src 'none'`, which blocks every network fetch the page could attempt. A poisoned
224
+ * explainer therefore can neither execute nor beacon out, which is the same no-remote-fetch-on-view
225
+ * invariant the markdown image rule holds (R4). Only inline styles, data: images and data: fonts
226
+ * resolve — enough for a page to look like itself, not enough to reach anything. */
227
+ const HTML_CSP = '<meta http-equiv="Content-Security-Policy" content="'
228
+ + "default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:" + '">';
229
+
230
+ const frames = new Map(); // tab id -> { text, node } — the frame currently showing that text
231
+
232
+ /* THE FRAME IS NEVER RE-POINTED, IT IS REPLACED. Writing a new `srcdoc` onto a frame that is
233
+ * already in the document does navigate it — but the replacement document does NOT get the meta
234
+ * CSP applied to its subresource loads, so the remote <link>s in the new page are fetched. That
235
+ * is not a theory: the panel-4 suite opens `web/index.html`, whose remote font stylesheet went out
236
+ * exactly this way, and the containment assertion caught it. Setting `srcdoc` BEFORE the frame is
237
+ * inserted is enforced correctly, so a text change builds a new frame and the old one is dropped.
238
+ *
239
+ * The cache is therefore keyed on the TEXT, not just the tab: a repaint that changed nothing hands
240
+ * back the identical node — which is what `setChildren` needs to leave a live frame alone — while a
241
+ * changed document gets a frame built from scratch. The cost is that saving an open HTML file
242
+ * returns it to the top; the alternative is a page that renders contained only until it is edited. */
243
+ function htmlFrame(tab, text) {
244
+ const cur = frames.get(tab.id);
245
+ if (cur && cur.text === text) return cur.node;
246
+ const node = el('iframe', {
247
+ style: 'flex:1;min-height:0;width:100%;border:0;background:var(--background)',
248
+ sandbox: '', // no allow-scripts, no allow-same-origin
249
+ referrerpolicy: 'no-referrer',
250
+ title: String(tab.path || 'document'),
251
+ srcdoc: HTML_CSP + text, // set HERE, on a node not yet in the document — see above
252
+ });
253
+ frames.set(tab.id, { text, node });
254
+ return node;
255
+ }
256
+
183
257
  function diffBody(raw, text, dirty) {
184
258
  const rows = dirty ? FX.diffLines(raw, text) : [FX.NO_DIFF_ROW];
185
259
  return el('div', {
@@ -212,6 +286,7 @@ function editBox(tab) {
212
286
  /** Release what a closed doc tab held. Called by panel-tabs.js when the tab goes away. */
213
287
  export function disposeDoc(id) {
214
288
  boxes.delete(id);
289
+ frames.delete(id);
215
290
  }
216
291
 
217
292
  /* ── mount ─────────────────────────────────────────────────────────────────────────────────── */
@@ -270,7 +345,7 @@ export function mountDoc(host, tab) {
270
345
  * genuinely differs, which is what keeps the caret still mid-sentence. */
271
346
  if (body.defaultValue !== text) body.defaultValue = text;
272
347
  if (body.value !== text) body.value = text;
273
- } else body = viewBody(text);
348
+ } else body = rendersAsPage(tab) ? htmlFrame(tab, text) : viewBody(text);
274
349
 
275
350
  setChildren(host, [modeBar, body]);
276
351
  return host;
@@ -3850,6 +3850,11 @@ async function handleRequest(req, res) {
3850
3850
  : null,
3851
3851
  artifactLinks: fourPanel.readDeclarations(stateDir, fourPanel.ARTIFACT_LINKS),
3852
3852
  initiativeLinks: fourPanel.readDeclarations(stateDir, fourPanel.INITIATIVE_LINKS),
3853
+ // Which destinations a session could look up, derived from the PM's agent profiles. The home
3854
+ // directory is passed IN rather than read inside four-panel.js for the same reason
3855
+ // readProfiles takes it as a parameter: a module that reads os.homedir() itself can only be
3856
+ // tested against the machine running the suite.
3857
+ homeDir: os.homedir(),
3853
3858
  });
3854
3859
  // Discriminated READ results stay 200 — the body carries the reason, the transport code cannot.
3855
3860
  return json(res, { ws: w ? w.id : null, ...out });
@@ -3877,7 +3882,20 @@ async function handleRequest(req, res) {
3877
3882
  built.row,
3878
3883
  );
3879
3884
  // A write that failed returns 500 and says so — never a false green (the /api/gate rule).
3880
- return json(res, { ...wrote, connectors: fourPanel.connectors() }, wrote.ok ? 200 : 500);
3885
+ return json(res, { ...wrote, connectors: fourPanel.connectors(os.homedir()) }, wrote.ok ? 200 : 500);
3886
+ }
3887
+ if (p === '/api/initiative/lookup-task' && req.method === 'POST') {
3888
+ // The task a SESSION runs to resolve a reference. This endpoint composes a string and returns
3889
+ // it. It contacts nothing — that is the entire point of the design: PMOS describes the lookup,
3890
+ // an agent session holding the PM's own MCP performs it, and the result comes back through
3891
+ // /api/initiative/link like any other declaration. There is no branch here that reaches out.
3892
+ let body = {};
3893
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
3894
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
3895
+ const built = fourPanel.lookupTask({
3896
+ kind: body.kind, subject: body.subject, hint: body.hint, profile: body.profile,
3897
+ });
3898
+ return json(res, built, built.ok ? 200 : 400);
3881
3899
  }
3882
3900
  if (p === '/api/search') {
3883
3901
  // Episodic recall over this workspace's own past sessions. Localhost only, like everything