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 +30 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/workspace/README.md +21 -1
- package/workspace/lib/four-panel.js +259 -24
- package/workspace/lib/tree-share.js +171 -13
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +4 -4
- package/workspace/public/index.html +6 -0
- package/workspace/public/pm/app.js +44 -7
- package/workspace/public/pm/data.js +42 -2
- package/workspace/public/pm/fixture.js +24 -1
- package/workspace/public/pm/panel-detail.js +226 -20
- package/workspace/public/pm/panel-tabs.js +192 -4
- package/workspace/public/pm/state.js +6 -0
- package/workspace/public/pm/tab-doc.js +5 -5
- package/workspace/public/pm/tab-task.js +1 -1
- package/workspace/public/pm/tab-term.js +17 -1
- package/workspace/server.js +87 -7
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.
|
|
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": {
|
package/payload/KIT-VERSION
CHANGED
package/workspace/README.md
CHANGED
|
@@ -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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
};
|