create-pmos 0.15.0 → 0.16.1
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 +65 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/README.md +50 -13
- package/workspace/bin/pmos.js +204 -51
- package/workspace/design-parity.js +358 -0
- package/workspace/doctor.js +54 -0
- package/workspace/lib/find-browser.js +60 -0
- package/workspace/lib/four-panel.js +345 -0
- package/workspace/lib/git-info.js +28 -1
- package/workspace/lib/mobile-nav.js +32 -2
- package/workspace/lib/server-discovery.js +127 -0
- package/workspace/package-lock.json +29 -2
- package/workspace/package.json +13 -5
- package/workspace/public/index.html +79 -409
- package/workspace/public/pm/app.js +242 -0
- package/workspace/public/pm/data.js +905 -0
- package/workspace/public/pm/dom.js +116 -0
- package/workspace/public/pm/fixture.js +979 -0
- package/workspace/public/pm/mobile.css +200 -0
- package/workspace/public/pm/mobile.js +403 -0
- package/workspace/public/pm/modes.js +1076 -0
- package/workspace/public/pm/panel-detail.js +648 -0
- package/workspace/public/pm/panel-initiatives.js +426 -0
- package/workspace/public/pm/panel-repos.js +553 -0
- package/workspace/public/pm/panel-tabs.js +487 -0
- package/workspace/public/pm/state.js +168 -0
- package/workspace/public/pm/tab-doc.js +279 -0
- package/workspace/public/pm/tab-task.js +83 -0
- package/workspace/public/pm/tab-term.js +560 -0
- package/workspace/public/pm/tokens.css +73 -0
- package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
- package/workspace/server.js +174 -11
- package/workspace/focus.test.js +0 -650
- package/workspace/handoff.test.js +0 -443
- package/workspace/profiles.test.js +0 -362
- package/workspace/public/app.js +0 -5560
- package/workspace/public/styles.css +0 -2258
- package/workspace/repo-scan.test.js +0 -327
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* lib/four-panel.js — the assembly behind `GET /api/initiative?id=&ws=`, and the two
|
|
3
|
+
* declaration stores the four-panel surface writes.
|
|
4
|
+
*
|
|
5
|
+
* ── Why this endpoint exists ─────────────────────────────────────────────────────────────────
|
|
6
|
+
* Panel 3 answers one question about ONE initiative, and answering it today costs four reads that
|
|
7
|
+
* every client would have to join for itself: `/api/library` for artifacts, `/api/console?
|
|
8
|
+
* name=ladder` for the engine's rungs, `/api/console?name=handoff` for external references, and a
|
|
9
|
+
* client-side join of `/api/sessions` against the `initiative/<id>` branch convention. Four
|
|
10
|
+
* round-trips per selection, and — worse — four chances for two surfaces to disagree about what a
|
|
11
|
+
* record declares. This module does the join once, server-side, from the SAME producers the
|
|
12
|
+
* existing endpoints use. It adds no new reader: `ladder`, `links`, `index` and `sessions` are
|
|
13
|
+
* passed IN by the route, already produced by console-state, handoff.js, initiativeIndex() and
|
|
14
|
+
* sessionList(). Nothing here re-derives them, so `/api/initiative` and `/api/console` can never
|
|
15
|
+
* hold two opinions about the same initiative.
|
|
16
|
+
*
|
|
17
|
+
* The existing endpoints are untouched on purpose — other surfaces still read them.
|
|
18
|
+
*
|
|
19
|
+
* ── The frontmatter reader ───────────────────────────────────────────────────────────────────
|
|
20
|
+
* The record's frontmatter is parsed with the WORKFLOW ENGINE's parser, not the workspace's
|
|
21
|
+
* `file-mode-parse`. The two disagree: file-mode-parse requires at least one character after the
|
|
22
|
+
* colon (`/^([a-z_]+):\s*(.+?)\s*$/`), so a record carrying an empty-valued key — `prd_path:` with
|
|
23
|
+
* nothing after it, which several records do — terminates the block there and drops every key
|
|
24
|
+
* below it, including `lane` and `parent_kr`. The engine tolerates the empty value and strips
|
|
25
|
+
* surrounding quotes. Reading a record through the parser that DERIVES the ladder is the only way
|
|
26
|
+
* this endpoint's `frontmatter` can agree with its own `ladder`.
|
|
27
|
+
*
|
|
28
|
+
* ── The declaration stores ───────────────────────────────────────────────────────────────────
|
|
29
|
+
* `artifact-links.jsonl` and `initiative-links.jsonl` are APPEND-ONLY records of what the PM
|
|
30
|
+
* declared, never of anything PMOS read. PMOS holds no credential and makes no outbound request
|
|
31
|
+
* (PRD, "External sync"), so a link row is a statement of intent — "this artifact corresponds to
|
|
32
|
+
* that page" — and the surface that renders it must say that nothing was read and nothing will be
|
|
33
|
+
* written. `connectors()` below is the honest answer to "is a connector configured": always none.
|
|
34
|
+
* It is a function rather than a constant so that the day a connector genuinely exists there is
|
|
35
|
+
* exactly one place that has to start telling a different truth.
|
|
36
|
+
*
|
|
37
|
+
* The files land beside the repo's other append-only records, resolved through the workflow
|
|
38
|
+
* engine's own `layout(root)`: `pmos/state/` in a kit repo — exactly as the PRD names it — and
|
|
39
|
+
* `planning/runs/` in pmos-self, where `pmos/state/` does not exist and the runs, evals and
|
|
40
|
+
* acceptances live. Hard-coding the PRD's path would have put every pmos-self declaration in a
|
|
41
|
+
* directory nothing else reads; and the workspace's own `state/` was the other candidate and is
|
|
42
|
+
* wrong for the opposite reason — it is gitignored runtime state, so a declaration written there
|
|
43
|
+
* would be a record of a PM decision that does not survive the machine.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const fs = require('fs');
|
|
47
|
+
const path = require('path');
|
|
48
|
+
|
|
49
|
+
const ARTIFACT_LINKS = 'artifact-links.jsonl';
|
|
50
|
+
const INITIATIVE_LINKS = 'initiative-links.jsonl';
|
|
51
|
+
|
|
52
|
+
/** The destinations the surface can declare. A kind outside this set is refused, not stored. */
|
|
53
|
+
const LINK_KINDS = ['confluence', 'jira', 'google'];
|
|
54
|
+
|
|
55
|
+
/** An initiative id is a FILENAME, never a path. Same guard the handoff surface uses. */
|
|
56
|
+
const ID_RE = /^[A-Za-z0-9][\w.-]*$/;
|
|
57
|
+
const validId = (id) => typeof id === 'string' && id.length <= 120 && ID_RE.test(id);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* No connector is configured, on every code path, because PMOS holds no credential. Returned as a
|
|
61
|
+
* list so the client can render "which destinations are live" without a second shape to learn; it
|
|
62
|
+
* is empty, and the note says why.
|
|
63
|
+
*/
|
|
64
|
+
function connectors() {
|
|
65
|
+
return {
|
|
66
|
+
configured: [],
|
|
67
|
+
note: 'no connector configured — nothing was read, and nothing will be written.',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* ── the record ─────────────────────────────────────────────────────────────────────────────── */
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read `<initiatives>/<id>.md`. ABSENT and BROKEN are different facts and never share an answer:
|
|
75
|
+
* a record that does not exist is a supported state the panel already draws ("no record"), while
|
|
76
|
+
* an unreadable one is PMOS failing to look and must render RED.
|
|
77
|
+
*
|
|
78
|
+
* `parse` is the engine's `parseLeadingFrontmatter`, injected so this module owns no second
|
|
79
|
+
* opinion about what a record declares.
|
|
80
|
+
*/
|
|
81
|
+
function readRecord({ root, dir, id, parse }) {
|
|
82
|
+
const file = path.join(dir, `${id}.md`);
|
|
83
|
+
const rel = path.relative(root, file);
|
|
84
|
+
let text;
|
|
85
|
+
try { text = fs.readFileSync(file, 'utf8'); }
|
|
86
|
+
catch (e) {
|
|
87
|
+
if (e && e.code === 'ENOENT') return { ok: true, absent: true, record: rel, frontmatter: null };
|
|
88
|
+
return { ok: false, error: `${rel} could not be read — ${String((e && e.code) || 'read failed')}` };
|
|
89
|
+
}
|
|
90
|
+
let fm;
|
|
91
|
+
try { fm = parse(text); }
|
|
92
|
+
catch (e) {
|
|
93
|
+
return { ok: false, error: `${rel} — ${String((e && e.message) || 'the frontmatter could not be parsed').slice(0, 160)}` };
|
|
94
|
+
}
|
|
95
|
+
return { ok: true, absent: false, record: rel, frontmatter: fm, tasks: parseTasks(text) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The OPTIONAL authored-task override (PRD, "Tasks"): a `## Tasks` section in the record BODY,
|
|
100
|
+
* with `### <phase>` sub-headings and `- title:` items carrying single-line `key: value` fields.
|
|
101
|
+
*
|
|
102
|
+
* It lives in the body and not in the frontmatter for a mechanical reason: the leading-frontmatter
|
|
103
|
+
* readers stop at the first line that is not `key: value`, so a YAML list under a `tasks:` key
|
|
104
|
+
* would silently drop `lane:` and `parent_kr:` below it. Absence of the section is not a defect
|
|
105
|
+
* and returns null — the caller derives instead, and a `{}` here would be indistinguishable from
|
|
106
|
+
* "authored, and empty".
|
|
107
|
+
*/
|
|
108
|
+
function parseTasks(text) {
|
|
109
|
+
const lines = String(text == null ? '' : text).split('\n');
|
|
110
|
+
let i = lines.findIndex((l) => /^##\s+Tasks\s*$/i.test(l));
|
|
111
|
+
if (i < 0) return null;
|
|
112
|
+
const out = {};
|
|
113
|
+
let phase = null, task = null;
|
|
114
|
+
for (i += 1; i < lines.length; i++) {
|
|
115
|
+
const line = lines[i];
|
|
116
|
+
if (/^##\s+/.test(line) && !/^###\s+/.test(line)) break; // the next section ends Tasks
|
|
117
|
+
let m = line.match(/^###\s+(\S+)\s*$/);
|
|
118
|
+
if (m) { phase = m[1]; task = null; if (!out[phase]) out[phase] = []; continue; }
|
|
119
|
+
if (!phase) continue;
|
|
120
|
+
m = line.match(/^\s*-\s+title:\s*(.*?)\s*$/);
|
|
121
|
+
if (m) { task = { title: m[1], status: 'todo' }; out[phase].push(task); continue; }
|
|
122
|
+
m = line.match(/^\s+([a-zA-Z_]+):\s*(.*?)\s*$/);
|
|
123
|
+
if (m && task) task[m[1] === 'evaluation' ? 'evalNote' : m[1]] = m[2];
|
|
124
|
+
}
|
|
125
|
+
return Object.keys(out).length ? out : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* ── artifacts ──────────────────────────────────────────────────────────────────────────────── */
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* This initiative's artifacts out of the library index. The index is the SAME producer
|
|
132
|
+
* `/api/library` uses, so the two surfaces list the same files in the same order — an artifact
|
|
133
|
+
* list that differed between panel 3 and the library lens would be two answers to one question.
|
|
134
|
+
* An initiative the index has never heard of has no artifacts; that is an empty list, not an error.
|
|
135
|
+
*/
|
|
136
|
+
function artifactsFor(index, id) {
|
|
137
|
+
const rows = (index && Array.isArray(index.initiatives)) ? index.initiatives : [];
|
|
138
|
+
const hit = rows.find((r) => r && r.id === id);
|
|
139
|
+
if (!hit || !Array.isArray(hit.artifacts)) return [];
|
|
140
|
+
return hit.artifacts.map((a) => ({ role: a.role, name: a.name, path: a.path, title: a.title || null }));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* ── sessions ───────────────────────────────────────────────────────────────────────────────── */
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Which live sessions belong to this initiative. Sessions carry NO `initiative_id` — the only
|
|
147
|
+
* signal is the `initiative/<id>` branch convention, so the join is: every registered working tree
|
|
148
|
+
* whose branch is `initiative/<id>`, then every session whose `ws` is one of those trees.
|
|
149
|
+
*
|
|
150
|
+
* `matched` names the trees the join found. It is carried because "no sessions" and "no working
|
|
151
|
+
* tree on that branch" are different states with different copy, and a client that only received
|
|
152
|
+
* an empty array would have to guess which one it was looking at.
|
|
153
|
+
*/
|
|
154
|
+
function sessionsFor({ sessions, workspaces, id }) {
|
|
155
|
+
const branch = `initiative/${id}`;
|
|
156
|
+
const trees = (workspaces || []).filter((w) => w && w.branch === branch);
|
|
157
|
+
const ids = new Set(trees.map((w) => w.id));
|
|
158
|
+
return {
|
|
159
|
+
matched: trees.map((w) => ({ ws: w.id, name: w.name, root: w.root, branch: w.branch })),
|
|
160
|
+
sessions: (sessions || []).filter((s) => s && ids.has(s.ws)),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* ── anchor ─────────────────────────────────────────────────────────────────────────────────── */
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Resolve the record's `parent_kr` through the same resolver the spine and the ladder use.
|
|
168
|
+
* `anchors` is `scripts/anchor-resolve.js`, injected; without it the anchor is UNKNOWN and says so
|
|
169
|
+
* rather than being reported as unanchored — "we could not look" and "there is nothing there" are
|
|
170
|
+
* the two answers this repo most insists on keeping apart.
|
|
171
|
+
*/
|
|
172
|
+
function anchorFor({ anchors, root, raw }) {
|
|
173
|
+
if (!anchors) {
|
|
174
|
+
return {
|
|
175
|
+
raw: raw || null, status: 'unknown', canonical: null,
|
|
176
|
+
reason: 'the anchor resolver (scripts/anchor-resolve.js) is not present in this install',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const index = anchors.parseOkrIndex(root);
|
|
180
|
+
const budgets = anchors.readBudgets(root);
|
|
181
|
+
const r = anchors.resolveAnchor(raw == null ? '' : String(raw), index, budgets);
|
|
182
|
+
return Object.assign({}, r, { canonical: anchors.canonical(r, index && index.period) });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* ── the declaration stores ─────────────────────────────────────────────────────────────────── */
|
|
186
|
+
|
|
187
|
+
function linkFile(stateDir, name) {
|
|
188
|
+
return stateDir ? path.join(stateDir, name) : null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Where the two stores live for a repo: the same directory the engine already puts this repo's
|
|
193
|
+
* append-only records in. One resolver, so a declaration cannot land somewhere the reader does not
|
|
194
|
+
* look. Returns null when the engine is unavailable — the caller then says it could not write,
|
|
195
|
+
* rather than inventing a location.
|
|
196
|
+
*/
|
|
197
|
+
function storeDir(engine, root) {
|
|
198
|
+
if (!engine || typeof engine.layout !== 'function') return null;
|
|
199
|
+
try { return engine.layout(root).runsDir; } catch { return null; }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Read one store. A torn line is DROPPED, never guessed — the same rule every jsonl reader here follows. */
|
|
203
|
+
function readDeclarations(stateDir, name) {
|
|
204
|
+
const file = linkFile(stateDir, name);
|
|
205
|
+
if (!file) return { ok: false, error: 'no file-mode state directory for this workspace — nothing was read', rows: [] };
|
|
206
|
+
let text;
|
|
207
|
+
try { text = fs.readFileSync(file, 'utf8'); }
|
|
208
|
+
catch (e) {
|
|
209
|
+
if (e && e.code === 'ENOENT') return { ok: true, empty: true, file, rows: [] };
|
|
210
|
+
return { ok: false, error: `${name} could not be read — ${String((e && e.code) || 'read failed')}`, rows: [] };
|
|
211
|
+
}
|
|
212
|
+
const rows = [];
|
|
213
|
+
for (const line of text.split('\n')) {
|
|
214
|
+
const t = line.trim();
|
|
215
|
+
if (!t) continue;
|
|
216
|
+
try { rows.push(JSON.parse(t)); } catch { /* torn line — dropped */ }
|
|
217
|
+
}
|
|
218
|
+
return { ok: true, empty: rows.length === 0, file, rows };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Append one declaration. Append-only: a later row for the same artifact supersedes an earlier one
|
|
223
|
+
* on read, and the earlier one stays on disk, because the history of what the PM declared is the
|
|
224
|
+
* point of a declaration store.
|
|
225
|
+
*
|
|
226
|
+
* A failed write returns `{ok:false}` and never a false green — the same rule `POST /api/gate`
|
|
227
|
+
* follows, and for the same reason: a click that goes green over a record that was never written
|
|
228
|
+
* is the worst failure this surface can have.
|
|
229
|
+
*/
|
|
230
|
+
function appendDeclaration(stateDir, name, row) {
|
|
231
|
+
const file = linkFile(stateDir, name);
|
|
232
|
+
if (!file) {
|
|
233
|
+
return { ok: false, error: 'this workspace has no file-mode state directory — the declaration was NOT recorded' };
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
// The directory is created rather than required: a repo that has never recorded a run has no
|
|
237
|
+
// runs directory yet, and refusing the PM's first declaration for that reason would be a
|
|
238
|
+
// failure with no cause the PM could act on.
|
|
239
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
240
|
+
fs.appendFileSync(file, JSON.stringify(row) + '\n');
|
|
241
|
+
return { ok: true, recorded: row, sink: file };
|
|
242
|
+
} catch (e) {
|
|
243
|
+
return { ok: false, error: `could not write ${name} — ${String((e && e.code) || 'write failed')}`, sink: file };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Validate + normalise an artifact-link declaration. Returns `{ok:false,error}` for a bad shape. */
|
|
248
|
+
function artifactLinkRow(body, { at } = {}) {
|
|
249
|
+
const artifact_path = String((body && body.artifact_path) || '').trim();
|
|
250
|
+
const kind = String((body && body.kind) || '').trim().toLowerCase();
|
|
251
|
+
const target = String((body && body.target) || '').trim();
|
|
252
|
+
if (!artifact_path) return { ok: false, error: 'artifact_path is required' };
|
|
253
|
+
if (artifact_path.includes('..')) return { ok: false, error: 'artifact_path must be a repo-relative path' };
|
|
254
|
+
if (!LINK_KINDS.includes(kind)) return { ok: false, error: `kind must be one of: ${LINK_KINDS.join(', ')}` };
|
|
255
|
+
if (!target) return { ok: false, error: 'target is required' };
|
|
256
|
+
const initiative_id = body && body.initiative_id ? String(body.initiative_id) : null;
|
|
257
|
+
if (initiative_id && !validId(initiative_id)) return { ok: false, error: 'that initiative id is not a record name' };
|
|
258
|
+
return {
|
|
259
|
+
ok: true,
|
|
260
|
+
row: {
|
|
261
|
+
at: at || new Date().toISOString(),
|
|
262
|
+
initiative_id,
|
|
263
|
+
artifact_path,
|
|
264
|
+
kind,
|
|
265
|
+
target: target.slice(0, 300),
|
|
266
|
+
// Stated on every row, not implied by its absence: this is a DECLARATION. Nothing was read
|
|
267
|
+
// from the destination and nothing was written to it.
|
|
268
|
+
declared: true,
|
|
269
|
+
connector: null,
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Validate + normalise an initiative-link (manual reference) declaration. */
|
|
275
|
+
function initiativeLinkRow(body, { at } = {}) {
|
|
276
|
+
const initiative_id = String((body && body.initiative_id) || '').trim();
|
|
277
|
+
const kind = String((body && body.kind) || '').trim().toLowerCase();
|
|
278
|
+
const label = String((body && body.label) || '').trim();
|
|
279
|
+
const target = String((body && (body.target || body.path)) || '').trim();
|
|
280
|
+
if (!validId(initiative_id)) return { ok: false, error: 'that initiative id is not a record name' };
|
|
281
|
+
if (!kind) return { ok: false, error: 'kind is required' };
|
|
282
|
+
if (!target) return { ok: false, error: 'a target reference is required' };
|
|
283
|
+
return {
|
|
284
|
+
ok: true,
|
|
285
|
+
row: {
|
|
286
|
+
at: at || new Date().toISOString(),
|
|
287
|
+
initiative_id,
|
|
288
|
+
kind: kind.slice(0, 40),
|
|
289
|
+
label: (label || target).slice(0, 200),
|
|
290
|
+
target: target.slice(0, 300),
|
|
291
|
+
declared: true,
|
|
292
|
+
connector: null,
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/* ── the assembled payload ──────────────────────────────────────────────────────────────────── */
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Join the pieces into the one payload panel 3 reads. Every field is either something a producer
|
|
301
|
+
* returned or explicitly null; nothing here invents a value.
|
|
302
|
+
*
|
|
303
|
+
* The result follows the house contract: `{ok:false,error}` for a failed read (RED),
|
|
304
|
+
* `{ok:true,absent:true,…}` for an initiative with no record (a supported, calm state — the
|
|
305
|
+
* artifacts are still listed, because the library can see them and claiming otherwise would hide
|
|
306
|
+
* real files), `{ok:true,…}` otherwise.
|
|
307
|
+
*/
|
|
308
|
+
function assemble({ id, rec, ladder, links, index, sessions, workspaces, anchor, artifactLinks, initiativeLinks }) {
|
|
309
|
+
if (!rec.ok) return { ok: false, id, error: rec.error };
|
|
310
|
+
const artifacts = artifactsFor(index, id);
|
|
311
|
+
const sess = sessionsFor({ sessions, workspaces, id });
|
|
312
|
+
const mine = (rows, pred) => (rows && rows.ok ? rows.rows.filter(pred) : []);
|
|
313
|
+
return {
|
|
314
|
+
ok: true,
|
|
315
|
+
id,
|
|
316
|
+
absent: !!rec.absent,
|
|
317
|
+
record: rec.record,
|
|
318
|
+
frontmatter: rec.frontmatter,
|
|
319
|
+
tasks: rec.tasks || null,
|
|
320
|
+
artifacts,
|
|
321
|
+
// Straight through from console-state — the SAME object `/api/console?name=ladder` returns,
|
|
322
|
+
// including its own {ok,empty,row,scope} discrimination, so a client already reading that
|
|
323
|
+
// endpoint reads this one without a second shape.
|
|
324
|
+
ladder: ladder || null,
|
|
325
|
+
// Straight through from handoff.js. `connected` is the literal false on every path it has.
|
|
326
|
+
links: links || null,
|
|
327
|
+
anchor: anchor || null,
|
|
328
|
+
sessions: sess.sessions,
|
|
329
|
+
session_trees: sess.matched,
|
|
330
|
+
declarations: {
|
|
331
|
+
artifacts: mine(artifactLinks, (r) => r && (r.initiative_id === id
|
|
332
|
+
|| artifacts.some((a) => a.path === r.artifact_path))),
|
|
333
|
+
links: mine(initiativeLinks, (r) => r && r.initiative_id === id),
|
|
334
|
+
artifacts_error: artifactLinks && artifactLinks.ok ? null : (artifactLinks && artifactLinks.error) || null,
|
|
335
|
+
links_error: initiativeLinks && initiativeLinks.ok ? null : (initiativeLinks && initiativeLinks.error) || null,
|
|
336
|
+
},
|
|
337
|
+
connectors: connectors(),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
module.exports = {
|
|
342
|
+
ARTIFACT_LINKS, INITIATIVE_LINKS, LINK_KINDS,
|
|
343
|
+
validId, connectors, readRecord, parseTasks, artifactsFor, sessionsFor, anchorFor,
|
|
344
|
+
storeDir, readDeclarations, appendDeclaration, artifactLinkRow, initiativeLinkRow, assemble,
|
|
345
|
+
};
|
|
@@ -85,6 +85,33 @@ function worktreesAt(top) {
|
|
|
85
85
|
return out;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// The URL `origin` fetches from, or null when there is no origin (or no git at all). This is the
|
|
89
|
+
// ONLY thing that can answer "did this working tree come from somewhere, or is it a folder on this
|
|
90
|
+
// machine?" — the presence of a `.git` directory answers a different question and answering with it
|
|
91
|
+
// would put a provenance label on a row that no read supports.
|
|
92
|
+
// `git remote get-url` rather than `git config remote.origin.url`: it resolves insteadOf/pushInsteadOf
|
|
93
|
+
// rewrites, so what comes back is the URL git would actually contact.
|
|
94
|
+
function originUrl(root) {
|
|
95
|
+
if (!root || !isDir(root)) return null;
|
|
96
|
+
const r = git(root, ['remote', 'get-url', 'origin']);
|
|
97
|
+
if (r.failed || r.code !== 0 || !r.out) return null;
|
|
98
|
+
return r.out.split('\n')[0].trim() || null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Is this remote a GitHub one? Both URL shapes git accepts: scp-style (git@github.com:o/r.git) and
|
|
102
|
+
// a real URL (https://github.com/o/r, ssh://git@github.com/o/r). Any other host — GitLab, Gitea, a
|
|
103
|
+
// bare path, a local mirror — is NOT github, and saying otherwise would be the unsupported label
|
|
104
|
+
// this function exists to avoid. It answers about the URL only; the caller decides what to call it.
|
|
105
|
+
function isGithubRemote(url) {
|
|
106
|
+
const u = String(url || '').trim();
|
|
107
|
+
if (!u) return false;
|
|
108
|
+
const scp = u.match(/^[^/@]+@([^:/]+):/); // git@host:owner/repo.git
|
|
109
|
+
const host = scp ? scp[1] : (u.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]*@)?([^/:]+)/i) || [])[1];
|
|
110
|
+
if (!host) return false;
|
|
111
|
+
const h = host.toLowerCase();
|
|
112
|
+
return h === 'github.com' || h.endsWith('.github.com');
|
|
113
|
+
}
|
|
114
|
+
|
|
88
115
|
// Where an isolated worktree lands: a sibling of the repo, gathered under one `<repo>-worktrees/`
|
|
89
116
|
// folder. Deliberately OUTSIDE the repo — nothing new appears inside the tree, so no `git status`
|
|
90
117
|
// noise and nothing for .gitignore to chase. The path is derived from a name already passed
|
|
@@ -240,4 +267,4 @@ function branchInfo(root) {
|
|
|
240
267
|
}
|
|
241
268
|
|
|
242
269
|
module.exports = { isRepo, repoRoot, validBranchName, branchInfo, checkout, worktrees, worktreePath,
|
|
243
|
-
addWorktree, worktreeSafety, removeWorktree };
|
|
270
|
+
addWorktree, worktreeSafety, removeWorktree, originUrl, isGithubRemote };
|
|
@@ -52,10 +52,17 @@ const MODE_TITLE = {
|
|
|
52
52
|
knowledge: 'knowledge', agent: 'agents', memory: 'memory',
|
|
53
53
|
};
|
|
54
54
|
// What a DETAIL screen is called. It moved here with the reducer, for the same reason.
|
|
55
|
+
// The second block is the four-panel surface's `detail.kind` vocabulary (pm/app.js's DETAIL_TITLE).
|
|
56
|
+
// It is ADDED rather than substituted: the first block is what the retired surface pushes and the
|
|
57
|
+
// two overlap on every key they share, so one map titles a detail screen coming from either. A
|
|
58
|
+
// missing key here is not a crash — `titleOf` falls back to 'detail' — it is a phone header that
|
|
59
|
+
// says nothing, which is why the set is written down rather than left to the fallback.
|
|
55
60
|
const DETAIL_TITLE = {
|
|
56
61
|
initiative: 'initiative', repo: 'repository', flow: 'workflow', sched: 'scheduled task',
|
|
57
62
|
skill: 'skill', know: 'knowledge', agent: 'agent', mem: 'memory',
|
|
58
63
|
ingest: 'ingest proposal', profiles: 'profiles', connections: 'connections',
|
|
64
|
+
init: 'initiative', prof: 'claude profile', intg: 'connection',
|
|
65
|
+
addprod: 'add repository', new: 'file new',
|
|
59
66
|
};
|
|
60
67
|
|
|
61
68
|
const MOBILE_DESTS = ['board', 'scheduled', 'agents', 'memory', 'more'];
|
|
@@ -74,6 +81,27 @@ const MOBILE_ROOT = {
|
|
|
74
81
|
// kind → column. There is no second input, and no screen may carry a column its kind does not give.
|
|
75
82
|
const MOBILE_COLUMN = { list: 'side', more: 'side', detail: 'rail', terminal: 'term', doc: 'term' };
|
|
76
83
|
function mobColumnOf(kind) { return MOBILE_COLUMN[kind] || 'side'; }
|
|
84
|
+
|
|
85
|
+
/* kind → PANEL INDEX, for the four-panel surface (public/pm/).
|
|
86
|
+
*
|
|
87
|
+
* WHY THIS EXISTS ALONGSIDE `column`, RATHER THAN REPLACING IT. `column` names one of the three
|
|
88
|
+
* columns of the retired `public/app.js` layout, and MOBNAV-P1 pins it to exactly those three
|
|
89
|
+
* values. The surface that ships now has FOUR panels — repositories · mode list · detail · tabs —
|
|
90
|
+
* and two of the old `side` screens belong to DIFFERENT panels there: a `list` screen is panel 2
|
|
91
|
+
* (the mode list) while a `more` screen is panel 1, which is the structure/settings panel and is
|
|
92
|
+
* therefore the phone's "more" destination in the four-panel design.
|
|
93
|
+
*
|
|
94
|
+
* So this is an ADDITIVE widening, not an edit: `columnOf` is untouched and still describes the
|
|
95
|
+
* three-column layout truthfully for the surface that owns it, and `panelOf` describes the
|
|
96
|
+
* four-panel one. Both are pure functions of `kind` alone, so the exclusivity guarantee — one
|
|
97
|
+
* visible surface per screen — is the same property stated against a different layout. Putting it
|
|
98
|
+
* HERE rather than in pm/mobile.js keeps a single node-walkable source of truth for "which surface
|
|
99
|
+
* does this screen show", which is the whole reason the reducer was pulled out of app.js.
|
|
100
|
+
*/
|
|
101
|
+
const MOBILE_PANEL = { more: 0, list: 1, detail: 2, terminal: 3, doc: 3 };
|
|
102
|
+
function mobPanelOf(kind) {
|
|
103
|
+
return Object.prototype.hasOwnProperty.call(MOBILE_PANEL, kind) ? MOBILE_PANEL[kind] : 1;
|
|
104
|
+
}
|
|
77
105
|
function mobDestForMode(k) { return MOBILE_DEST[k] || null; }
|
|
78
106
|
// Complete a partial screen. The column is derived, never passed in, so a caller cannot put a
|
|
79
107
|
// detail on the terminal column by writing it down.
|
|
@@ -120,8 +148,10 @@ function mobCurrentOf(nav) {
|
|
|
120
148
|
function mobMigrateView(v) { return MOBILE_ROOT[v] ? v : 'board'; }
|
|
121
149
|
|
|
122
150
|
return { push: mobPush, pop: mobPop, switchDest: mobSwitchDest, init: mobNavInit,
|
|
123
|
-
current: mobCurrentOf, titleOf: mobTitleOf, columnOf: mobColumnOf,
|
|
151
|
+
current: mobCurrentOf, titleOf: mobTitleOf, columnOf: mobColumnOf, panelOf: mobPanelOf,
|
|
152
|
+
destForMode: mobDestForMode,
|
|
124
153
|
MODE_TITLE,
|
|
125
154
|
migrateView: mobMigrateView, screen: mobScreen, root: mobRootOf,
|
|
126
|
-
DESTS: MOBILE_DESTS, DEST_FOR_MODE: MOBILE_DEST, ROOTS: MOBILE_ROOT,
|
|
155
|
+
DESTS: MOBILE_DESTS, DEST_FOR_MODE: MOBILE_DEST, ROOTS: MOBILE_ROOT,
|
|
156
|
+
PANEL_OF_KIND: MOBILE_PANEL, DETAIL_TITLE };
|
|
127
157
|
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* lib/server-discovery.js — WHICH workspace server is running, and on WHICH port.
|
|
3
|
+
*
|
|
4
|
+
* The launcher used to answer that question with a constant: port 7433, or whatever
|
|
5
|
+
* `PMOS_WS_PORT` said in THIS shell. That constant is why a second repo could not be added to a
|
|
6
|
+
* running workspace. The sequence the PM hit, end to end:
|
|
7
|
+
*
|
|
8
|
+
* 1. something already held 7433 (a wedged server, a crashed one whose socket lingered, or an
|
|
9
|
+
* unrelated program), so the server exited with EADDRINUSE and printed the advice
|
|
10
|
+
* `PMOS_WS_PORT=7500 npm start`;
|
|
11
|
+
* 2. the PM followed it, and the workspace came up on 7500 — the browser tab is on 7500;
|
|
12
|
+
* 3. `pmos` in the other repo, in a fresh shell with no `PMOS_WS_PORT`, looked at 7433 only,
|
|
13
|
+
* found nothing, and started a SECOND server there. The repo was registered — with the
|
|
14
|
+
* server nobody was looking at.
|
|
15
|
+
*
|
|
16
|
+
* Nothing in that sequence errors. That is the whole defect: two servers, each honest, and a repo
|
|
17
|
+
* that "never added" because it landed in the one that isn't on screen.
|
|
18
|
+
*
|
|
19
|
+
* So the port is DISCOVERED, never assumed. The candidate list is built from state the running
|
|
20
|
+
* server itself writes (`state/server-<port>.pid`, written on listen and removed on shutdown —
|
|
21
|
+
* server.js), which makes a server discoverable however it was started: `pmos`, `npm start`, or a
|
|
22
|
+
* hand-typed `PMOS_WS_PORT=7500 node server.js`.
|
|
23
|
+
*
|
|
24
|
+
* An explicit `PMOS_WS_PORT` is treated as a PIN, not a hint: it narrows the candidates to that one
|
|
25
|
+
* port and never widens them. The test suite depends on that isolation — a suite that could attach
|
|
26
|
+
* to the PM's real workspace would drive their live sessions.
|
|
27
|
+
*
|
|
28
|
+
* Everything here is pure, or reads the filesystem and nothing else: no sockets, no child
|
|
29
|
+
* processes. The I/O lives in the caller (bin/pmos.js, doctor.js), so the whole policy is testable
|
|
30
|
+
* in-process without binding a port.
|
|
31
|
+
*/
|
|
32
|
+
const fs = require('fs');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
|
|
35
|
+
const DEFAULT_PORT = 7433;
|
|
36
|
+
// How far the launcher rolls forward when the default port is taken by something that is not a
|
|
37
|
+
// workspace server. Bounded on purpose: "start somewhere, anywhere" would scatter servers across
|
|
38
|
+
// the ephemeral range and make discovery a scan.
|
|
39
|
+
const FALLBACK_SPAN = 12;
|
|
40
|
+
|
|
41
|
+
// state/server-<port>.pid — the launcher AND the server agree on this name; it is the only place a
|
|
42
|
+
// port is recorded, so it is the only shape parsed here.
|
|
43
|
+
const PID_RE = /^server-(\d{2,5})\.pid$/;
|
|
44
|
+
|
|
45
|
+
const pidFileName = (port) => `server-${Number(port)}.pid`;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Every `state/server-<port>.pid` on disk, most recently written first. A record is returned even
|
|
49
|
+
* when its pid is unreadable — a file whose contents are gone still names a port worth probing,
|
|
50
|
+
* and treating it as absent is how a running server becomes invisible.
|
|
51
|
+
*/
|
|
52
|
+
function pidRecords(stateDir) {
|
|
53
|
+
let names = [];
|
|
54
|
+
try { names = fs.readdirSync(stateDir); } catch { return []; }
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const name of names) {
|
|
57
|
+
const m = PID_RE.exec(name);
|
|
58
|
+
if (!m) continue;
|
|
59
|
+
const file = path.join(stateDir, name);
|
|
60
|
+
let pid = null, mtime = 0;
|
|
61
|
+
try { pid = Number(String(fs.readFileSync(file, 'utf8')).trim()) || null; } catch {}
|
|
62
|
+
try { mtime = fs.statSync(file).mtimeMs; } catch {}
|
|
63
|
+
out.push({ port: Number(m[1]), pid, file, mtime });
|
|
64
|
+
}
|
|
65
|
+
out.sort((a, b) => b.mtime - a.mtime);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The ports to probe, in order. With a pinned port that list is exactly one long — see the header:
|
|
71
|
+
* a pin is an instruction, and widening it would let a test (or a second workspace deliberately
|
|
72
|
+
* kept apart) attach to a server it was told not to touch.
|
|
73
|
+
*/
|
|
74
|
+
function candidatePorts({ stateDir, pinned = null, defaultPort = DEFAULT_PORT } = {}) {
|
|
75
|
+
const pin = Number(pinned);
|
|
76
|
+
if (Number.isInteger(pin) && pin > 0) return [pin];
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
const out = [];
|
|
79
|
+
const push = (p) => { if (Number.isInteger(p) && p > 0 && !seen.has(p)) { seen.add(p); out.push(p); } };
|
|
80
|
+
push(Number(defaultPort) || DEFAULT_PORT);
|
|
81
|
+
for (const r of pidRecords(stateDir)) push(r.port);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Is this pid still around? EPERM means yes-but-not-ours, which is still alive. */
|
|
86
|
+
function pidAlive(pid, kill) {
|
|
87
|
+
const k = kill || ((p, sig) => process.kill(p, sig));
|
|
88
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
89
|
+
try { k(pid, 0); return true; } catch (e) { return !!e && e.code === 'EPERM'; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Which pid files describe a server that is gone. Deliberately conservative: a file is stale only
|
|
94
|
+
* when its port does NOT answer AND its pid is NOT alive. A server that is mid-start — listening
|
|
95
|
+
* socket not yet open, process very much alive — must survive this, or one launcher would delete
|
|
96
|
+
* another's record and make it undiscoverable a second later.
|
|
97
|
+
*/
|
|
98
|
+
function stalePidFiles(stateDir, { livePorts = [], alive = pidAlive } = {}) {
|
|
99
|
+
const live = new Set(livePorts.map(Number));
|
|
100
|
+
return pidRecords(stateDir)
|
|
101
|
+
.filter((r) => !live.has(r.port) && !alive(r.pid))
|
|
102
|
+
.map((r) => r.file);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The first port at or after `start` that `isFree` accepts, within the bounded span. `isFree` is
|
|
107
|
+
* injected (the caller owns the socket), so this stays pure and the span is assertable.
|
|
108
|
+
*/
|
|
109
|
+
function nextFreePort(start, isFree, span = FALLBACK_SPAN) {
|
|
110
|
+
const from = Number(start) || DEFAULT_PORT;
|
|
111
|
+
for (let p = from; p < from + span; p++) if (isFree(p)) return p;
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Does this JSON body come from a workspace server? `/api/sessions` answers `{sessions:[…]}`, and
|
|
117
|
+
* requiring the array is what keeps an unrelated program that happens to serve JSON on 7433 from
|
|
118
|
+
* being adopted as "the workspace" — the launcher would then register the PM's repo into it.
|
|
119
|
+
*/
|
|
120
|
+
function looksLikeWorkspace(body) {
|
|
121
|
+
return !!body && typeof body === 'object' && Array.isArray(body.sessions);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
DEFAULT_PORT, FALLBACK_SPAN, PID_RE,
|
|
126
|
+
pidFileName, pidRecords, candidatePorts, pidAlive, stalePidFiles, nextFreePort, looksLikeWorkspace,
|
|
127
|
+
};
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.16.1",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
"bin": {
|
|
20
20
|
"pmos": "bin/pmos.js"
|
|
21
21
|
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"pixelmatch": "^7.1.0",
|
|
24
|
+
"pngjs": "^7.0.0"
|
|
25
|
+
},
|
|
22
26
|
"engines": {
|
|
23
27
|
"node": ">=18"
|
|
24
28
|
}
|
|
@@ -57,6 +61,19 @@
|
|
|
57
61
|
"node-addon-api": "^7.1.0"
|
|
58
62
|
}
|
|
59
63
|
},
|
|
64
|
+
"node_modules/pixelmatch": {
|
|
65
|
+
"version": "7.2.0",
|
|
66
|
+
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.2.0.tgz",
|
|
67
|
+
"integrity": "sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==",
|
|
68
|
+
"dev": true,
|
|
69
|
+
"license": "ISC",
|
|
70
|
+
"dependencies": {
|
|
71
|
+
"pngjs": "^7.0.0"
|
|
72
|
+
},
|
|
73
|
+
"bin": {
|
|
74
|
+
"pixelmatch": "bin/pixelmatch"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
60
77
|
"node_modules/playwright-core": {
|
|
61
78
|
"version": "1.61.1",
|
|
62
79
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
|
@@ -69,6 +86,16 @@
|
|
|
69
86
|
"node": ">=18"
|
|
70
87
|
}
|
|
71
88
|
},
|
|
89
|
+
"node_modules/pngjs": {
|
|
90
|
+
"version": "7.0.0",
|
|
91
|
+
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
|
|
92
|
+
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
|
|
93
|
+
"dev": true,
|
|
94
|
+
"license": "MIT",
|
|
95
|
+
"engines": {
|
|
96
|
+
"node": ">=14.19.0"
|
|
97
|
+
}
|
|
98
|
+
},
|
|
72
99
|
"node_modules/ws": {
|
|
73
100
|
"version": "8.21.1",
|
|
74
101
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|