create-pmos 0.14.0 → 0.16.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.
Files changed (39) hide show
  1. package/README.md +57 -0
  2. package/package.json +1 -1
  3. package/payload/KIT-VERSION +2 -2
  4. package/workspace/README.md +44 -12
  5. package/workspace/design-parity.js +358 -0
  6. package/workspace/lib/console-state.js +48 -1
  7. package/workspace/lib/engine/workflow-state.js +13 -3
  8. package/workspace/lib/find-browser.js +60 -0
  9. package/workspace/lib/focus.js +967 -0
  10. package/workspace/lib/four-panel.js +345 -0
  11. package/workspace/lib/git-info.js +28 -1
  12. package/workspace/lib/handoff.js +280 -0
  13. package/workspace/lib/layout.js +68 -46
  14. package/workspace/lib/mobile-nav.js +157 -0
  15. package/workspace/lib/profiles.js +285 -0
  16. package/workspace/lib/repo-scan.js +469 -0
  17. package/workspace/package-lock.json +29 -2
  18. package/workspace/package.json +14 -4
  19. package/workspace/public/index.html +79 -279
  20. package/workspace/public/pm/app.js +242 -0
  21. package/workspace/public/pm/data.js +877 -0
  22. package/workspace/public/pm/dom.js +116 -0
  23. package/workspace/public/pm/fixture.js +979 -0
  24. package/workspace/public/pm/mobile.css +200 -0
  25. package/workspace/public/pm/mobile.js +403 -0
  26. package/workspace/public/pm/modes.js +1076 -0
  27. package/workspace/public/pm/panel-detail.js +648 -0
  28. package/workspace/public/pm/panel-initiatives.js +426 -0
  29. package/workspace/public/pm/panel-repos.js +460 -0
  30. package/workspace/public/pm/panel-tabs.js +487 -0
  31. package/workspace/public/pm/state.js +164 -0
  32. package/workspace/public/pm/tab-doc.js +279 -0
  33. package/workspace/public/pm/tab-task.js +83 -0
  34. package/workspace/public/pm/tab-term.js +560 -0
  35. package/workspace/public/pm/tokens.css +73 -0
  36. package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
  37. package/workspace/server.js +1072 -31
  38. package/workspace/public/app.js +0 -3450
  39. package/workspace/public/styles.css +0 -1444
@@ -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 };
@@ -0,0 +1,280 @@
1
+ 'use strict';
2
+ /*
3
+ * The handoff links model (workspace-jira-handoff · PRD R1/R4/R5/R6/R7).
4
+ *
5
+ * A real PMOS workflow can end outside PMOS: the PM owns intake through spec, and the build happens
6
+ * on another team's board. This module turns what an initiative RECORD declares about that handoff
7
+ * into a model the panel can render. It reads the record's frontmatter and nothing else, because
8
+ * there is nothing else to read.
9
+ *
10
+ * THE DISTINCTION THE WHOLE MODULE RESTS ON:
11
+ * A DECLARATION is something the PM typed into `planning/initiatives/<id>.md`.
12
+ * A READING is something PMOS went and looked at.
13
+ * This module returns declarations, labels them as declarations, and returns no readings at all.
14
+ *
15
+ * Even the epic key is a declaration. PMOS does not know that `ATL-482` exists, that it is an epic,
16
+ * that it belongs to this initiative, or that anyone has touched it. It knows that a human wrote
17
+ * that string in a file in this repository. Every rule below follows from taking that seriously.
18
+ *
19
+ * WHY THERE IS NO STORY COUNT AND NO SYNC TIME, AND WHY THAT IS NOT A "NOT YET".
20
+ * There is no Jira client, no Confluence client, no credential store, no host allow-list and no
21
+ * outbound call anywhere in the workspace. That absence is a RULING, not a gap:
22
+ * `planning/prd/jira-sync-prd.md` records that a PMOS-side Jira REST client and a stored credential
23
+ * were built in a first draft and then REJECTED by the PM at build review, under D01 (orchestrate,
24
+ * don't execute) and D51 (no standing credential). So `3 of 7 stories done` here would not be an
25
+ * optimistic estimate or a stale cache — it would be a fabricated reading of a system PMOS cannot
26
+ * see, wearing the same visual grammar the panel uses for facts it actually derived. A PM who
27
+ * believes PMOS is watching Jira stops watching Jira, and that is the failure this module exists to
28
+ * make impossible.
29
+ *
30
+ * HONEST BY CONSTRUCTION, NOT BY DISCIPLINE. The defence is structural, so that it survives an
31
+ * author who has not read this comment. handoff.test.js asserts each of these as a property:
32
+ *
33
+ * · this file `require`s NOTHING — no fs, no net, no clock. There is no path by which an external
34
+ * reading could enter it, and no I/O to mock, stub or accidentally re-enable;
35
+ * · the model contains NO NUMBER anywhere — every value is a string, a boolean, null, or an array
36
+ * of those. A story count, an issue tally and a progress percentage have no field to live in;
37
+ * · the field sets are closed. A link is exactly LINK_FIELDS, the result is exactly RESULT_FIELDS,
38
+ * and the test compares those key lists for equality — so `synced_at` cannot be added quietly;
39
+ * · `connected` is the literal `false`, and the whole result is deep-frozen, so a consumer can
40
+ * neither flip it nor bolt a fabricated field onto it after the fact;
41
+ * · every string this module emits is either copied VERBATIM out of the record or is fixed prose
42
+ * enumerated in the test — so a value that is computed, estimated, or formatted relative to now
43
+ * fails the moment it is invented, whatever it is and however plausible it looks.
44
+ *
45
+ * The not-connected state is not decoration on this feature — it IS the feature. The failure it
46
+ * prevents is a screen on which every individual sentence is true and the composition still reads
47
+ * as a live connection.
48
+ *
49
+ * WHAT THIS MODULE DOES NOT DO — deliberately, and the renderer must:
50
+ * · it does not escape. Values come straight from frontmatter and have NOT passed through `esc()`
51
+ * (unlike `mdLink`, which is safe only because `inline()` escaped the whole line first). They
52
+ * are returned verbatim so the whitelist property test can compare them against the record file
53
+ * byte for byte; the renderer MUST escape at every interpolation site, including the `href`;
54
+ * · it does not compose the R8 ladder annotation or the R10 sessions line. It returns the pieces
55
+ * (`to`, `at`, `systems`) and leaves the sentence to the surface that shows it.
56
+ */
57
+
58
+ // ── the record fields (PRD R1) ──────────────────────────────────────────────────────────────────
59
+ // All five optional, all flat, all single-line — `parseLeadingFrontmatter` (lib/file-mode-parse.js)
60
+ // admits `^[a-z_]+: value$` and nothing else. A YAML list under a `links:` key does not partially
61
+ // parse: it TERMINATES the frontmatter block at that line and silently drops every field below it,
62
+ // including `lane` and `parent_kr`. That is why there is no list form here and never will be; the
63
+ // truncation is pinned by a test in handoff.test.js so the next author who reaches for a list finds
64
+ // a failing test explaining it rather than an initiative that reads as unanchored.
65
+ const HANDOFF_KEYS = ['flow', 'handoff_to', 'handoff_epic', 'handoff_page', 'handoff_at'];
66
+
67
+ // The two keys that name something in another system. Iteration order here — not frontmatter order —
68
+ // fixes the order of `links`, so the section renders the same way whatever order the PM typed.
69
+ const REFERENCE_KEYS = ['handoff_epic', 'handoff_page'];
70
+ const KIND = { handoff_epic: 'jira', handoff_page: 'confluence' };
71
+ const SYSTEM = { handoff_epic: 'Jira', handoff_page: 'Confluence' };
72
+
73
+ // The closed field sets. These exist to be asserted against: a new field is a test failure, which is
74
+ // the point — "no story count" has to be a property of the shape, not a habit of the author.
75
+ const LINK_FIELDS = ['kind', 'id', 'declared', 'state', 'why', 'state_text', 'href', 'inert_why'];
76
+ const PROBLEM_FIELDS = ['id', 'declared', 'why'];
77
+ const RESULT_FIELDS = ['connected', 'handoff', 'flow', 'to', 'at', 'systems', 'links', 'problems', 'empty'];
78
+
79
+ // The one state a link can be in. There is no second value and no code path toward one.
80
+ const NOT_CONNECTED = 'not-connected';
81
+
82
+ // R5 — why a bare reference is inert. PMOS was handed a key, not a location; guessing the location
83
+ // (a base URL, an issue-browse path) would be PMOS inventing the one thing it was not told.
84
+ const INERT_WHY = 'PMOS was given a reference, not a URL, and will not construct one it was not given';
85
+
86
+ // R4 — the state, per row and per system. `why` is the short clause; `state_text` is the full
87
+ // sentence for the row's accessible name. BOTH are per-link on purpose: a single banner at the top
88
+ // of the section is the FAIL shape the PRD names, because a PM scanning a row sees the row.
89
+ function whyFor(system) { return `no ${system} credential is configured`; }
90
+ function stateTextFor(system) {
91
+ return `not connected — ${whyFor(system)}; this is what the record declares, not a reading`;
92
+ }
93
+
94
+ // ── validation ──────────────────────────────────────────────────────────────────────────────────
95
+ // This surface has no read that can fail, so a malformed declaration is the only failure it CAN
96
+ // have — which makes it the only thing that may render red, and it must (R7).
97
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
98
+ const ISO_DATE = /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/;
99
+ const SINGLE_TOKEN = /^\S+$/;
100
+ // `flow` names the file `planning/workflows/<flow>.md`, so it is a filename and is checked as one.
101
+ // A value carrying `/` or a leading `.` would send the "open the template" affordance somewhere the
102
+ // PM did not point it.
103
+ const TEMPLATE_NAME = /^[a-z0-9][a-z0-9._-]*$/;
104
+ // R5's allow-list, deliberately STRICTER than `mdLink`'s (`app.js:2999`), which also admits
105
+ // protocol-relative `//host` and `mailto:`. Neither belongs on a handoff reference: `//evil` inherits
106
+ // whatever scheme the page happens to be on, and a mail address is not an epic. A host character is
107
+ // required after the slashes, so a bare `https://` is refused rather than rendered as a dead link.
108
+ const ABSOLUTE_URL = /^https?:\/\/[^\s/?#]+/i;
109
+
110
+ const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
111
+
112
+ // Names a JS type for a problem message. Used ONLY when the declared value is not text at all, in
113
+ // which case there is no "value verbatim" to print and naming what arrived is the honest substitute.
114
+ function typeName(v) {
115
+ if (v === null) return 'null';
116
+ if (Array.isArray(v)) return 'an array';
117
+ return typeof v === 'object' ? 'an object' : `a ${typeof v}`;
118
+ }
119
+
120
+ // Per-key shape rules. Each returns a reason string when the value is malformed, else null.
121
+ // NOTE: a reason NEVER embeds the declared value — the `declared` field prints it verbatim, and
122
+ // keeping the prose value-free is what lets the whitelist property test enumerate the fixed
123
+ // vocabulary this module can emit.
124
+ const CHECK = {
125
+ flow: (v) => (TEMPLATE_NAME.test(v) ? null
126
+ : '`flow:` names a template file under planning/workflows/ — lower-case letters, digits, dot, underscore and hyphen only. This value cannot name a file, so there is nothing to open'),
127
+ // `handoff_to` is "who received it, in plain language" (R1), so whitespace is EXPECTED here and
128
+ // the token rule below must not be applied to it. The asymmetry is deliberate, not an oversight.
129
+ handoff_to: () => null,
130
+ handoff_epic: (v) => (SINGLE_TOKEN.test(v) ? null
131
+ : '`handoff_epic:` holds a reference or an absolute URL — a single token. This value contains whitespace, so it is prose in a field meant for an identifier, and PMOS will not dress prose as a reference'),
132
+ handoff_page: (v) => (SINGLE_TOKEN.test(v) ? null
133
+ : '`handoff_page:` holds a reference or an absolute URL — a single token. This value contains whitespace, so it is prose in a field meant for an identifier, and PMOS will not dress prose as a reference'),
134
+ handoff_at: (v) => (ISO_DATE.test(v) ? null
135
+ : '`handoff_at:` is the ISO date the PM declared the handoff (YYYY-MM-DD). This value is not one, and PMOS will not guess what date was meant'),
136
+ };
137
+
138
+ /* Read ONE declared key. Returns { present, value, why }:
139
+ * present:false — the key is not in the record. Nothing is claimed about it.
140
+ * why:null — a well-formed declaration; `value` is the verbatim (trimmed) string.
141
+ * why:<reason> — malformed; `value` is the offending string verbatim, or null when the
142
+ * declared value was never text.
143
+ * ABSENT and MALFORMED are answered separately because they are different facts: one is an
144
+ * initiative that was not handed off, the other is a record with a mistake in it, and rendering the
145
+ * second as the first is how a typo becomes silence.
146
+ */
147
+ function readDeclared(fm, key) {
148
+ if (!has(fm, key)) return { present: false, value: null, why: null };
149
+ const raw = fm[key];
150
+ if (typeof raw !== 'string') {
151
+ return { present: true, value: null,
152
+ why: `\`${key}:\` is ${typeName(raw)}, not the single-line string the record format admits — PMOS prints what the record declares as text, and there is no text here to print` };
153
+ }
154
+ const v = raw.trim();
155
+ if (!v) {
156
+ return { present: true, value: null,
157
+ why: `\`${key}:\` is present but empty — the key is declared and no value is, so there is nothing to state` };
158
+ }
159
+ if (CONTROL_CHARS.test(v)) {
160
+ return { present: true, value: null,
161
+ why: `\`${key}:\` contains a control character — refused rather than rendered, because what it would print is not what the record shows` };
162
+ }
163
+ return { present: true, value: v, why: CHECK[key](v) };
164
+ }
165
+
166
+ // Freezing is part of the contract, not tidiness: a frozen result cannot acquire a `synced_at` or a
167
+ // `story_count` downstream, and `connected` cannot be flipped by a consumer that would rather it
168
+ // were true.
169
+ function deepFreeze(o) {
170
+ if (o && typeof o === 'object' && !Object.isFrozen(o)) {
171
+ Object.freeze(o);
172
+ for (const k of Object.keys(o)) deepFreeze(o[k]);
173
+ }
174
+ return o;
175
+ }
176
+
177
+ /* readLinks(record) — the handoff model for ONE initiative.
178
+ *
179
+ * `record` is the initiative's parsed leading frontmatter: the plain object
180
+ * `parseLeadingFrontmatter(text)` returns (lib/file-mode-parse.js). Not a path, not the file text —
181
+ * this module does no I/O by design, and a caller that hands it text gets a stated problem rather
182
+ * than a silent empty, because "no handoff declared" and "you gave me the wrong thing" are
183
+ * different facts.
184
+ *
185
+ * Returns a deep-frozen object with exactly RESULT_FIELDS:
186
+ *
187
+ * connected false — ALWAYS, a literal. There is no argument, no record field and no code path
188
+ * that produces any other value.
189
+ * handoff boolean — the record declares `flow:` or any `handoff_*` key. False for every
190
+ * initiative that was not handed off, so the panel is unchanged for them.
191
+ * flow string|null — the verbatim `flow:` value, or null (absent or malformed).
192
+ * to string|null — the verbatim `handoff_to:` value.
193
+ * at string|null — the verbatim `handoff_at:` value, only when it is an ISO date.
194
+ * systems string[] — 'Jira' and/or 'Confluence', from which reference keys are DECLARED
195
+ * (including malformed ones — a broken epic still names what PMOS cannot read).
196
+ * links link[] — one per well-formed reference, in REFERENCE_KEYS order.
197
+ * problems problem[] — one per malformed declaration (R7). Render red, value verbatim.
198
+ * empty boolean — a handoff is declared and there is nothing to link and nothing wrong: the
199
+ * verified empty of R6. Absence is STATED, never implied by a missing section.
200
+ *
201
+ * A link is exactly LINK_FIELDS:
202
+ * kind 'jira' | 'confluence' — which system the reference points AT. A kind marker, never a
203
+ * claim about it, and never to be styled like the panel's derived `d2-pill`.
204
+ * id 'handoff_epic' | 'handoff_page' — the RECORD KEY this row came from. This is the
205
+ * row's identity for rendering; it is NOT the epic's identifier. The thing the
206
+ * PM typed is `declared`.
207
+ * declared string — the reference, verbatim and UNESCAPED. The renderer escapes.
208
+ * state 'not-connected' — always, for every link, for every record.
209
+ * why string — the short clause, per system ('no Jira credential is configured').
210
+ * state_text string — the full R4 sentence for the row's accessible name.
211
+ * href string|null — the value IFF it is an absolute http(s) URL (R5); else null. Byte-
212
+ * identical to `declared` when set — never normalised, never constructed.
213
+ * inert_why string|null — why this row is not a link, when href is null.
214
+ *
215
+ * A problem is exactly PROBLEM_FIELDS: { id, declared, why } — the record key (or 'record' for a
216
+ * caller that passed the wrong shape), the offending value verbatim (null when it was never text),
217
+ * and the reason, which never embeds the value.
218
+ */
219
+ function readLinks(record) {
220
+ const problems = [];
221
+ const links = [];
222
+ let fm = {};
223
+
224
+ if (record !== null && record !== undefined) {
225
+ if (typeof record !== 'object' || Array.isArray(record)) {
226
+ problems.push({ id: 'record', declared: null,
227
+ why: `readLinks was given ${typeName(record)}, not the parsed frontmatter object it reads — no handoff is being claimed for this record, and that is a fault rather than an absence` });
228
+ } else {
229
+ fm = record;
230
+ }
231
+ }
232
+
233
+ const declared = HANDOFF_KEYS.filter((k) => has(fm, k));
234
+ const value = {};
235
+ for (const key of HANDOFF_KEYS) {
236
+ const r = readDeclared(fm, key);
237
+ if (!r.present) continue;
238
+ if (r.why) { problems.push({ id: key, declared: r.value, why: r.why }); continue; }
239
+ value[key] = r.value;
240
+ }
241
+
242
+ for (const key of REFERENCE_KEYS) {
243
+ const v = value[key];
244
+ if (v === undefined) continue;
245
+ const isUrl = ABSOLUTE_URL.test(v);
246
+ links.push({
247
+ kind: KIND[key],
248
+ id: key,
249
+ declared: v,
250
+ state: NOT_CONNECTED,
251
+ why: whyFor(SYSTEM[key]),
252
+ state_text: stateTextFor(SYSTEM[key]),
253
+ href: isUrl ? v : null,
254
+ inert_why: isUrl ? null : INERT_WHY,
255
+ });
256
+ }
257
+
258
+ return deepFreeze({
259
+ connected: false,
260
+ handoff: declared.length > 0,
261
+ flow: value.flow === undefined ? null : value.flow,
262
+ to: value.handoff_to === undefined ? null : value.handoff_to,
263
+ at: value.handoff_at === undefined ? null : value.handoff_at,
264
+ systems: REFERENCE_KEYS.filter((k) => declared.indexOf(k) !== -1).map((k) => SYSTEM[k]),
265
+ links,
266
+ problems,
267
+ // A `flow:` with no references is a VERIFIED EMPTY, in the panel's existing vocabulary — not a
268
+ // hidden section. A malformed declaration is not empty; it is red, and red wins.
269
+ empty: declared.length > 0 && links.length === 0 && problems.length === 0,
270
+ });
271
+ }
272
+
273
+ module.exports = deepFreeze({
274
+ readLinks,
275
+ HANDOFF_KEYS, REFERENCE_KEYS, LINK_FIELDS, PROBLEM_FIELDS, RESULT_FIELDS,
276
+ NOT_CONNECTED, INERT_WHY,
277
+ // Exported so the renderer states EXACTLY what this module states — a surface that rewords the
278
+ // not-connected sentence is a surface that can soften it.
279
+ whyFor, stateTextFor,
280
+ });