create-pmos 0.24.0 → 0.25.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 +201 -6
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/data.js +72 -15
- package/workspace/public/pm/panel-detail.js +327 -93
- package/workspace/public/pm/state.js +6 -0
- package/workspace/server.js +65 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit \u2014 run the PMOS product-management workflow (initiative \u2192 lane-fit spec \u2192 contract \u2192 build \u2192 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. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `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
|
@@ -57,6 +57,15 @@ const path = require('path');
|
|
|
57
57
|
|
|
58
58
|
const ARTIFACT_LINKS = 'artifact-links.jsonl';
|
|
59
59
|
const INITIATIVE_LINKS = 'initiative-links.jsonl';
|
|
60
|
+
/**
|
|
61
|
+
* The THIRD store, and the newest place this module has to refuse something.
|
|
62
|
+
*
|
|
63
|
+
* A link says *this file corresponds to that page*. A sync row says *the PM decided which way the
|
|
64
|
+
* content should travel, and here is what came back*. They are different facts with different
|
|
65
|
+
* lifetimes — a link outlives any number of syncs — so they are different files rather than a
|
|
66
|
+
* `direction` column bolted onto a link row, where every re-sync would supersede the link itself.
|
|
67
|
+
*/
|
|
68
|
+
const ARTIFACT_SYNC = 'artifact-sync.jsonl';
|
|
60
69
|
|
|
61
70
|
/** The destinations the surface can declare. A kind outside this set is refused, not stored. */
|
|
62
71
|
const LINK_KINDS = ['confluence', 'jira', 'google'];
|
|
@@ -70,12 +79,38 @@ const LINK_KINDS = ['confluence', 'jira', 'google'];
|
|
|
70
79
|
* resolved — a lookup performed BY A SESSION that holds the PM's MCP returned this record.
|
|
71
80
|
* Carries the destination's own title and the moment it was read.
|
|
72
81
|
* unreachable — a lookup was attempted and did not succeed. Carries the reason it failed.
|
|
82
|
+
* retracted — the PM withdrew this reference. It is a STATE and not an absence, because the
|
|
83
|
+
* store is append-only and "the PM removed this" is a fact worth keeping; a link
|
|
84
|
+
* deleted from disk would be a link that never existed, and the history of what the
|
|
85
|
+
* PM declared is the whole reason this file is a ledger. A later declaration for
|
|
86
|
+
* the same pair brings the reference back, because `linkView` reads the latest row
|
|
87
|
+
* and nothing here is a tombstone.
|
|
73
88
|
*
|
|
74
89
|
* `connected` was the word this surface used before, and it is retired rather than re-worded: PMOS
|
|
75
90
|
* is never connected to anything — a session is — so the word had no honest referent here. It is
|
|
76
91
|
* also the word that let a row the PM had merely declared read as a row PMOS had verified.
|
|
77
92
|
*/
|
|
78
|
-
const LINK_STATES = ['declared', 'resolved', 'unreachable'];
|
|
93
|
+
const LINK_STATES = ['declared', 'resolved', 'unreachable', 'retracted'];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Which way the content travels, and what happened. The sync store's own closed vocabulary.
|
|
97
|
+
*
|
|
98
|
+
* push — the repo file is the source; its content goes to the destination.
|
|
99
|
+
* adopt — the destination is the source; its content comes into the repo file.
|
|
100
|
+
*
|
|
101
|
+
* proposed — the PM chose this direction. A task was composed. NOTHING has been written anywhere,
|
|
102
|
+
* and this is the only state PMOS itself can produce.
|
|
103
|
+
* applied — a session holding the PM's MCP performed the write and reported back. Carries the
|
|
104
|
+
* profile that did it and the moment it did.
|
|
105
|
+
* failed — the attempt was made and did not succeed. Carries the reason.
|
|
106
|
+
*
|
|
107
|
+
* There is deliberately no `in sync`. The prototype's status line read `pushed · repo → jira · in
|
|
108
|
+
* sync 12:41`, where `12:41` was a literal in a seed module and `in sync` was a claim about two
|
|
109
|
+
* documents PMOS had never compared. `applied` says the smaller true thing: a session wrote, and
|
|
110
|
+
* here is who and when.
|
|
111
|
+
*/
|
|
112
|
+
const SYNC_DIRECTIONS = ['push', 'adopt'];
|
|
113
|
+
const SYNC_STATES = ['proposed', 'applied', 'failed'];
|
|
79
114
|
|
|
80
115
|
/**
|
|
81
116
|
* How a reference came to be in the store. This is the field that makes the store cleanable: the
|
|
@@ -384,6 +419,12 @@ function provenance(body, at) {
|
|
|
384
419
|
if (state === 'unreachable' && !reason) {
|
|
385
420
|
return { error: 'an unreachable reference must carry the reason the lookup failed' };
|
|
386
421
|
}
|
|
422
|
+
// Withdrawing a reference is the PM's act and nobody else's. A session that could retract could
|
|
423
|
+
// silently un-say something the PM said, which is the mirror image of the rule above — there, a
|
|
424
|
+
// caller may not claim to have read what it did not read; here, it may not claim to be the person.
|
|
425
|
+
if (state === 'retracted' && origin !== 'typed') {
|
|
426
|
+
return { error: 'a retraction is the PM withdrawing their own reference — send origin: typed' };
|
|
427
|
+
}
|
|
387
428
|
return {
|
|
388
429
|
origin,
|
|
389
430
|
state,
|
|
@@ -472,7 +513,7 @@ function initiativeLinkRow(body, { at } = {}) {
|
|
|
472
513
|
* the other. Rows are ordered by their recorded time with file order as the tiebreak, because two
|
|
473
514
|
* rows can share a timestamp and file order is the only thing that then says which was written second.
|
|
474
515
|
*/
|
|
475
|
-
function linkView(rows) {
|
|
516
|
+
function linkView(rows, { includeRetracted = false } = {}) {
|
|
476
517
|
const list = Array.isArray(rows) ? rows : [];
|
|
477
518
|
const seen = new Map();
|
|
478
519
|
list.forEach((row, i) => {
|
|
@@ -493,7 +534,154 @@ function linkView(rows) {
|
|
|
493
534
|
// this initiative was opened to make identifiable. They are reported as what they are.
|
|
494
535
|
origin: row.origin || null,
|
|
495
536
|
state: row.state || (row.origin ? 'declared' : 'unattributed'),
|
|
496
|
-
}))
|
|
537
|
+
}))
|
|
538
|
+
// A pair whose LATEST row is a retraction is not a link any more, and the surface is a statement
|
|
539
|
+
// of now. The row it came from is still on disk and still readable — `includeRetracted` is how
|
|
540
|
+
// a caller that wants the history rather than the statement asks for it — but the panel that
|
|
541
|
+
// asks "what is linked" gets an answer that does not include what the PM withdrew.
|
|
542
|
+
.filter((row) => includeRetracted || row.state !== 'retracted');
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/* ── the sync ledger ────────────────────────────────────────────────────────────────────────── */
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Validate + normalise one sync row, and enforce the rule this whole store exists for.
|
|
549
|
+
*
|
|
550
|
+
* THE RULE, which is `provenance`'s rule carried onto a stronger verb: **`applied` cannot be
|
|
551
|
+
* asserted, only evidenced.** A row may claim a session wrote to the destination only if it can name
|
|
552
|
+
* the profile whose session did it. A caller that says "applied" and cannot say by whom is not
|
|
553
|
+
* describing a write.
|
|
554
|
+
*
|
|
555
|
+
* It matters more here than it did for `resolved`, not less. A failed search is obviously
|
|
556
|
+
* unfinished; a *publish* that a PM ran in another window looks finished from inside this process,
|
|
557
|
+
* and the difference between "the task was composed" and "the page was updated" is invisible from
|
|
558
|
+
* here. `proposed` is therefore the ONLY state PMOS can produce on its own, and it says exactly what
|
|
559
|
+
* happened: a direction was chosen and a task was written down.
|
|
560
|
+
*/
|
|
561
|
+
function syncRow(body, { at } = {}) {
|
|
562
|
+
const artifact_path = String((body && body.artifact_path) || '').trim();
|
|
563
|
+
const kind = String((body && body.kind) || '').trim().toLowerCase();
|
|
564
|
+
const target = String((body && body.target) || '').trim();
|
|
565
|
+
const direction = String((body && body.direction) || '').trim().toLowerCase();
|
|
566
|
+
if (!artifact_path) return { ok: false, error: 'artifact_path is required' };
|
|
567
|
+
if (artifact_path.includes('..')) return { ok: false, error: 'artifact_path must be a repo-relative path' };
|
|
568
|
+
if (!LINK_KINDS.includes(kind)) return { ok: false, error: `kind must be one of: ${LINK_KINDS.join(', ')}` };
|
|
569
|
+
if (!target) return { ok: false, error: 'target is required — a sync needs the destination it was linked to' };
|
|
570
|
+
if (!SYNC_DIRECTIONS.includes(direction)) {
|
|
571
|
+
return { ok: false, error: `direction must be one of: ${SYNC_DIRECTIONS.join(', ')}` };
|
|
572
|
+
}
|
|
573
|
+
const state = String((body && body.state) || 'proposed').trim().toLowerCase();
|
|
574
|
+
if (!SYNC_STATES.includes(state)) return { ok: false, error: `state must be one of: ${SYNC_STATES.join(', ')}` };
|
|
575
|
+
const profile = body && body.profile ? String(body.profile).slice(0, 120) : null;
|
|
576
|
+
const reason = body && body.reason ? String(body.reason).slice(0, 300) : null;
|
|
577
|
+
const detail = body && body.detail ? String(body.detail).slice(0, 300) : null;
|
|
578
|
+
if (state === 'applied' && !profile) {
|
|
579
|
+
return { ok: false, error: 'an applied sync must name the profile whose session performed it — PMOS wrote nothing itself. Send profile with the session that did.' };
|
|
580
|
+
}
|
|
581
|
+
if (state === 'failed' && !reason) {
|
|
582
|
+
return { ok: false, error: 'a failed sync must carry the reason it failed' };
|
|
583
|
+
}
|
|
584
|
+
const initiative_id = body && body.initiative_id ? String(body.initiative_id) : null;
|
|
585
|
+
if (initiative_id && !validId(initiative_id)) return { ok: false, error: 'that initiative id is not a record name' };
|
|
586
|
+
return {
|
|
587
|
+
ok: true,
|
|
588
|
+
row: {
|
|
589
|
+
at: at || new Date().toISOString(),
|
|
590
|
+
initiative_id,
|
|
591
|
+
artifact_path,
|
|
592
|
+
kind,
|
|
593
|
+
target: target.slice(0, 300),
|
|
594
|
+
direction,
|
|
595
|
+
state,
|
|
596
|
+
profile,
|
|
597
|
+
reason,
|
|
598
|
+
// What the session reported about what it wrote — a page version, a commit, a summary of the
|
|
599
|
+
// change. Free text, and only ever the session's own words: nothing here composes one.
|
|
600
|
+
detail,
|
|
601
|
+
// Only a row that actually wrote something carries a write time, for the same reason only a
|
|
602
|
+
// row that read something carries a read time. Absent means nobody has written, which is a
|
|
603
|
+
// fact the surface renders rather than a gap it fills in with a clock.
|
|
604
|
+
applied_at: state === 'applied' ? (at || new Date().toISOString()) : null,
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* One row per (artifact, destination kind): the latest sync, which is what the status line says.
|
|
611
|
+
* Same read-time collapse as `linkView` and for the same reason — the store is the history, the
|
|
612
|
+
* surface is the statement of now.
|
|
613
|
+
*/
|
|
614
|
+
function syncView(rows) {
|
|
615
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
616
|
+
const seen = new Map();
|
|
617
|
+
list.forEach((row, i) => {
|
|
618
|
+
if (!row || typeof row !== 'object') return;
|
|
619
|
+
const key = String(row.artifact_path || '') + '|' + String(row.kind || '');
|
|
620
|
+
const prev = seen.get(key);
|
|
621
|
+
if (!prev) { seen.set(key, { row, i }); return; }
|
|
622
|
+
const a = String(prev.row.at || ''), b = String(row.at || '');
|
|
623
|
+
if (b > a || (b === a && i > prev.i)) seen.set(key, { row, i });
|
|
624
|
+
});
|
|
625
|
+
return [...seen.values()].sort((x, y) => x.i - y.i).map(({ row }) => ({ ...row }));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* The task a session performs to carry out a sync. Composed here, on the server, for the same
|
|
630
|
+
* reason `lookupTask` is: it is the thing the design turns on, and a string built in a click handler
|
|
631
|
+
* is a string no test ever sees.
|
|
632
|
+
*
|
|
633
|
+
* Note what changed from `lookupTask`, because it is the whole risk of this initiative. That task
|
|
634
|
+
* said *search, and do not create, publish or modify anything*. This one asks a session to WRITE.
|
|
635
|
+
* So it names its target and names what it will overwrite, in both directions, and forbids touching
|
|
636
|
+
* anything else — the cost of a vague target here is an overwritten Confluence page or an
|
|
637
|
+
* overwritten repo file, not a wrong pixel. It also asks the session to record the outcome through
|
|
638
|
+
* the same validated endpoint, where a claim of success will be refused unless it names the profile
|
|
639
|
+
* that made it.
|
|
640
|
+
*/
|
|
641
|
+
function syncTask({ direction, kind, artifact_path, target, profile } = {}) {
|
|
642
|
+
const d = String(direction || '').toLowerCase();
|
|
643
|
+
const k = String(kind || '').toLowerCase();
|
|
644
|
+
if (!SYNC_DIRECTIONS.includes(d)) return { ok: false, error: `direction must be one of: ${SYNC_DIRECTIONS.join(', ')}` };
|
|
645
|
+
if (!LINK_KINDS.includes(k)) return { ok: false, error: `kind must be one of: ${LINK_KINDS.join(', ')}` };
|
|
646
|
+
const file = String(artifact_path || '').slice(0, 300);
|
|
647
|
+
const dest = String(target || '').slice(0, 300);
|
|
648
|
+
if (!file) return { ok: false, error: 'artifact_path is required' };
|
|
649
|
+
if (!dest) return { ok: false, error: 'target is required' };
|
|
650
|
+
const what = k === 'jira' ? 'Jira issue' : k === 'confluence' ? 'Confluence page' : 'Google Doc';
|
|
651
|
+
const lines = d === 'push'
|
|
652
|
+
? [
|
|
653
|
+
`Publish the repository file ${file} to the ${what} at ${dest}.`,
|
|
654
|
+
'',
|
|
655
|
+
`Use your ${k} MCP tools. THE DESTINATION YOU WILL OVERWRITE IS ${dest} — write to that record`,
|
|
656
|
+
'and to nothing else. Do not create a new page, do not touch a parent or a child, and do not',
|
|
657
|
+
'edit any other record you happen to find while looking.',
|
|
658
|
+
'Convert the markdown to the destination\'s own format; keep the repository file unchanged.',
|
|
659
|
+
'Report the version or revision you wrote and the time you wrote it.',
|
|
660
|
+
]
|
|
661
|
+
: [
|
|
662
|
+
`Bring the ${what} at ${dest} into the repository file ${file}.`,
|
|
663
|
+
'',
|
|
664
|
+
`Use your ${k} MCP tools to READ ${dest}. THE FILE YOU WILL OVERWRITE IS ${file} — write to`,
|
|
665
|
+
'that path and to nothing else, and PRESERVE ITS EXISTING YAML FRONTMATTER BLOCK VERBATIM:',
|
|
666
|
+
'the repository\'s gates read those keys, and a document that arrives without them fails CI.',
|
|
667
|
+
'Convert the destination\'s content to markdown, and report what changed in the file.',
|
|
668
|
+
];
|
|
669
|
+
return {
|
|
670
|
+
ok: true,
|
|
671
|
+
direction: d,
|
|
672
|
+
profile: profile || null,
|
|
673
|
+
text: [
|
|
674
|
+
...lines,
|
|
675
|
+
'',
|
|
676
|
+
'Then record the outcome: POST /api/initiative/sync with state applied and the profile you ran',
|
|
677
|
+
`under (${profile || 'the profile holding this MCP'}), or state failed with the reason verbatim.`,
|
|
678
|
+
'A success that cannot name the profile that performed it will be refused, and refusal is',
|
|
679
|
+
'correct — PMOS wrote nothing itself and must not report that it did.',
|
|
680
|
+
'If you did not complete the write, say so plainly. An unreported failure reads here as a',
|
|
681
|
+
'sync that never happened, which is the honest outcome; a reported success that did not',
|
|
682
|
+
'happen is the defect this whole surface exists to refuse.',
|
|
683
|
+
].join('\n'),
|
|
684
|
+
};
|
|
497
685
|
}
|
|
498
686
|
|
|
499
687
|
/**
|
|
@@ -537,7 +725,7 @@ function lookupTask({ kind, subject, hint, profile } = {}) {
|
|
|
537
725
|
* artifacts are still listed, because the library can see them and claiming otherwise would hide
|
|
538
726
|
* real files), `{ok:true,…}` otherwise.
|
|
539
727
|
*/
|
|
540
|
-
function assemble({ id, rec, ladder, links, index, sessions, workspaces, anchor, artifactLinks, initiativeLinks, homeDir }) {
|
|
728
|
+
function assemble({ id, rec, ladder, links, index, sessions, workspaces, anchor, artifactLinks, initiativeLinks, artifactSync, homeDir }) {
|
|
541
729
|
if (!rec.ok) return { ok: false, id, error: rec.error };
|
|
542
730
|
const artifacts = artifactsFor(index, id);
|
|
543
731
|
const sess = sessionsFor({ sessions, workspaces, id });
|
|
@@ -565,16 +753,23 @@ function assemble({ id, rec, ladder, links, index, sessions, workspaces, anchor,
|
|
|
565
753
|
artifacts: mine(artifactLinks, (r) => r && (r.initiative_id === id
|
|
566
754
|
|| artifacts.some((a) => a.path === r.artifact_path))),
|
|
567
755
|
links: mine(initiativeLinks, (r) => r && r.initiative_id === id),
|
|
756
|
+
// Scoped the same way the links are, and collapsed the same way: this initiative's rows only,
|
|
757
|
+
// latest per (artifact, destination). A sync row for another initiative's copy of the same
|
|
758
|
+
// path is not this initiative's status line.
|
|
759
|
+
sync: syncView((artifactSync && artifactSync.ok ? artifactSync.rows : []).filter((r) => r
|
|
760
|
+
&& (r.initiative_id === id || artifacts.some((a) => a.path === r.artifact_path)))),
|
|
568
761
|
artifacts_error: artifactLinks && artifactLinks.ok ? null : (artifactLinks && artifactLinks.error) || null,
|
|
569
762
|
links_error: initiativeLinks && initiativeLinks.ok ? null : (initiativeLinks && initiativeLinks.error) || null,
|
|
763
|
+
sync_error: artifactSync && artifactSync.ok ? null : (artifactSync && artifactSync.error) || null,
|
|
570
764
|
},
|
|
571
765
|
connectors: connectors(homeDir),
|
|
572
766
|
};
|
|
573
767
|
}
|
|
574
768
|
|
|
575
769
|
module.exports = {
|
|
576
|
-
ARTIFACT_LINKS, INITIATIVE_LINKS,
|
|
770
|
+
ARTIFACT_LINKS, INITIATIVE_LINKS, ARTIFACT_SYNC,
|
|
771
|
+
LINK_KINDS, LINK_STATES, LINK_ORIGINS, MCP_FOR_KIND, SYNC_DIRECTIONS, SYNC_STATES,
|
|
577
772
|
validId, connectors, readRecord, parseTasks, artifactsFor, sessionsFor, anchorFor,
|
|
578
773
|
storeDir, readDeclarations, appendDeclaration, artifactLinkRow, initiativeLinkRow, assemble,
|
|
579
|
-
linkView, lookupTask,
|
|
774
|
+
linkView, lookupTask, syncRow, syncView, syncTask,
|
|
580
775
|
};
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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.25.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -38,11 +38,17 @@
|
|
|
38
38
|
*
|
|
39
39
|
* ── The one thing this file refuses to do ───────────────────────────────────────────────────
|
|
40
40
|
* Linking an artifact records a DECLARATION (`{artifact_path, kind, target}` appended to
|
|
41
|
-
* `artifact-links.jsonl`)
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
41
|
+
* `artifact-links.jsonl`) and choosing a sync direction records another (`artifact-sync.jsonl`).
|
|
42
|
+
* Neither makes an outbound request, because PMOS holds no credential: the request is made by an
|
|
43
|
+
* agent session holding the PM's own MCP, and what comes back here is a report (D53).
|
|
44
|
+
*
|
|
45
|
+
* `state.connectors` used to be published as a literal `[]` on every load. That was written when it
|
|
46
|
+
* was true of every machine, and it stayed after the server learned to DERIVE the answer from the
|
|
47
|
+
* PM's agent profiles — so a careful derivation shipped into a client that had already decided. The
|
|
48
|
+
* panel branches on it (`hasConnector`, `resolverFor`), so the visible cost was a control that never
|
|
49
|
+
* rendered and a machine with a Jira MCP that reported having none. It is now published from the
|
|
50
|
+
* payload, and the constant is gone rather than corrected: a constant cannot be wrong, which is
|
|
51
|
+
* exactly why it could not be trusted.
|
|
46
52
|
*/
|
|
47
53
|
|
|
48
54
|
import { state, setState, subscribe, FIXTURE } from './state.js';
|
|
@@ -299,20 +305,36 @@ function linksFor(payload) {
|
|
|
299
305
|
out.push({ kind: l.kind, label: l.declared, path: l.href || l.declared, st: l.state_text || l.state });
|
|
300
306
|
}
|
|
301
307
|
for (const r of (payload.declarations && payload.declarations.links) || []) {
|
|
302
|
-
|
|
308
|
+
// The row's OWN provenance, carried rather than replaced. `declared · not connected` was a
|
|
309
|
+
// literal written before the store had a state vocabulary, and it outlived it: a reference a
|
|
310
|
+
// session had genuinely resolved rendered the same seven words as one the PM had typed a second
|
|
311
|
+
// earlier. The panel derives its state text from these fields (`linkStateText`), so carrying
|
|
312
|
+
// them is the whole fix — there is no second opinion to keep in step.
|
|
313
|
+
out.push({
|
|
314
|
+
kind: r.kind, label: r.label, path: r.target,
|
|
315
|
+
state: r.state, origin: r.origin, profile: r.profile || null,
|
|
316
|
+
reason: r.reason || null, resolved_at: r.resolved_at || null,
|
|
317
|
+
});
|
|
303
318
|
}
|
|
304
319
|
return out;
|
|
305
320
|
}
|
|
306
321
|
|
|
307
|
-
/** Artifacts, with any declared external destination attached. */
|
|
322
|
+
/** Artifacts, with any declared external destination and the latest sync for it attached. */
|
|
308
323
|
function artsFor(payload) {
|
|
309
324
|
const dest = new Map();
|
|
310
325
|
for (const r of (payload.declarations && payload.declarations.artifacts) || []) {
|
|
311
|
-
|
|
326
|
+
// The server has already collapsed the store to one row per pair and dropped what the PM
|
|
327
|
+
// retracted, so what arrives here is the statement of now.
|
|
328
|
+
dest.set(r.artifact_path, { kind: r.kind, target: r.target, state: r.state, origin: r.origin });
|
|
329
|
+
}
|
|
330
|
+
const sync = new Map();
|
|
331
|
+
for (const r of (payload.declarations && payload.declarations.sync) || []) {
|
|
332
|
+
sync.set(r.artifact_path + '|' + r.kind, r);
|
|
312
333
|
}
|
|
313
334
|
return (payload.artifacts || []).map((a) => {
|
|
314
335
|
const d = dest.get(a.path);
|
|
315
|
-
|
|
336
|
+
if (!d) return { role: a.role, path: a.path };
|
|
337
|
+
return { role: a.role, path: a.path, dest: d, sync: sync.get(a.path + '|' + d.kind) || null };
|
|
316
338
|
});
|
|
317
339
|
}
|
|
318
340
|
|
|
@@ -662,7 +684,14 @@ async function hydrate(slug) {
|
|
|
662
684
|
// and its footer ("read failed · as of …") is entitled to say when it was attempted.
|
|
663
685
|
const asOf = readStamp();
|
|
664
686
|
hydrated[slug] = { ...recordFrom(payload), asOf };
|
|
665
|
-
setState((s) => ({
|
|
687
|
+
setState((s) => ({
|
|
688
|
+
records: { ...s.records, [slug]: hydrated[slug] },
|
|
689
|
+
// The server's `{configured:[{kind, profile, server, declared}], declaredBy, note}`, straight
|
|
690
|
+
// through. Published on every hydration rather than once at boot, because a profile added
|
|
691
|
+
// while the workspace is open should reach the panel on the next selection rather than on the
|
|
692
|
+
// next restart — and because the payload is the only place this fact is ever produced.
|
|
693
|
+
connectors: payload && payload.connectors ? payload.connectors : s.connectors,
|
|
694
|
+
}));
|
|
666
695
|
return payload;
|
|
667
696
|
} finally { inFlight.delete(slug); }
|
|
668
697
|
}
|
|
@@ -780,7 +809,9 @@ export async function loadRepos() {
|
|
|
780
809
|
const sel = state.sel || (p ? firstSlugOf(p.name) : null);
|
|
781
810
|
setState({
|
|
782
811
|
wsId: p ? p.ws : null,
|
|
783
|
-
|
|
812
|
+
// NOT initialised to a value here. What is resolvable is the server's derivation from the PM's
|
|
813
|
+
// agent profiles, it arrives with the first `/api/initiative` payload, and seeding a `[]` in the
|
|
814
|
+
// meantime is not a placeholder — it is an answer, and the panel would render it as one.
|
|
784
815
|
product: state.product && products.has(state.product) ? state.product : first,
|
|
785
816
|
sel,
|
|
786
817
|
});
|
|
@@ -992,7 +1023,7 @@ export async function saveDoc({ ws, path, base, text }) {
|
|
|
992
1023
|
* Appends ONE declaration to `artifact-links.jsonl`. It contacts nothing: PMOS holds no credential,
|
|
993
1024
|
* so this records what the PM says the mapping is and says nothing about the destination.
|
|
994
1025
|
*/
|
|
995
|
-
export async function recordArtifactLink({ artifact_path, slug, kind, target }) {
|
|
1026
|
+
export async function recordArtifactLink({ artifact_path, slug, kind, target, origin, state: st }) {
|
|
996
1027
|
if (FIXTURE) return { ok: true };
|
|
997
1028
|
const p = products.get(currentProductName());
|
|
998
1029
|
const out = await postJson('/api/initiative/link', {
|
|
@@ -1001,11 +1032,37 @@ export async function recordArtifactLink({ artifact_path, slug, kind, target })
|
|
|
1001
1032
|
// The surface labels a Google destination `google doc`; the store's vocabulary is `google`.
|
|
1002
1033
|
kind: String(kind || '').startsWith('google') ? 'google' : kind,
|
|
1003
1034
|
target,
|
|
1035
|
+
// Passed through rather than defaulted here. The server owns the rules about which origin may
|
|
1036
|
+
// claim which state — including that a retraction is the PM's alone — and a client that filled
|
|
1037
|
+
// these in itself would be a second opinion about provenance, which is the one thing this store
|
|
1038
|
+
// must have exactly one of.
|
|
1039
|
+
origin, state: st,
|
|
1004
1040
|
});
|
|
1005
1041
|
if (out && out.ok === false) console.warn('[pm] the artifact link was NOT recorded: ' + out.error);
|
|
1006
1042
|
return out;
|
|
1007
1043
|
}
|
|
1008
1044
|
|
|
1045
|
+
/**
|
|
1046
|
+
* panel-detail.js — `recordSync({artifact_path, slug, kind, target, direction, profile})`.
|
|
1047
|
+
*
|
|
1048
|
+
* Records the direction the PM chose and returns the TASK a session will run to carry it out. It
|
|
1049
|
+
* syncs nothing: the row lands as `proposed`, which is the only state PMOS can produce on its own,
|
|
1050
|
+
* and it stays that way until a session that actually performed the write reports back and names
|
|
1051
|
+
* itself. Nothing here contacts Confluence, Jira or Google, and nothing here can.
|
|
1052
|
+
*/
|
|
1053
|
+
export async function recordSync({ artifact_path, slug, kind, target, direction, profile }) {
|
|
1054
|
+
if (FIXTURE) return { ok: false, error: 'fixture mode records no sync' };
|
|
1055
|
+
const p = products.get(currentProductName());
|
|
1056
|
+
const out = await postJson('/api/initiative/sync', {
|
|
1057
|
+
ws: state.wsId || (p ? p.ws : ''),
|
|
1058
|
+
initiative_id: slug, artifact_path,
|
|
1059
|
+
kind: String(kind || '').startsWith('google') ? 'google' : kind,
|
|
1060
|
+
target, direction, profile: profile || null,
|
|
1061
|
+
});
|
|
1062
|
+
if (out && out.ok === false) console.warn('[pm] the sync direction was NOT recorded: ' + out.error);
|
|
1063
|
+
return out;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1009
1066
|
/**
|
|
1010
1067
|
* panel-detail.js — `lookupTask({kind, subject, hint, profile})`.
|
|
1011
1068
|
*
|
|
@@ -1018,13 +1075,13 @@ export async function lookupTask({ kind, subject, hint, profile }) {
|
|
|
1018
1075
|
return postJson('/api/initiative/lookup-task', { kind, subject, hint, profile });
|
|
1019
1076
|
}
|
|
1020
1077
|
|
|
1021
|
-
/** panel-detail.js — `recordInitiativeLink({slug, kind, label, path,
|
|
1022
|
-
export async function recordInitiativeLink({ slug, kind, label, path }) {
|
|
1078
|
+
/** panel-detail.js — `recordInitiativeLink({slug, kind, label, path, origin, state})` → `initiative-links.jsonl`. */
|
|
1079
|
+
export async function recordInitiativeLink({ slug, kind, label, path, origin, state: st }) {
|
|
1023
1080
|
if (FIXTURE) return { ok: true };
|
|
1024
1081
|
const p = products.get(currentProductName());
|
|
1025
1082
|
const out = await postJson('/api/initiative/link', {
|
|
1026
1083
|
store: 'initiative', ws: state.wsId || (p ? p.ws : ''),
|
|
1027
|
-
initiative_id: slug, kind, label, target: path,
|
|
1084
|
+
initiative_id: slug, kind, label, target: path, origin, state: st,
|
|
1028
1085
|
});
|
|
1029
1086
|
if (out && out.ok === false) console.warn('[pm] the reference link was NOT recorded: ' + out.error);
|
|
1030
1087
|
return out;
|
|
@@ -35,11 +35,18 @@
|
|
|
35
35
|
* rendering in the real app is "the beautiful shell" the PRD names as a failure mode.
|
|
36
36
|
*
|
|
37
37
|
* ── The one place the design's fiction is refused ────────────────────────────────────────────
|
|
38
|
-
* The artifact sync panel
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
38
|
+
* The artifact sync panel keeps the design's SHAPE — the statement, then the two approvals — and
|
|
39
|
+
* replaces its claims. In FIXTURE mode the prototype's copy renders verbatim, because the parity
|
|
40
|
+
* comparator measures this surface against the design file and a re-worded label is a defect there.
|
|
41
|
+
* In real mode the panel says only what a row in the store says: nothing chosen yet, a direction
|
|
42
|
+
* proposed and unreported, or a named session's write at a named time. The design's
|
|
43
|
+
* `2 sections differ` and `in sync 12:41` are unreachable from the real path — a difference summary
|
|
44
|
+
* over a destination nobody contacted is precisely the watermelon this repo has a word for.
|
|
45
|
+
*
|
|
46
|
+
* The two approvals ACT. They record the direction the PM chose and show the task a session will
|
|
47
|
+
* run; they do not sync, because PMOS holds no credential and makes no outbound request (D53). The
|
|
48
|
+
* property that has to survive every future change to this file: there is no path from a click to a
|
|
49
|
+
* rendered claim about a destination without a profile in between.
|
|
43
50
|
*/
|
|
44
51
|
|
|
45
52
|
import { el, setChildren } from './dom.js';
|
|
@@ -162,21 +169,27 @@ function hasConnector(kind) {
|
|
|
162
169
|
const NO_CONNECTOR = 'no connector configured — nothing was read, and nothing will be written.';
|
|
163
170
|
|
|
164
171
|
/**
|
|
165
|
-
*
|
|
172
|
+
* What the two sync approvals now do, and why they stopped being inert.
|
|
173
|
+
*
|
|
174
|
+
* They used to be dead in every real session — `const inert = FIXTURE ? !live : true` — because
|
|
175
|
+
* publishing and read-back were out of scope by name and an inert control that states its reason
|
|
176
|
+
* beats a control that lies. That was right on the day it shipped and it stopped being right when
|
|
177
|
+
* the PM asked for the capability, because the reason it gave was *waiting on a decision the PM had
|
|
178
|
+
* not made*, and they made it.
|
|
166
179
|
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* as the fabricated identifier one level up.
|
|
180
|
+
* What a click does now is the whole design in one sentence: it records the DIRECTION the PM chose
|
|
181
|
+
* and shows the TASK a session will run to carry it out. It does not sync. PMOS holds no credential
|
|
182
|
+
* and makes no outbound request (D53) — the session holds the credential, performs the write, and
|
|
183
|
+
* reports back through a store that refuses any claim of success that cannot name the profile that
|
|
184
|
+
* made it.
|
|
173
185
|
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* about
|
|
186
|
+
* So the surface can now say three different true things where it previously said one: nothing has
|
|
187
|
+
* been chosen; a direction was chosen and nobody has reported; a named session wrote at a named
|
|
188
|
+
* time. The word it still cannot say is `in sync`, because that is a claim about two documents PMOS
|
|
189
|
+
* has never compared.
|
|
177
190
|
*/
|
|
178
|
-
const
|
|
179
|
-
+ '
|
|
191
|
+
const SYNC_NOT_A_SYNC = 'PMOS does not perform this itself — it records which way you chose and '
|
|
192
|
+
+ 'composes the task. The session you run it in holds the credential; PMOS holds none.';
|
|
180
193
|
|
|
181
194
|
/* ── the sticky detail header (lines 332–335) ───────────────────────────────────────────────
|
|
182
195
|
* Built once and re-used, so panel 3's chrome keeps its identity across renders the way panel 2's
|
|
@@ -344,6 +357,22 @@ function linkStateStyle(st) {
|
|
|
344
357
|
return { color: 'var(--muted-foreground)', label: 'declared' };
|
|
345
358
|
}
|
|
346
359
|
|
|
360
|
+
/* ── the small controls a recorded link needs to be the PM's ────────────────────────────────
|
|
361
|
+
* `edit` and `unlink`. Neither existed, and their absence made a paste error permanent: the store
|
|
362
|
+
* is append-only and supersedes on read, so CHANGING a link was always one row away — there was
|
|
363
|
+
* simply nothing in the UI that could write that row. Removing one had no representation at all.
|
|
364
|
+
*
|
|
365
|
+
* They are rendered only in real mode. Fixture mode is a transcription of the prototype, pinned
|
|
366
|
+
* line-for-line by the parity comparator, and the prototype has no such controls; adding them there
|
|
367
|
+
* would be a parity failure dressed as a feature.
|
|
368
|
+
*/
|
|
369
|
+
const MINI_BTN = 'font-family:var(--mono);font-size:10px;padding:3px 8px;min-height:24px;'
|
|
370
|
+
+ 'background:transparent;border:1px solid var(--border);color:var(--muted-foreground)';
|
|
371
|
+
|
|
372
|
+
function miniBtn(label, onClick, extra) {
|
|
373
|
+
return el('button', { onClick, style: MINI_BTN + (extra || ''), hover: 'border-color:var(--ring);color:var(--foreground)' }, label);
|
|
374
|
+
}
|
|
375
|
+
|
|
347
376
|
/**
|
|
348
377
|
* The one sentence a link row is allowed to say about itself, built from what was actually observed.
|
|
349
378
|
* Every branch names its evidence or admits it has none — there is deliberately no branch that
|
|
@@ -363,7 +392,13 @@ function linkStateText(lk) {
|
|
|
363
392
|
function linksRow(slug) {
|
|
364
393
|
const rec = records()[slug] || {};
|
|
365
394
|
const manual = (state.initLinks || {})[slug] || [];
|
|
366
|
-
|
|
395
|
+
// A reference the PM withdrew in this window is dropped here as well as on disk. The server
|
|
396
|
+
// already omits retracted pairs from what it publishes, so this only covers the moment between
|
|
397
|
+
// the click and the next hydration — without it the row would linger until a reload and the PM
|
|
398
|
+
// would reasonably conclude the control had failed.
|
|
399
|
+
const dropped = (state.linkGone || {})[slug] || [];
|
|
400
|
+
const gone = (lk) => dropped.some((d) => d.kind === lk.kind && d.target === (lk.target || lk.path));
|
|
401
|
+
const links = [...(rec.links || []), ...manual].filter((lk) => !gone(lk));
|
|
367
402
|
const openSrc = (state.linkForm || {})[slug] || null;
|
|
368
403
|
|
|
369
404
|
/**
|
|
@@ -378,31 +413,62 @@ function linksRow(slug) {
|
|
|
378
413
|
(s) => ({
|
|
379
414
|
initLinks: { ...s.initLinks, [slug]: [...((s.initLinks || {})[slug] || []), link] },
|
|
380
415
|
linkForm: { ...s.linkForm, [slug]: null },
|
|
416
|
+
// A pair the PM re-declares is no longer withdrawn. Clearing this is what makes R-B3's
|
|
417
|
+
// "a later declaration brings it back" true on the screen as well as in the store.
|
|
418
|
+
linkGone: { ...s.linkGone, [slug]: ((s.linkGone || {})[slug] || []).filter((d) => !(d.kind === src && d.target === target)) },
|
|
381
419
|
}),
|
|
382
420
|
() => declare('recordInitiativeLink', { slug, ...link }),
|
|
383
421
|
);
|
|
384
422
|
};
|
|
385
423
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
424
|
+
/**
|
|
425
|
+
* Withdraw a reference. This appends a `retracted` row — it does not delete anything, because the
|
|
426
|
+
* store is a history of what the PM declared and a deleted row is a declaration that never
|
|
427
|
+
* happened. The server refuses a retraction that does not come from the PM.
|
|
428
|
+
*/
|
|
429
|
+
const retract = (lk) => {
|
|
430
|
+
const target = lk.target || lk.path;
|
|
431
|
+
setState(
|
|
432
|
+
(s) => ({
|
|
433
|
+
linkGone: { ...s.linkGone, [slug]: [...((s.linkGone || {})[slug] || []), { kind: lk.kind, target }] },
|
|
434
|
+
initLinks: { ...s.initLinks, [slug]: ((s.initLinks || {})[slug] || []).filter((x) => !(x.kind === lk.kind && (x.target || x.path) === target)) },
|
|
435
|
+
}),
|
|
436
|
+
() => declare('recordInitiativeLink', {
|
|
437
|
+
slug, kind: lk.kind, label: lk.label, path: target, origin: 'typed', state: 'retracted',
|
|
438
|
+
}),
|
|
439
|
+
);
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const rows = links.map((lk) => {
|
|
443
|
+
const body = el('button', {
|
|
444
|
+
onClick: () => openDoc(slug, lk.path, String(lk.kind).toUpperCase()),
|
|
445
|
+
// In fixture mode this button IS the row and carries the prototype's own negative margin. In
|
|
446
|
+
// real mode it sits inside a wrapper that carries it instead, and the button flexes to fill.
|
|
447
|
+
style: 'display:flex;gap:8px;align-items:center;padding:5px 6px;min-height:34px;'
|
|
448
|
+
+ (FIXTURE ? 'margin:0 -6px' : 'flex:1;min-width:0'),
|
|
449
|
+
hover: 'background:var(--accent)',
|
|
450
|
+
},
|
|
451
|
+
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)),
|
|
452
|
+
el('span', { style: 'font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(lk.label)),
|
|
453
|
+
// A row that CARRIES provenance states what was observed; a row that does not keeps whatever
|
|
454
|
+
// status copy it came with. The branch is on the row, not on the mode, which is the point: the
|
|
455
|
+
// prototype's seeded rows (`auto · session`) render unchanged so the parity comparator still
|
|
456
|
+
// sees a like-for-like surface, while every row this surface actually records — in either mode —
|
|
457
|
+
// is held to the closed vocabulary. Branching on FIXTURE instead would have left a real row
|
|
458
|
+
// in a fixture-mode session rendering `undefined`.
|
|
459
|
+
(lk.state || lk.origin)
|
|
460
|
+
? el('span', {
|
|
461
|
+
style: 'margin-left:auto;flex:none;font-family:var(--mono);font-size:10px;color:'
|
|
462
|
+
+ linkStateStyle(lk.state || 'declared').color,
|
|
463
|
+
}, iv(linkStateText(lk)))
|
|
464
|
+
: el('span', { style: 'margin-left:auto;flex:none;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, iv(lk.st)),
|
|
465
|
+
);
|
|
466
|
+
// The prototype's row is a single button and the comparator measures it as one. Only real mode
|
|
467
|
+
// grows the wrapper and the withdraw control.
|
|
468
|
+
if (FIXTURE) return body;
|
|
469
|
+
return el('div', { style: 'display:flex;gap:6px;align-items:center;margin:0 -6px;min-width:0' },
|
|
470
|
+
body, miniBtn('unlink', () => retract(lk)));
|
|
471
|
+
});
|
|
406
472
|
|
|
407
473
|
// Which destinations a session could actually look up, and under which profile. Derived by the
|
|
408
474
|
// server from the PM's agent profiles — never a constant, and never a claim that PMOS is connected.
|
|
@@ -484,16 +550,69 @@ function linksRow(slug) {
|
|
|
484
550
|
/* ── 5b · ARTIFACTS (lines 399–437, derivation 1779–1795) ───────────────────────────────────
|
|
485
551
|
* Repo files, each with its role and path; clicking one opens the document. An artifact may carry
|
|
486
552
|
* an external DESTINATION — recorded as a declaration, never as an outbound call — and once linked
|
|
487
|
-
* it grows the ⇄ pill, a `sync` button, and the inline sync panel behind it.
|
|
553
|
+
* it grows the ⇄ pill, a `sync` button, `edit`/`unlink`, and the inline sync panel behind it.
|
|
554
|
+
*
|
|
555
|
+
* The sync panel is where this surface is at its most careful, and the care moved rather than
|
|
556
|
+
* relaxed. It used to be careful by refusing to act; it is now careful about WHAT A CLICK MEANS. A
|
|
557
|
+
* direction is a decision the PM makes and PMOS records; the write is performed by a session
|
|
558
|
+
* holding the PM's own MCP; and the status line reports the latest recorded row and nothing else.
|
|
559
|
+
* There is no path from a click to a claim about the destination without a profile in between —
|
|
560
|
+
* that sentence is the acceptance criterion for this whole region.
|
|
561
|
+
*/
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* The status line beside the pill, derived from the latest recorded sync row.
|
|
488
565
|
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
566
|
+
* Everything it can say is something a row on disk says. It cannot say `in sync`, because that
|
|
567
|
+
* compares two documents PMOS has never read; it cannot invent a time, because the only times here
|
|
568
|
+
* are the ones the store stamped. `proposed` says the quiet true thing that the old panel had no
|
|
569
|
+
* way to express: you chose a direction, and nobody has reported doing it.
|
|
493
570
|
*/
|
|
571
|
+
function syncStatus(sy) {
|
|
572
|
+
if (!sy) return null;
|
|
573
|
+
const hm = (t) => String(t || '').slice(11, 16);
|
|
574
|
+
const arrow = sy.direction === 'push' ? 'repo → ' + sy.kind : sy.kind + ' → repo';
|
|
575
|
+
if (sy.state === 'applied') {
|
|
576
|
+
return {
|
|
577
|
+
color: 'var(--success)',
|
|
578
|
+
text: arrow + ' · applied by ' + (sy.profile || 'a session') + (hm(sy.applied_at) ? ' · ' + hm(sy.applied_at) : ''),
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
if (sy.state === 'failed') {
|
|
582
|
+
return { color: 'var(--warning)', text: arrow + ' · failed · ' + (sy.reason || 'no reason recorded') };
|
|
583
|
+
}
|
|
584
|
+
return {
|
|
585
|
+
color: 'var(--muted-foreground)',
|
|
586
|
+
text: arrow + ' · proposed' + (hm(sy.at) ? ' ' + hm(sy.at) : '') + ' · no session has reported',
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** What the open sync panel says about where things stand. One sentence per recorded state. */
|
|
591
|
+
function syncSummary(sy, dest) {
|
|
592
|
+
const hm = (t) => String(t || '').slice(11, 16);
|
|
593
|
+
if (!sy) {
|
|
594
|
+
return 'no direction has been chosen for this document yet. nothing has been written either way,'
|
|
595
|
+
+ ' and PMOS has read nothing from ' + dest.kind + '.';
|
|
596
|
+
}
|
|
597
|
+
const way = sy.direction === 'push' ? 'push to ' + sy.kind : 'adoption from ' + sy.kind;
|
|
598
|
+
if (sy.state === 'applied') {
|
|
599
|
+
return sy.profile + ' reported applying this ' + way + (hm(sy.applied_at) ? ' at ' + hm(sy.applied_at) : '')
|
|
600
|
+
+ '.' + (sy.detail ? ' it reported: ' + sy.detail : '');
|
|
601
|
+
}
|
|
602
|
+
if (sy.state === 'failed') {
|
|
603
|
+
return 'the last attempt at this ' + way + ' failed — ' + (sy.reason || 'no reason was recorded')
|
|
604
|
+
+ '. nothing was written.';
|
|
605
|
+
}
|
|
606
|
+
return 'a ' + way + ' was proposed' + (hm(sy.at) ? ' at ' + hm(sy.at) : '')
|
|
607
|
+
+ '. the task below is what performs it, and no session has reported back.';
|
|
608
|
+
}
|
|
609
|
+
|
|
494
610
|
function artifactRow(slug, a) {
|
|
495
611
|
const key = FX.artKey(slug, a.path);
|
|
496
|
-
|
|
612
|
+
// `hasOwnProperty`, not `||`. An unlinked artifact records `null` under its key, and a falsy check
|
|
613
|
+
// would fall straight through to the server's `a.dest` and re-draw the link the PM just withdrew.
|
|
614
|
+
const over = state.artLinks || {};
|
|
615
|
+
const dest = Object.prototype.hasOwnProperty.call(over, key) ? over[key] : (a.dest || null);
|
|
497
616
|
const syncState = (state.artSyncState || {})[key] || null;
|
|
498
617
|
const syncOpen = !!(state.artSyncOpen || {})[key];
|
|
499
618
|
|
|
@@ -506,28 +625,76 @@ function artifactRow(slug, a) {
|
|
|
506
625
|
el('span', { style: 'font-family:var(--mono);font-size:10px;color:var(--muted-foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(a.path)),
|
|
507
626
|
);
|
|
508
627
|
|
|
628
|
+
const openHere = (state.artLinkForm || {})[key] || null;
|
|
629
|
+
const inputId = 'artref-' + key.replace(/[^A-Za-z0-9]/g, '-');
|
|
630
|
+
const conn = (state.connectors && state.connectors.configured) || [];
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Record a destination for this artifact. Used by BOTH the first link and an edit of one already
|
|
634
|
+
* recorded — they are the same act as far as the store is concerned, because it is append-only and
|
|
635
|
+
* supersedes on read. That is why editing needed no new endpoint and no mutation: the second row
|
|
636
|
+
* simply wins, and the first stays as the history of what the PM once believed.
|
|
637
|
+
*/
|
|
638
|
+
const submit = (kind) => {
|
|
639
|
+
const node = document.getElementById(inputId);
|
|
640
|
+
const target = String((node && node.value) || '').trim();
|
|
641
|
+
if (!target) return;
|
|
642
|
+
const d = { kind, target, origin: 'typed', state: 'declared' };
|
|
643
|
+
setState((s) => ({
|
|
644
|
+
artLinks: { ...s.artLinks, [key]: d },
|
|
645
|
+
artLinkForm: { ...s.artLinkForm, [key]: null },
|
|
646
|
+
// A re-linked pair discards whatever sync was recorded against the OLD destination in this
|
|
647
|
+
// window. Carrying it over would attach a status line to a record it was never about.
|
|
648
|
+
artSyncTask: { ...s.artSyncTask, [key]: null },
|
|
649
|
+
}));
|
|
650
|
+
declare('recordArtifactLink', { artifact_path: a.path, slug, kind, target, origin: 'typed', state: 'declared' });
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
/** Withdraw this artifact's destination — an appended `retracted` row, never a deletion. */
|
|
654
|
+
const retract = () => {
|
|
655
|
+
if (!dest) return;
|
|
656
|
+
setState((s) => ({
|
|
657
|
+
artLinks: { ...s.artLinks, [key]: null },
|
|
658
|
+
artSyncOpen: { ...s.artSyncOpen, [key]: false },
|
|
659
|
+
artSyncTask: { ...s.artSyncTask, [key]: null },
|
|
660
|
+
}));
|
|
661
|
+
declare('recordArtifactLink', {
|
|
662
|
+
artifact_path: a.path, slug, kind: dest.kind, target: dest.target, origin: 'typed', state: 'retracted',
|
|
663
|
+
});
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
/** The paste-a-reference form. `initial` pre-fills it when the PM is editing rather than adding. */
|
|
667
|
+
const destForm = (kind, initial) => el('div', { style: 'margin-top:6px;padding:8px 10px;border:1px solid var(--border);background:var(--background)' },
|
|
668
|
+
el('div', { style: 'font-size:11.5px;color:var(--muted-foreground);line-height:1.5;margin-bottom:6px' },
|
|
669
|
+
iv(conn.some((c) => c.kind === kind)
|
|
670
|
+
? 'paste the ' + kind + ' reference this file corresponds to. a session on '
|
|
671
|
+
+ (conn.find((c) => c.kind === kind) || {}).profile + ' can find it for you — PMOS reads nothing.'
|
|
672
|
+
: 'paste the ' + kind + ' reference this file corresponds to. nothing here can look it up,'
|
|
673
|
+
+ ' so what you type is recorded as your own declaration.')),
|
|
674
|
+
el('input', {
|
|
675
|
+
id: inputId,
|
|
676
|
+
value: initial || '',
|
|
677
|
+
placeholder: kind === 'jira' ? 'ATL-482 or its URL' : 'the page or document URL',
|
|
678
|
+
style: 'width:100%;font-family:var(--mono);font-size:11px;padding:5px 7px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
|
|
679
|
+
}),
|
|
680
|
+
el('div', { style: 'display:flex;gap:6px;margin-top:7px;flex-wrap:wrap' },
|
|
681
|
+
el('button', {
|
|
682
|
+
onClick: () => submit(kind),
|
|
683
|
+
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',
|
|
684
|
+
}, 'record'),
|
|
685
|
+
el('button', {
|
|
686
|
+
onClick: () => setState((s) => ({ artLinkForm: { ...s.artLinkForm, [key]: null } })),
|
|
687
|
+
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)',
|
|
688
|
+
}, 'cancel'),
|
|
689
|
+
),
|
|
690
|
+
);
|
|
691
|
+
|
|
509
692
|
if (!dest) {
|
|
510
693
|
// Unlinked: the three destinations it could be linked to.
|
|
511
694
|
// In FIXTURE mode the prototype's own destination copy is drawn, so the parity comparator still
|
|
512
695
|
// sees a like-for-like surface. In REAL mode a click opens a FORM: `linkArtDest` built the
|
|
513
696
|
// destination out of the artifact's own FILENAME — `PM / <basename>`, `ATL epic · <basename>` —
|
|
514
697
|
// 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
|
-
|
|
531
698
|
const linkRow = el('div', { style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:3px 0 0' },
|
|
532
699
|
el('span', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)' }, 'link'),
|
|
533
700
|
FX.ART_LINK_SRCS.map((ls) => el('button', {
|
|
@@ -546,34 +713,13 @@ function artifactRow(slug, a) {
|
|
|
546
713
|
}, '+ ', iv(ls.l))),
|
|
547
714
|
);
|
|
548
715
|
|
|
549
|
-
|
|
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);
|
|
716
|
+
return el('div', { style: 'min-width:0' }, head, linkRow, openHere ? destForm(openHere, '') : null);
|
|
574
717
|
}
|
|
575
718
|
|
|
576
|
-
// Linked: the destination pill, the sync button,
|
|
719
|
+
// Linked: the destination pill, the sync button, the PM's own controls, and the status line —
|
|
720
|
+
// which in real mode is what the store recorded, and in fixture mode is the prototype's own.
|
|
721
|
+
const recorded = FIXTURE ? null : syncStatus(a.sync || null);
|
|
722
|
+
const shown = FIXTURE ? syncState : (recorded || syncState);
|
|
577
723
|
const pillRow = el('div', { style: 'display:flex;gap:7px;align-items:center;flex-wrap:wrap;padding:3px 0 0 0' },
|
|
578
724
|
el('span', { style: 'font-family:var(--mono);font-size:10px;padding:1px 6px;background:var(--brand-wash);color:var(--brand)' },
|
|
579
725
|
'⇄ ', iv(dest.kind), ' · ', iv(dest.target)),
|
|
@@ -582,15 +728,22 @@ function artifactRow(slug, a) {
|
|
|
582
728
|
style: 'font-family:var(--mono);font-size:10.5px;padding:3px 10px;min-height:26px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
|
|
583
729
|
hover: 'border-color:var(--ring)',
|
|
584
730
|
}, 'sync'),
|
|
585
|
-
|
|
731
|
+
// Real mode only: the prototype has no edit or unlink control and the comparator measures the
|
|
732
|
+
// fixture surface against it.
|
|
733
|
+
FIXTURE ? null : miniBtn('edit', () => setState((s) => ({
|
|
734
|
+
artLinkForm: { ...s.artLinkForm, [key]: (s.artLinkForm || {})[key] ? null : dest.kind },
|
|
735
|
+
}))),
|
|
736
|
+
FIXTURE ? null : miniBtn('unlink', retract),
|
|
737
|
+
shown && el('span', { style: 'font-family:var(--mono);font-size:10px;color:' + shown.color }, iv(shown.text)),
|
|
586
738
|
);
|
|
587
739
|
|
|
588
740
|
let panel = null;
|
|
589
|
-
if (syncOpen) {
|
|
741
|
+
if (syncOpen && FIXTURE) {
|
|
742
|
+
// The prototype's panel, transcribed. Its two approvals write the seed module's own result
|
|
743
|
+
// strings; nothing here reaches a store, and the parity comparator measures this surface against
|
|
744
|
+
// the design file character for character.
|
|
590
745
|
const live = hasConnector(dest.kind);
|
|
591
|
-
|
|
592
|
-
// surface. REAL mode never enables them: they are publishing and read-back, both out of scope.
|
|
593
|
-
const inert = FIXTURE ? !live : true;
|
|
746
|
+
const inert = !live;
|
|
594
747
|
const decide = (make) => () => {
|
|
595
748
|
if (inert) return;
|
|
596
749
|
setState((s) => ({
|
|
@@ -601,7 +754,7 @@ function artifactRow(slug, a) {
|
|
|
601
754
|
const off = inert ? ';opacity:.5;cursor:not-allowed' : '';
|
|
602
755
|
panel = el('div', { style: 'margin-top:6px;padding:9px 11px;border:1px solid var(--border);background:var(--background)' },
|
|
603
756
|
el('div', { style: 'font-size:12px;line-height:1.5;color:var(--muted-foreground)' },
|
|
604
|
-
iv(
|
|
757
|
+
iv(live ? FX.artDiffText(dest) : NO_CONNECTOR)),
|
|
605
758
|
el('div', { style: 'display:flex;gap:6px;margin-top:8px;flex-wrap:wrap' },
|
|
606
759
|
el('button', {
|
|
607
760
|
onClick: decide(FX.artSyncPushed), disabled: inert,
|
|
@@ -614,9 +767,90 @@ function artifactRow(slug, a) {
|
|
|
614
767
|
}, 'adopt ', iv(dest.kind), ' → repo'),
|
|
615
768
|
),
|
|
616
769
|
);
|
|
770
|
+
} else if (syncOpen) {
|
|
771
|
+
panel = realSyncPanel(slug, a, key, dest);
|
|
617
772
|
}
|
|
618
773
|
|
|
619
|
-
return el('div', { style: 'min-width:0' }, head, pillRow, panel);
|
|
774
|
+
return el('div', { style: 'min-width:0' }, head, pillRow, openHere ? destForm(openHere, dest.target) : null, panel);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* The real sync panel: what is true now, who could perform the next step, the two directions, and —
|
|
779
|
+
* once a direction is chosen — the task that carries it out.
|
|
780
|
+
*
|
|
781
|
+
* The click handler is worth reading closely, because it is where this initiative could most easily
|
|
782
|
+
* have reproduced the defect it exists to remove. It records a `proposed` row and asks the server
|
|
783
|
+
* for the task text. It does NOT set a status line of its own, does not stamp a time, and has no
|
|
784
|
+
* success branch — because from in here there is no such thing as success to observe. The status
|
|
785
|
+
* line updates when the panel next reads the store, and it can only say what a row says.
|
|
786
|
+
*/
|
|
787
|
+
function realSyncPanel(slug, a, key, dest) {
|
|
788
|
+
const task = (state.artSyncTask || {})[key] || null;
|
|
789
|
+
const resolver = ((state.connectors && state.connectors.configured) || []).find((c) => c.kind === dest.kind) || null;
|
|
790
|
+
|
|
791
|
+
const choose = (direction) => () => {
|
|
792
|
+
const data = sibling('data', './data.js');
|
|
793
|
+
if (!data || typeof data.recordSync !== 'function') return;
|
|
794
|
+
setState((s) => ({ artSyncTask: { ...s.artSyncTask, [key]: { pending: true, direction } } }));
|
|
795
|
+
Promise.resolve(data.recordSync({
|
|
796
|
+
artifact_path: a.path, slug, kind: dest.kind, target: dest.target,
|
|
797
|
+
direction, profile: resolver ? resolver.profile : null,
|
|
798
|
+
}))
|
|
799
|
+
.then((out) => setState((s) => ({
|
|
800
|
+
artSyncTask: {
|
|
801
|
+
...s.artSyncTask,
|
|
802
|
+
[key]: (!out || out.ok === false)
|
|
803
|
+
// A refusal is shown, never absorbed. The store returning 500 rather than a false green
|
|
804
|
+
// is the server's half of this rule; showing what it said is the client's.
|
|
805
|
+
? { error: (out && out.error) || 'the direction could not be recorded', direction }
|
|
806
|
+
: { text: out.task, error: out.task_error || null, direction, profile: resolver ? resolver.profile : null },
|
|
807
|
+
},
|
|
808
|
+
})))
|
|
809
|
+
.catch((e) => setState((s) => ({
|
|
810
|
+
artSyncTask: { ...s.artSyncTask, [key]: { error: String((e && e.message) || e), direction } },
|
|
811
|
+
})));
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
const btn = (label, primary, onClick) => el('button', {
|
|
815
|
+
onClick,
|
|
816
|
+
style: 'font-family:var(--mono);font-size:10.5px;padding:5px 10px;min-height:28px;'
|
|
817
|
+
+ (primary
|
|
818
|
+
? 'background:var(--brand);color:oklch(0.987 0.002 197.1);font-weight:500'
|
|
819
|
+
: 'background:var(--secondary);border:1px solid var(--border);color:var(--foreground)'),
|
|
820
|
+
hover: primary ? null : 'border-color:var(--ring)',
|
|
821
|
+
}, label);
|
|
822
|
+
|
|
823
|
+
return el('div', { style: 'margin-top:6px;padding:9px 11px;border:1px solid var(--border);background:var(--background)' },
|
|
824
|
+
el('div', { style: 'font-size:12px;line-height:1.5;color:var(--muted-foreground)' },
|
|
825
|
+
iv(syncSummary(a.sync || null, dest))),
|
|
826
|
+
el('div', { style: 'font-size:11.5px;line-height:1.5;color:var(--muted-foreground);margin-top:5px' },
|
|
827
|
+
iv(resolver
|
|
828
|
+
? SYNC_NOT_A_SYNC + ' a session on ' + resolver.profile + ' holds your ' + dest.kind + ' MCP.'
|
|
829
|
+
: SYNC_NOT_A_SYNC + ' no agent profile declares a ' + dest.kind + ' MCP, so the task below is'
|
|
830
|
+
+ ' yours to run wherever you hold that access.')),
|
|
831
|
+
el('div', { style: 'display:flex;gap:6px;margin-top:8px;flex-wrap:wrap' },
|
|
832
|
+
btn('repo is source → push', true, choose('push')),
|
|
833
|
+
el('button', {
|
|
834
|
+
onClick: choose('adopt'),
|
|
835
|
+
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)',
|
|
836
|
+
hover: 'border-color:var(--ring)',
|
|
837
|
+
}, 'adopt ', iv(dest.kind), ' → repo'),
|
|
838
|
+
),
|
|
839
|
+
task && (task.error
|
|
840
|
+
? el('div', { style: 'margin-top:8px;font-size:11.5px;color:var(--warning);line-height:1.5' },
|
|
841
|
+
iv('the direction was not recorded — ' + task.error))
|
|
842
|
+
: task.pending
|
|
843
|
+
? el('div', { style: 'margin-top:8px;font-size:11.5px;color:var(--muted-foreground)' }, iv('recording…'))
|
|
844
|
+
: el('div', { style: 'margin-top:8px' },
|
|
845
|
+
el('div', { style: 'font-size:11px;color:var(--muted-foreground);line-height:1.5;margin-bottom:5px' },
|
|
846
|
+
iv('recorded as proposed. run this in a session on ' + (task.profile || 'a profile holding that MCP')
|
|
847
|
+
+ '. it records the outcome itself, and PMOS will refuse a success that does not name'
|
|
848
|
+
+ ' the profile that performed it.')),
|
|
849
|
+
el('pre', {
|
|
850
|
+
style: 'font-family:var(--mono);font-size:10.5px;line-height:1.5;white-space:pre-wrap;'
|
|
851
|
+
+ 'padding:8px 9px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground);margin:0',
|
|
852
|
+
}, iv(task.text)))),
|
|
853
|
+
);
|
|
620
854
|
}
|
|
621
855
|
|
|
622
856
|
function artifactsRow(slug, rec) {
|
|
@@ -37,6 +37,12 @@ function initial() {
|
|
|
37
37
|
detail: { kind: 'init' },
|
|
38
38
|
tabs: [], active: null, splitId: null,
|
|
39
39
|
initLinks: {}, artLinks: {}, artSyncOpen: {}, artSyncState: {},
|
|
40
|
+
/* The sync half of panel 3, all per-window and none of it persisted. `artSyncTask` holds the
|
|
41
|
+
* task text the server composed for a direction the PM just chose; `linkGone` holds the
|
|
42
|
+
* references withdrawn since the last hydration, so a retracted row leaves the screen at the
|
|
43
|
+
* click rather than at the next read. Neither is a source of truth — what a link IS and what
|
|
44
|
+
* was synced both come back from the store on the next `/api/initiative`. */
|
|
45
|
+
artSyncTask: {}, linkGone: {},
|
|
40
46
|
extraSess: {}, termHist: {}, startN: 1, docEdits: {}, docModes: {},
|
|
41
47
|
/* `+ session` into a working tree that already has sessions in it opens a CHOOSER instead of
|
|
42
48
|
* spawning (workspace-session-choice): `{slug, tree, branch, peers}` while it is open, null
|
package/workspace/server.js
CHANGED
|
@@ -702,11 +702,21 @@ function readTextDoc(root, rel) {
|
|
|
702
702
|
// falling back to the filename convention when an artifact declares nothing.
|
|
703
703
|
// Ordered LONGEST-DISCRIMINATOR-FIRST: `-copy-brief` must be tested before `-brief`, and
|
|
704
704
|
// `-eval-rubric` before any `-rubric`, because the first match wins and slices the stem.
|
|
705
|
+
//
|
|
706
|
+
// The qualified-brief entries (`-comms-brief`, `-launch-brief`) are here for a reason worth stating,
|
|
707
|
+
// because it is the same reason `-copy-brief` was: a stem ending `-comms-brief` ALSO ends `-brief`,
|
|
708
|
+
// so without its own entry it matches the shorter one and the slice leaves a `-comms` tail on the
|
|
709
|
+
// stem. That tail belongs to the qualifier, not to the initiative, and it is enough to put a comms
|
|
710
|
+
// brief in a different group from the PRD it was written for — which is precisely what the PM
|
|
711
|
+
// reported seeing (PRD, defect 3).
|
|
705
712
|
const ROLE_SUFFIXES = [
|
|
706
713
|
['-eval-rubric', 'rubric'], ['-design-brief', 'design brief'], ['-design-reference', 'design'],
|
|
707
|
-
['-copy-brief', 'copy brief'], ['-
|
|
714
|
+
['-copy-brief', 'copy brief'], ['-comms-brief', 'comms brief'], ['-launch-brief', 'launch brief'],
|
|
715
|
+
['-user-stories', 'user stories'], ['-user-story', 'user stories'], ['-stories', 'user stories'],
|
|
716
|
+
['-one-pager', 'one-pager'], ['-copy', 'copy'],
|
|
708
717
|
['-dogfood', 'dogfood'], ['-reconcile', 'reconcile'], ['-findings', 'findings'],
|
|
709
718
|
['-scoping', 'scoping'], ['-intake', 'intake'], ['-brief', 'brief'], ['-prd', 'PRD'],
|
|
719
|
+
['-spec', 'spec'],
|
|
710
720
|
];
|
|
711
721
|
const ROLE_BY_DIR = {
|
|
712
722
|
prd: 'PRD', evals: 'eval', design: 'design', research: 'research', briefs: 'brief',
|
|
@@ -864,12 +874,34 @@ function initiativeIndex(root) {
|
|
|
864
874
|
for (const m of metas) if (!m.initiative) byStem.set(m.stem, (byStem.get(m.stem) || 0) + 1);
|
|
865
875
|
const groups = new Map();
|
|
866
876
|
const ungrouped = [];
|
|
877
|
+
// PREFIX ATTACHMENT, and the three constraints that keep it from becoming a similarity score.
|
|
878
|
+
//
|
|
879
|
+
// The join above is stem EQUALITY, and equality is the wrong shape for how PMs name files. An
|
|
880
|
+
// initiative `ooh-zbsg-ast1` carries `ooh-zbsg-ast1-design-prd.md`, `ooh-zbsg-ast1-user-stories.md`
|
|
881
|
+
// and `ooh-zbsg-ast1-comms-brief.md`; after the role suffix is sliced those are three DIFFERENT
|
|
882
|
+
// stems, so the initiative's own PRD was attributed to no initiative and never reached panel 3.
|
|
883
|
+
// Measured on this repository one initiative ago: 148 documents attributed to nothing, 31 of them
|
|
884
|
+
// beginning with a declared id.
|
|
885
|
+
//
|
|
886
|
+
// 1. Only EXPLICITLY DECLARED ids may capture (`declared`, not `groups`). A group that exists
|
|
887
|
+
// only because two filenames happened to share a stem was never named by anyone, and giving
|
|
888
|
+
// it the authority to claim documents would let a naming coincidence annex a neighbourhood.
|
|
889
|
+
// 2. LONGEST WINS, so `workspace-session-choice-prd.md` cannot be captured by `workspace-session`
|
|
890
|
+
// when both are declared. Sorted once per call, not per document.
|
|
891
|
+
// 3. It runs LAST, after equality and after the convention join, so it can only move a document
|
|
892
|
+
// that would otherwise be `ungrouped`. It cannot take a document away from a group that has a
|
|
893
|
+
// better claim on it, which is what makes the total document count provably unchanged.
|
|
894
|
+
//
|
|
895
|
+
// The boundary being held here is the one `lib/related-docs.js` holds for the same reason: a
|
|
896
|
+
// concrete tie, never "feels related". `x-` is a tie the PM typed. Similarity is not.
|
|
897
|
+
const capturing = [...declared].sort((a, b) => b.length - a.length);
|
|
898
|
+
const prefixOwner = (stem) => capturing.find((id) => stem.length > id.length + 1 && stem.startsWith(id + '-')) || null;
|
|
867
899
|
const keyFor = (m) => {
|
|
868
900
|
if (m.initiative) return m.initiative;
|
|
869
901
|
if (declared.has(m.stem)) return m.stem;
|
|
870
902
|
// the convention join: `jira-sync-prd.md` + `jira-sync-eval-rubric.md` are one unit
|
|
871
903
|
if ((byStem.get(m.stem) || 0) > 1) return m.stem;
|
|
872
|
-
return
|
|
904
|
+
return prefixOwner(m.stem);
|
|
873
905
|
};
|
|
874
906
|
for (const m of metas) {
|
|
875
907
|
const k = keyFor(m);
|
|
@@ -3952,6 +3984,7 @@ async function handleRequest(req, res) {
|
|
|
3952
3984
|
: null,
|
|
3953
3985
|
artifactLinks: fourPanel.readDeclarations(stateDir, fourPanel.ARTIFACT_LINKS),
|
|
3954
3986
|
initiativeLinks: fourPanel.readDeclarations(stateDir, fourPanel.INITIATIVE_LINKS),
|
|
3987
|
+
artifactSync: fourPanel.readDeclarations(stateDir, fourPanel.ARTIFACT_SYNC),
|
|
3955
3988
|
// Which destinations a session could look up, derived from the PM's agent profiles. The home
|
|
3956
3989
|
// directory is passed IN rather than read inside four-panel.js for the same reason
|
|
3957
3990
|
// readProfiles takes it as a parameter: a module that reads os.homedir() itself can only be
|
|
@@ -3986,6 +4019,36 @@ async function handleRequest(req, res) {
|
|
|
3986
4019
|
// A write that failed returns 500 and says so — never a false green (the /api/gate rule).
|
|
3987
4020
|
return json(res, { ...wrote, connectors: fourPanel.connectors(os.homedir()) }, wrote.ok ? 200 : 500);
|
|
3988
4021
|
}
|
|
4022
|
+
if (p === '/api/initiative/sync' && req.method === 'POST') {
|
|
4023
|
+
// The PM chose a direction for a link they already recorded, or a session is reporting what it
|
|
4024
|
+
// did about one. Both arrive here, and the difference between them is exactly the difference the
|
|
4025
|
+
// store enforces: a direction the PM chose is `proposed` and claims nothing, while a session's
|
|
4026
|
+
// report is `applied` and is REFUSED unless it names the profile that performed it.
|
|
4027
|
+
//
|
|
4028
|
+
// This route composes the task text and appends the row. It performs no sync — there is no
|
|
4029
|
+
// branch here that contacts Confluence, Jira or Google, and there cannot be one: the workspace
|
|
4030
|
+
// holds no credential (D53), and `no-network.test.js` asserts the absence structurally.
|
|
4031
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
4032
|
+
let body = {};
|
|
4033
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
4034
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
4035
|
+
const w2 = body.ws ? getWs(body.ws) : w;
|
|
4036
|
+
const built = fourPanel.syncRow(body);
|
|
4037
|
+
if (!built.ok) return json(res, { ok: false, error: built.error }, 400);
|
|
4038
|
+
const task = fourPanel.syncTask({
|
|
4039
|
+
direction: built.row.direction, kind: built.row.kind,
|
|
4040
|
+
artifact_path: built.row.artifact_path, target: built.row.target, profile: body.profile || null,
|
|
4041
|
+
});
|
|
4042
|
+
const engine = consoleState.loadEngine();
|
|
4043
|
+
if (!engine.ok) return json(res, { ok: false, error: engine.error }, 500);
|
|
4044
|
+
const stateDir = fourPanel.storeDir(engine.engine, w2 ? w2.root : SELF_ROOT);
|
|
4045
|
+
const wrote = fourPanel.appendDeclaration(stateDir, fourPanel.ARTIFACT_SYNC, built.row);
|
|
4046
|
+
// A write that failed returns 500 and says so — never a false green (the /api/gate rule). The
|
|
4047
|
+
// task is returned either way: a PM whose store is unwritable can still run the work by hand,
|
|
4048
|
+
// and withholding the text would turn a recording failure into a capability failure.
|
|
4049
|
+
return json(res, { ...wrote, task: task.ok ? task.text : null, task_error: task.ok ? null : task.error },
|
|
4050
|
+
wrote.ok ? 200 : 500);
|
|
4051
|
+
}
|
|
3989
4052
|
if (p === '/api/initiative/lookup-task' && req.method === 'POST') {
|
|
3990
4053
|
// The task a SESSION runs to resolve a reference. This endpoint composes a string and returns
|
|
3991
4054
|
// it. It contacts nothing — that is the entire point of the design: PMOS describes the lookup,
|