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.
- package/README.md +57 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/README.md +44 -12
- package/workspace/design-parity.js +358 -0
- package/workspace/lib/console-state.js +48 -1
- package/workspace/lib/engine/workflow-state.js +13 -3
- package/workspace/lib/find-browser.js +60 -0
- package/workspace/lib/focus.js +967 -0
- package/workspace/lib/four-panel.js +345 -0
- package/workspace/lib/git-info.js +28 -1
- package/workspace/lib/handoff.js +280 -0
- package/workspace/lib/layout.js +68 -46
- package/workspace/lib/mobile-nav.js +157 -0
- package/workspace/lib/profiles.js +285 -0
- package/workspace/lib/repo-scan.js +469 -0
- package/workspace/package-lock.json +29 -2
- package/workspace/package.json +14 -4
- package/workspace/public/index.html +79 -279
- package/workspace/public/pm/app.js +242 -0
- package/workspace/public/pm/data.js +877 -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 +460 -0
- package/workspace/public/pm/panel-tabs.js +487 -0
- package/workspace/public/pm/state.js +164 -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 +1072 -31
- package/workspace/public/app.js +0 -3450
- 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
|
+
});
|