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.
- package/README.md +28 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/workspace/README.md +10 -0
- package/workspace/lib/four-panel.js +201 -6
- package/workspace/lib/session-state.js +56 -2
- package/workspace/lib/working-set.js +224 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/index.html +7 -0
- package/workspace/public/pm/data.js +100 -16
- package/workspace/public/pm/panel-detail.js +327 -93
- package/workspace/public/pm/panel-initiatives.js +172 -17
- package/workspace/public/pm/state.js +6 -0
- package/workspace/server.js +69 -2
package/README.md
CHANGED
|
@@ -333,6 +333,34 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
|
|
|
333
333
|
`okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
|
|
334
334
|
exactly what you're running.
|
|
335
335
|
|
|
336
|
+
## What's new in 0.24.0
|
|
337
|
+
|
|
338
|
+
**The initiative board says which work is yours, and which of it has an agent running.** Run
|
|
339
|
+
`npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
|
|
340
|
+
|
|
341
|
+
- **A `NOW · working on` group is pinned at the top of the board.** With a few dozen initiatives
|
|
342
|
+
filed by objective, the list is in the right *filing* order and the wrong *attention* order — the
|
|
343
|
+
one you have an agent running on sits wherever its KR happens to fall. This group collects what
|
|
344
|
+
you are actually on, ordered so whatever is **waiting on you** is the first row on the screen.
|
|
345
|
+
- **Three signals, ranked and told apart.** An initiative is in the group because a session is live
|
|
346
|
+
on its `initiative/<id>` branch, or a working tree is checked out on that branch with nothing
|
|
347
|
+
running in it, or you have a tab open on it in this window. They are never merged into one vague
|
|
348
|
+
"active" badge — each row's tooltip says which one put it there, and the two weaker ones say
|
|
349
|
+
plainly that nothing is running.
|
|
350
|
+
- **A live session shows on its own row.** The `#` beside an initiative becomes that session's own
|
|
351
|
+
glyph — `◆ needs you`, `● working`, `◇ maybe blocked`, `◼ quiet` — in the same vocabulary the
|
|
352
|
+
session sidebar uses. Hovering names **every** session on that initiative with its state, rather
|
|
353
|
+
than counting them: the status bar already says how many and never which. Several sessions fold to
|
|
354
|
+
the one that most wants you, and `maybe blocked` outranks `working`.
|
|
355
|
+
- **The glyphs differ in shape, not just colour**, so the board still reads in greyscale and for a
|
|
356
|
+
colour-blind reader.
|
|
357
|
+
- **Nothing is re-homed and nothing is invented.** An initiative still appears under its own
|
|
358
|
+
objective and KR — the group is a shortcut, not a second filing system — and it disappears
|
|
359
|
+
entirely when there is nothing to say. A session PMOS cannot place on an initiative is not placed
|
|
360
|
+
on one.
|
|
361
|
+
- **Searching for a session name now finds it.** It only ever matched initiatives you had already
|
|
362
|
+
opened in that window, and returned nothing for the rest without saying it had not looked.
|
|
363
|
+
|
|
336
364
|
## What's new in 0.21.0
|
|
337
365
|
|
|
338
366
|
**The workspace terminal's lines are no longer double-spaced.** Run
|
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
package/workspace/README.md
CHANGED
|
@@ -67,6 +67,16 @@ bar and a 26px status bar, on the oklch token layer, in both themes:
|
|
|
67
67
|
2. **Initiatives** — a channel list. Collapsible objective/KR groups over `#slug` rows, one search
|
|
68
68
|
box that forces the groups open and reports matches elsewhere, and an add form. Unanchored
|
|
69
69
|
initiatives are filed honestly and shown **rejected** under D59, never hidden.
|
|
70
|
+
A **`NOW · working on`** group is pinned above the objectives when — and only when — there is
|
|
71
|
+
something to say: every initiative with a live session on its `initiative/<id>` branch, a
|
|
72
|
+
registered working tree on that branch, or a tab open in this window, ordered so whatever is
|
|
73
|
+
**waiting on you** is the first row on the board. The three signals are ranked, never merged, and
|
|
74
|
+
each row's tooltip names which one put it there. On a row with a live session the leading `#`
|
|
75
|
+
becomes that session's own glyph — `◆ needs you`, `● working`, `◇ maybe blocked`, `◼ quiet` —
|
|
76
|
+
in the vocabulary `lib/session-state.js` uses everywhere else, with every session named
|
|
77
|
+
individually on hover. The glyphs differ in **shape**, so the signal survives greyscale.
|
|
78
|
+
The join is the branch convention and nothing else: a session PMOS cannot place on an initiative
|
|
79
|
+
is not placed on one.
|
|
70
80
|
3. **Initiative detail** — the record's header and pills (type / anchor / flow / branch), the derived
|
|
71
81
|
footer, **task phases** (collapsible; a phase's status is derived from its tasks, and the tasks
|
|
72
82
|
are derived from the workflow ladder unless the record authors them), a **links** section for
|
|
@@ -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
|
};
|
|
@@ -66,8 +66,62 @@
|
|
|
66
66
|
|
|
67
67
|
// Filled = corroborated by both sources, hollow = suspected from one. `needs` takes ◆, which has
|
|
68
68
|
// sat declared-but-dead in the client's GLYPH map since the harness tower; this makes it live.
|
|
69
|
+
//
|
|
70
|
+
// THE SHAPES CARRY THE SIGNAL, AND COLOUR ONLY REINFORCES IT. Every pair in this map is
|
|
71
|
+
// distinguishable in greyscale, which is the property `fixture.js`'s SDOT does not have (it gives
|
|
72
|
+
// `working` and `needs` the same ● and separates them by hue alone). That map is the design
|
|
73
|
+
// prototype's seed data, parity-locked to prototype-original.html; this one is the shipped
|
|
74
|
+
// vocabulary. When a surface has to draw a session, it draws from here.
|
|
69
75
|
const GLYPH = { working: '●', quiet: '◼', exited: '○', needs: '◆', blocked: '◇' };
|
|
70
76
|
|
|
77
|
+
// The colour that goes with the glyph, as a SEMANTIC TOKEN NAME — never a hex, never an oklch.
|
|
78
|
+
// The workspace's own token layer (pm/tokens.css, D38) resolves it, and workspace-ci's
|
|
79
|
+
// token-hygiene step fails on raw hex in a component rule.
|
|
80
|
+
//
|
|
81
|
+
// It lives HERE, beside WORD and GLYPH, for the reason the module's header gives: a second copy of
|
|
82
|
+
// one table is free to drift. The initiative board needed a colour per presented state and there
|
|
83
|
+
// was none anywhere in the repo — adding one inside the panel would have started exactly the
|
|
84
|
+
// second table this file exists to prevent.
|
|
85
|
+
//
|
|
86
|
+
// `blocked` takes --warning and not --destructive on purpose. Its own WORD names its uncertainty
|
|
87
|
+
// ("maybe blocked") because its evidence cannot separate a permission prompt from a hung tool from
|
|
88
|
+
// a corpse; a destructive colour would launder that into a confident claim of failure.
|
|
89
|
+
// `quiet` is muted: nothing is happening and nothing is wrong, and any louder colour would make an
|
|
90
|
+
// idle session read as a problem on a board of 178 rows.
|
|
91
|
+
const COLOR = {
|
|
92
|
+
working: 'var(--success)',
|
|
93
|
+
quiet: 'var(--muted-foreground)',
|
|
94
|
+
exited: 'var(--muted-foreground)',
|
|
95
|
+
needs: 'var(--brand)',
|
|
96
|
+
blocked: 'var(--warning)',
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* How loudly a presented state asks for the PM, highest first. Used where several sessions have to
|
|
101
|
+
* collapse into ONE mark — a board row standing for every session on an initiative.
|
|
102
|
+
*
|
|
103
|
+
* `blocked` outranks `working` deliberately. A working session is doing what it was asked to; a
|
|
104
|
+
* "maybe blocked" one may be sitting on a permission prompt, which is the PM's to clear. Ranking
|
|
105
|
+
* it below the running states would bury the only two states that can want a human.
|
|
106
|
+
*
|
|
107
|
+
* `exited` is 0: a corpse is not a claim on anyone's attention. A caller filtering live sessions
|
|
108
|
+
* drops it before it gets here; the entry exists so the map is total over PRESENTED.
|
|
109
|
+
*/
|
|
110
|
+
const ATTENTION = { needs: 4, blocked: 3, working: 2, quiet: 1, exited: 0 };
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The presented state that should stand for a whole GROUP of sessions — the loudest one.
|
|
114
|
+
* Returns null for an empty list, so a caller cannot draw a mark for nothing.
|
|
115
|
+
*/
|
|
116
|
+
function loudest(states) {
|
|
117
|
+
let best = null;
|
|
118
|
+
for (const s of states || []) {
|
|
119
|
+
if (!(s in ATTENTION)) continue; // an unknown word is never promoted into a claim
|
|
120
|
+
if (best === null || ATTENTION[s] > ATTENTION[best]) best = s;
|
|
121
|
+
}
|
|
122
|
+
return best;
|
|
123
|
+
}
|
|
124
|
+
|
|
71
125
|
// Which presented states may carry the open call's verb + age. Unchanged for `working` (shipped
|
|
72
126
|
// behaviour); extended to `blocked`, where the age is the most informative part of a weak claim —
|
|
73
127
|
// "a call has been open 6m and nothing is moving" is the whole signal.
|
|
@@ -134,6 +188,6 @@
|
|
|
134
188
|
return w;
|
|
135
189
|
}
|
|
136
190
|
|
|
137
|
-
return { CADENCE, TURN, PRESENTED, WORD, GLYPH, CARRIES_CALL_AGE,
|
|
138
|
-
presented, presentedStates, isNeedsClaim, label };
|
|
191
|
+
return { CADENCE, TURN, PRESENTED, WORD, GLYPH, COLOR, ATTENTION, CARRIES_CALL_AGE,
|
|
192
|
+
presented, presentedStates, isNeedsClaim, loudest, label };
|
|
139
193
|
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// working-set — which initiatives is the PM actually working on, and which of those are live.
|
|
3
|
+
//
|
|
4
|
+
// Two questions the initiative board could not answer, asked by the PM about their own board of 178
|
|
5
|
+
// records: *which of these am I currently working on*, and *which one has an active session*. Both
|
|
6
|
+
// were computable from state the browser already held, and nothing put them together.
|
|
7
|
+
//
|
|
8
|
+
// ── THE JOIN, AND WHY IT IS ONLY EVER TWO HOPS ───────────────────────────────────────────────
|
|
9
|
+
// A session carries NO initiative id. It never has. The only thing tying a PTY to an initiative is
|
|
10
|
+
// the branch convention — a working tree on `initiative/<id>` — and that is exactly the join
|
|
11
|
+
// lib/four-panel.js `sessionsFor()` already makes on the server, per initiative, inside
|
|
12
|
+
// /api/initiative:
|
|
13
|
+
//
|
|
14
|
+
// session ──s.ws──▶ workspace ──w.branch──▶ "initiative/<id>" ──▶ the initiative
|
|
15
|
+
//
|
|
16
|
+
// This module is that same join, made once over ALL sessions instead of once per initiative, so the
|
|
17
|
+
// board can colour 178 rows without 178 requests. It is deliberately NOT a second opinion: it takes
|
|
18
|
+
// the branch strings the server derived (`git rev-parse --abbrev-ref`, published on /api/workspaces)
|
|
19
|
+
// and the session rows the server published, and it runs no git and guesses at nothing.
|
|
20
|
+
//
|
|
21
|
+
// ── WHAT IT REFUSES TO DO ────────────────────────────────────────────────────────────────────
|
|
22
|
+
// Every input that does not resolve contributes NOTHING. A session whose workspace is unknown, a
|
|
23
|
+
// workspace with no branch, a branch that is not `initiative/…`, a branch that is the literal
|
|
24
|
+
// `initiative/` with no id — each is dropped in silence rather than bucketed somewhere plausible.
|
|
25
|
+
// A marker on the wrong initiative is worse than no marker: the PM would stop trusting the column,
|
|
26
|
+
// and an untrusted signal costs more than an absent one.
|
|
27
|
+
//
|
|
28
|
+
// `exited` is not live. A session that has ended is a corpse, not a claim on anyone's attention, and
|
|
29
|
+
// it is filtered before the loudest-state fold rather than ranked at the bottom of it.
|
|
30
|
+
//
|
|
31
|
+
// ── THREE SIGNALS, KEPT APART ────────────────────────────────────────────────────────────────
|
|
32
|
+
// "Working on" is not one fact. It is three, with different strengths and different lifetimes:
|
|
33
|
+
//
|
|
34
|
+
// session a live PTY is on initiative/<id> — strongest; ends when the process does
|
|
35
|
+
// tree a registered working tree is on that branch — nothing is running in it
|
|
36
|
+
// open this window has a tab open on it — intent, not machine state; persisted
|
|
37
|
+
//
|
|
38
|
+
// They are ranked, never merged. `reason` survives all the way to the row's tooltip so the surface
|
|
39
|
+
// can say which one it is: a single undifferentiated "active" badge would assert something none of
|
|
40
|
+
// the three supports on its own.
|
|
41
|
+
//
|
|
42
|
+
// Pure functions over plain rows, like lib/tree-share.js and for the same reason — the rules are the
|
|
43
|
+
// part worth walking exhaustively in node, without a browser and without a server.
|
|
44
|
+
//
|
|
45
|
+
// ── ONE module, TWO consumers ────────────────────────────────────────────────────────────────
|
|
46
|
+
// A UMD, so the SERVER can hold the same rules the BROWSER draws from. Today the drawing consumer is
|
|
47
|
+
// pm/panel-initiatives.js; test.js is the other, and it walks the whole input domain.
|
|
48
|
+
(function (root, factory) {
|
|
49
|
+
const api = factory(
|
|
50
|
+
typeof require === 'function' && typeof module !== 'undefined' && module.exports
|
|
51
|
+
? require('./session-state')
|
|
52
|
+
: (root && root.PMOSSessionState),
|
|
53
|
+
);
|
|
54
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
55
|
+
else root.PMOSWorkingSet = api;
|
|
56
|
+
})(typeof self !== 'undefined' ? self : this, function (SS) {
|
|
57
|
+
|
|
58
|
+
const PREFIX = 'initiative/';
|
|
59
|
+
|
|
60
|
+
/** The three reasons an initiative is in the working set, loudest first. */
|
|
61
|
+
const REASONS = ['session', 'tree', 'open'];
|
|
62
|
+
const REASON_RANK = { session: 2, tree: 1, open: 0 };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The initiative id a branch name names, or null.
|
|
66
|
+
*
|
|
67
|
+
* Strict on purpose. `initiative/` with nothing after it is not an initiative, and neither is
|
|
68
|
+
* `feature/initiative/x` — the convention is a PREFIX, and matching it loosely is how a marker
|
|
69
|
+
* lands on a row that has nothing to do with the session.
|
|
70
|
+
*/
|
|
71
|
+
function initiativeOfBranch(branch) {
|
|
72
|
+
const b = typeof branch === 'string' ? branch : '';
|
|
73
|
+
if (!b.startsWith(PREFIX)) return null;
|
|
74
|
+
const id = b.slice(PREFIX.length);
|
|
75
|
+
return id ? id : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Is this session row one the board should draw? A corpse is not. */
|
|
79
|
+
function isLive(s) {
|
|
80
|
+
return !!s && s.shown !== 'exited';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Every LIVE session, grouped by the initiative its working tree is on.
|
|
85
|
+
*
|
|
86
|
+
* sessions the rows /api/sessions publishes (id, ws, name, shown, label, …)
|
|
87
|
+
* branchOf ws id → branch string, or null. The caller supplies it because the browser holds
|
|
88
|
+
* it as a Map built by foldWorkspaces and the server holds it as a workspace list;
|
|
89
|
+
* taking a function rather than either shape keeps this module ignorant of both.
|
|
90
|
+
*
|
|
91
|
+
* Returns `{ <id>: [session, …] }` — insertion-ordered, never containing an empty array, and
|
|
92
|
+
* never containing an initiative the join could not actually reach.
|
|
93
|
+
*/
|
|
94
|
+
function sessionsByInitiative(sessions, branchOf) {
|
|
95
|
+
const out = Object.create(null);
|
|
96
|
+
if (typeof branchOf !== 'function') return out;
|
|
97
|
+
for (const s of Array.isArray(sessions) ? sessions : []) {
|
|
98
|
+
if (!isLive(s) || !s.ws) continue;
|
|
99
|
+
let id = null;
|
|
100
|
+
// A branch lookup that THROWS must not take the board down with it. The map is the data
|
|
101
|
+
// layer's and this module does not own its failure modes.
|
|
102
|
+
try { id = initiativeOfBranch(branchOf(s.ws)); } catch { id = null; }
|
|
103
|
+
if (!id) continue;
|
|
104
|
+
(out[id] || (out[id] = [])).push(s);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The one presented state that stands for a whole initiative's sessions — the loudest, via
|
|
111
|
+
* session-state's own ATTENTION order. Null when there is nothing live, so a caller cannot draw
|
|
112
|
+
* a mark for nothing.
|
|
113
|
+
*/
|
|
114
|
+
function stateOf(sessions) {
|
|
115
|
+
if (!SS || typeof SS.loudest !== 'function') return null;
|
|
116
|
+
return SS.loudest((sessions || []).map((s) => s && s.shown));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Every initiative a registered working tree is checked out on.
|
|
121
|
+
*
|
|
122
|
+
* `branches` is a flat list of branch strings — the caller enumerates its own registry. A tree on
|
|
123
|
+
* initiative/<id> means the PM has a checkout dedicated to that initiative, whether or not
|
|
124
|
+
* anything is running in it; that is a weaker claim than a live session and a real one.
|
|
125
|
+
*/
|
|
126
|
+
function initiativesWithTree(branches) {
|
|
127
|
+
const out = new Set();
|
|
128
|
+
for (const b of Array.isArray(branches) ? branches : []) {
|
|
129
|
+
const id = initiativeOfBranch(b);
|
|
130
|
+
if (id) out.add(id);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Every initiative this window has a tab open on.
|
|
137
|
+
*
|
|
138
|
+
* The weakest of the three signals and the only one that reflects INTENT rather than machine
|
|
139
|
+
* state. It is also durable: pm/state.js persists `tabs`, so it survives a reload — which is the
|
|
140
|
+
* whole reason it is worth reading. It can be stale (a tab left open for a week reads like one
|
|
141
|
+
* opened a minute ago), which is why it ranks last and why its reason is carried, not hidden.
|
|
142
|
+
*/
|
|
143
|
+
function initiativesWithTab(tabs) {
|
|
144
|
+
const out = new Set();
|
|
145
|
+
for (const t of Array.isArray(tabs) ? tabs : []) {
|
|
146
|
+
if (t && typeof t.slug === 'string' && t.slug) out.add(t.slug);
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* THE WORKING SET — the rows the board pins above the Objectives.
|
|
153
|
+
*
|
|
154
|
+
* live {id: [session…]} from sessionsByInitiative
|
|
155
|
+
* trees Set<id> from initiativesWithTree
|
|
156
|
+
* tabs Set<id> from initiativesWithTab
|
|
157
|
+
* onBoard (id) => boolean is this slug on the repository currently showing? REQUIRED, and
|
|
158
|
+
* the reason a tab or a tree belonging to another repository cannot
|
|
159
|
+
* leak a row onto the wrong board.
|
|
160
|
+
*
|
|
161
|
+
* Ordered by attention: needs → blocked → working → quiet → tree → open, alphabetical within a
|
|
162
|
+
* band. So the initiative waiting on the PM is the first row on the screen.
|
|
163
|
+
*
|
|
164
|
+
* Each entry is `{slug, reason, state, sessions}`. `reason` is one of REASONS and is never
|
|
165
|
+
* collapsed away — the surface says which of the three it is.
|
|
166
|
+
*/
|
|
167
|
+
function workingSet({ live, trees, tabs, onBoard } = {}) {
|
|
168
|
+
const L = live && typeof live === 'object' ? live : {};
|
|
169
|
+
const T = trees instanceof Set ? trees : new Set();
|
|
170
|
+
const B = tabs instanceof Set ? tabs : new Set();
|
|
171
|
+
const keep = typeof onBoard === 'function' ? onBoard : () => false;
|
|
172
|
+
|
|
173
|
+
const seen = new Map();
|
|
174
|
+
const add = (slug, reason, sessions) => {
|
|
175
|
+
if (!slug || seen.has(slug) || !keep(slug)) return;
|
|
176
|
+
seen.set(slug, { slug, reason, state: reason === 'session' ? stateOf(sessions) : null, sessions: sessions || [] });
|
|
177
|
+
};
|
|
178
|
+
for (const slug of Object.keys(L)) add(slug, 'session', L[slug]);
|
|
179
|
+
for (const slug of T) add(slug, 'tree', null);
|
|
180
|
+
for (const slug of B) add(slug, 'open', null);
|
|
181
|
+
|
|
182
|
+
const attention = (e) => (e.state && SS && SS.ATTENTION ? (SS.ATTENTION[e.state] || 0) : 0);
|
|
183
|
+
return [...seen.values()].sort((a, b) => {
|
|
184
|
+
if (REASON_RANK[a.reason] !== REASON_RANK[b.reason]) return REASON_RANK[b.reason] - REASON_RANK[a.reason];
|
|
185
|
+
if (attention(a) !== attention(b)) return attention(b) - attention(a);
|
|
186
|
+
return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* What the PM reads when they hover a row's session mark: every live session named INDIVIDUALLY,
|
|
192
|
+
* with the SERVER's own word for its state.
|
|
193
|
+
*
|
|
194
|
+
* The glyph summarises and the tooltip does not — a count would reproduce the status bar's
|
|
195
|
+
* "3 sessions running", which is the thing that says how many and never which. `s.label` is
|
|
196
|
+
* server-rendered (session-state's `label`, one clock, one sentence), so this cannot word a
|
|
197
|
+
* session differently from the pane it belongs to. A row with no label falls back to `shown`
|
|
198
|
+
* rather than inventing a sentence.
|
|
199
|
+
*/
|
|
200
|
+
function sessionTitle(slug, sessions) {
|
|
201
|
+
const rows = (sessions || []).filter(isLive);
|
|
202
|
+
if (!rows.length) return '';
|
|
203
|
+
const lines = rows.map((s) => '· ' + (s.name || s.id || 'session') + ' — ' + (s.label || s.shown || 'unknown'));
|
|
204
|
+
const n = rows.length;
|
|
205
|
+
return [
|
|
206
|
+
n + ' live session' + (n === 1 ? '' : 's') + ' on ' + PREFIX + slug,
|
|
207
|
+
...lines,
|
|
208
|
+
].join('\n');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** One line naming why an initiative is in the working set. Stated, never implied. */
|
|
212
|
+
function reasonTitle(entry) {
|
|
213
|
+
if (!entry) return '';
|
|
214
|
+
if (entry.reason === 'session') return sessionTitle(entry.slug, entry.sessions);
|
|
215
|
+
if (entry.reason === 'tree') return 'a registered working tree is on ' + PREFIX + entry.slug + ' — nothing is running in it';
|
|
216
|
+
return 'you have a tab open on this initiative in this window';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
PREFIX, REASONS, REASON_RANK,
|
|
221
|
+
initiativeOfBranch, isLive, sessionsByInitiative, stateOf,
|
|
222
|
+
initiativesWithTree, initiativesWithTab, workingSet, sessionTitle, reasonTitle,
|
|
223
|
+
};
|
|
224
|
+
});
|
|
@@ -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