create-pmos 0.19.0 → 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/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/workspace/lib/four-panel.js +259 -24
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +4 -4
- package/workspace/public/pm/data.js +12 -0
- package/workspace/public/pm/fixture.js +24 -1
- package/workspace/public/pm/panel-detail.js +226 -20
- package/workspace/server.js +19 -1
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
|
@@ -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
|
};
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "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.
|
|
9
|
+
"version": "0.20.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"private": true,
|
|
5
|
-
"description": "PMOS Terminal Workspace
|
|
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
|
|
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"
|
|
@@ -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
|
-
|
|
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.
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
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
|
-
|
|
290
|
-
|
|
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) => ({
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/workspace/server.js
CHANGED
|
@@ -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
|