create-pmos 0.18.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -333,6 +333,36 @@ 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.19.0
337
+
338
+ **A long document scrolls its own panel, and starting a session asks what is already running.** Run
339
+ `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
340
+
341
+ - **Opening a long document no longer scrolls the whole window.** Scrolling a document in the right
342
+ panel moved the entire interface — the top bar slid away, the status bar rose from below — while
343
+ the panel's own scrollbar never engaged. Every panel now scrolls inside itself and the app frame
344
+ never moves. (If you are curious: the shell's middle row was written `1fr`, which is
345
+ `minmax(auto, 1fr)`, so a tall document enlarged the row rather than overflowing it. Every style
346
+ involved read as correct, which is why it survived review.)
347
+ - **`+ session` now asks before it starts one, when the checkout is already busy.** It used to
348
+ start the session and *then* tell you how many others were in there — a count arriving after the
349
+ decision, naming something you cannot act on. Now you get a chooser first, listing every session
350
+ in that checkout.
351
+ - **It says what each one is.** Its name, its agent, the same word the sidebar uses (`working` ·
352
+ `quiet 12m` · `needs you` · `maybe blocked`), the agent's own last words, and who holds the writer
353
+ claim.
354
+ - **…and what ending it would cost, without over-claiming.** Only `needs you` reads as settled —
355
+ its turn finished and it is waiting on you. `working` says output is arriving right now. `quiet`
356
+ and `maybe blocked` say, in as many words, that the workspace *cannot tell* a finished turn from a
357
+ permission prompt from a stuck tool. A confident "safe to end" that discarded a running turn would
358
+ be worse than no reading at all.
359
+ - **You can take the running session instead of making another.** `open it` attaches a tab to it and
360
+ starts nothing. `end it` is there too — the first click arms it and names what it will end, the
361
+ second does it. `+ start another session here anyway` is always one click: being told is never
362
+ being blocked, and no session is ever refused because another is there.
363
+ - **The same detail is written into the new session's scrollback**, so scrolling back tomorrow
364
+ answers the same question the chooser did.
365
+
336
366
  ## What's new in 0.18.1
337
367
 
338
368
  **Three fixes to the workspace terminal, all of them about what the window knew about a session.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.18.1",
3
+ "version": "0.20.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@9fee84a
1
+ kernel-source: pmos@ec75724
2
2
  assembled: 2026-07-31
3
3
  mode: vendor
@@ -162,6 +162,24 @@ session in a tree that already has live ones now says so in the pane — naming
162
162
  way out (⑂ a new branch in its own folder). It fires on the plain `+ session` path too, where no
163
163
  git operation runs at all: the server already knew, and used to say nothing.
164
164
 
165
+ **`+ session` asks before it starts one.** A count arriving *after* the spawn is a fact you cannot
166
+ act on, and the answer you usually want is not "start another" at all — it is "which of those is the
167
+ one I was already working in?". So clicking `+ session` into an occupied tree opens a chooser
168
+ first, listing every session in that tree with:
169
+
170
+ - **what it is** — its name, its agent, the sidebar's own word for its state (`working` ·
171
+ `quiet 12m` · `needs you` · `maybe blocked`), the agent's last words, and the writer claim;
172
+ - **what ending it would cost** — one sentence per session, worded from the evidence. Only
173
+ `needs you` reads as settled ("its turn finished and it is waiting on you"). `quiet` and
174
+ `maybe blocked` say, in as many words, that PMOS *cannot tell* — a confident "safe" that
175
+ discards a running turn is worse than no reading at all;
176
+ - **`open it`** — attach a tab to that session and start nothing.
177
+
178
+ `end it` is there too, and is the only destructive control on the surface: two clicks, the first
179
+ arming it. `+ start another session here anyway` is always one click away — informing you is never
180
+ obstructing you. The same per-session detail is written into the new pane's scrollback, so scrolling
181
+ back tomorrow answers the same question the chooser did.
182
+
165
183
  **The tree is the unit, not the folder you registered.** A repo registered twice — say `~/repo` and
166
184
  `~/repo/packages/app`, the ordinary shape for a product repo added by path — is **one** tree, and
167
185
  its sessions are told about each other. Two *worktrees* of one repo are **two** trees, and are not
@@ -178,7 +196,9 @@ Be clear about what this buys: **PMOS hosts your agent's terminal, it does not s
178
196
  the files.** Nothing here prevents a write, a checkout or a commit, and no spawn is ever refused
179
197
  because someone else is in the tree. It makes a shared tree visible; the discipline is still yours.
180
198
  (The role rides on `/api/sessions` rows as `tree` / `tree_role` / `tree_peers`; the tab-bar chip is
181
- not wired yet.)
199
+ not wired yet. `/api/workspaces` publishes each registration's `tree`, which is what lets the
200
+ chooser answer "who is already in there?" *before* anything is started; `/api/tree/peers` returns
201
+ each session described — `shown`, `label`, `doing` and a `risk` — rather than a list of ids.)
182
202
 
183
203
  A workspace anywhere **inside** a repo knows its branch — a subdirectory (`repo/packages/app`)
184
204
  counts, which matters for a product repo added by path. A folder with **no git** simply says so and
@@ -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
  };