create-pmos 0.23.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.
@@ -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 renders exactly as designed — the repo-is-source statement, the
39
- * difference summary, the two approvals. In FIXTURE mode the summary is the prototype's copy
40
- * verbatim. In real mode with no connector configured it says so, and the two approval buttons are
41
- * inert, because a panel that reported "2 sections differ" without having read anything would be
42
- * precisely the watermelon this repo has a word for (PRD, "External sync").
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
- * Why the two sync approvals are inert in real mode even when a destination IS resolvable.
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
- * 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.
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
- * 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.
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 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.';
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
- const links = [...(rec.links || []), ...manual];
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
- const rows = links.map((lk) => el('button', {
387
- onClick: () => openDoc(slug, lk.path, String(lk.kind).toUpperCase()),
388
- style: 'display:flex;gap:8px;align-items:center;padding:5px 6px;margin:0 -6px;min-height:34px',
389
- hover: 'background:var(--accent)',
390
- },
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)),
392
- el('span', { style: 'font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(lk.label)),
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)),
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
- * The sync panel is where this surface is at its most careful. The repo is the source of truth,
490
- * stated first; then the difference summary; then the two approvals, whose chosen result persists
491
- * as the status line beside the pill. With no connector, the summary says nothing was read and
492
- * both approvals are inert.
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
- const dest = (state.artLinks || {})[key] || a.dest || null;
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
- 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);
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, and the persisted result of the last approval.
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
- syncState && el('span', { style: 'font-family:var(--mono);font-size:10px;color:' + syncState.color }, iv(syncState.text)),
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
- // 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;
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(FIXTURE ? (live ? FX.artDiffText(dest) : NO_CONNECTOR) : SYNC_OUT_OF_SCOPE)),
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) {