create-pmos 0.29.0 → 0.30.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 +77 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/okf/core/concepts/frontmatter-format.md +36 -1
- package/workspace/lib/drive-guard.js +51 -6
- package/workspace/lib/four-panel.js +40 -3
- package/workspace/package.json +4 -4
- package/workspace/public/pm/data.js +6 -1
- package/workspace/public/pm/panel-detail.js +25 -1
- package/workspace/public/pm/panel-tabs.js +87 -0
- package/workspace/public/pm/state.js +10 -0
- package/workspace/server.js +75 -4
package/README.md
CHANGED
|
@@ -333,6 +333,83 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
|
|
|
333
333
|
`okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
|
|
334
334
|
exactly what you're running.
|
|
335
335
|
|
|
336
|
+
## What's new in 0.30.0
|
|
337
|
+
|
|
338
|
+
**Your user stories are on the board, and a story can name the Jira issue, Confluence page or
|
|
339
|
+
Google Doc it corresponds to — in the file itself. The scratchpad drags up and down.**
|
|
340
|
+
Run `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
|
|
341
|
+
|
|
342
|
+
- **User stories show up on an initiative's artifacts list, in the right place.** The index has
|
|
343
|
+
recognised `*-user-stories.md` for a while and nothing downstream used it: stories sorted *below*
|
|
344
|
+
the PRD and the rubric they decompose, and the role label was twelve characters in a box built for
|
|
345
|
+
eight, so it ran under the file path beside it. Stories now sort directly after the PRD, and the
|
|
346
|
+
chip fits. A file in a `planning/stories/` folder counts as a story too, with no suffix needed in
|
|
347
|
+
its name.
|
|
348
|
+
- **A story can carry its own destination.** Put `jira: ABC-123`, `confluence: SPACE / page` or
|
|
349
|
+
`google_doc: <url>` in a story's frontmatter and the workspace draws the ⇄ pill on its row — no
|
|
350
|
+
clicking, no re-typing on a second machine, and the tie is in git where an agent reading the file
|
|
351
|
+
can see it. It works for any planning document, not only stories.
|
|
352
|
+
- **It says what it knows, which is nothing.** That pill reads *declared in the file · nothing has
|
|
353
|
+
been checked*. PMOS holds no credential, makes no request, and cannot mark a reference resolved —
|
|
354
|
+
only a session carrying your own MCP can do that. A link you record in the panel still wins over
|
|
355
|
+
the one in the file, and `unlink` now survives a reload instead of the file quietly putting the
|
|
356
|
+
pill back.
|
|
357
|
+
- **The scratchpad resizes.** Drag the handle along its top edge — up for taller, down for shorter.
|
|
358
|
+
It cannot be dragged shut, it cannot take more than 70% of the panel (the pane above is often a
|
|
359
|
+
live terminal), the size sticks across reloads, and `↑`/`↓` work when the handle is focused with
|
|
360
|
+
`Home` to reset. Resizing never touches what you have staged in the pad.
|
|
361
|
+
- **Not in this release, said plainly:** the `Story` frontmatter type, the gate rule that enforces
|
|
362
|
+
it and the `--fix` repair are PMOS-self only and are **not** in this payload. The registry they
|
|
363
|
+
enforce is PMOS's own vocabulary, and running it inside your repository would fail files written
|
|
364
|
+
to your conventions. The workspace half above works whatever `type:` your story files declare.
|
|
365
|
+
Nothing here reads status back from a tracker — there is no "3 of 7 done", and the surface does
|
|
366
|
+
not imply there is.
|
|
367
|
+
|
|
368
|
+
## What's new in 0.29.1
|
|
369
|
+
|
|
370
|
+
**0.29.0 could not reach an existing install. This is that release, delivered.**
|
|
371
|
+
Run `npx create-pmos@latest workspace`.
|
|
372
|
+
|
|
373
|
+
- **If you are on 0.28.0, 0.29.0 never upgraded you.** The launcher decides whether to reinstall by
|
|
374
|
+
comparing the bundled workspace-app version against the installed one, and 0.29.0 shipped with the
|
|
375
|
+
app version left at `0.28.0` — so the comparison came out equal and the upgrade was silently
|
|
376
|
+
skipped. Everything listed below was in the 0.29.0 tarball and unreachable from an existing
|
|
377
|
+
install. 0.29.1 bumps both versions together, which is what actually delivers it.
|
|
378
|
+
- **The check that catches this already existed and did not run.** An assertion pins the two versions
|
|
379
|
+
in lockstep; CI only ran it when `workspace/**` changed, and a release PR touches
|
|
380
|
+
`npm-pkg/package.json`. The path filter now includes it, so the guard runs on the diff that breaks
|
|
381
|
+
it. This is the second time a version bump has shipped without reaching adopters (0.9.1 was the
|
|
382
|
+
first), and the first time the repo could have known.
|
|
383
|
+
|
|
384
|
+
**What 0.29.0 carried, now actually delivered:**
|
|
385
|
+
|
|
386
|
+
- **A skipped Review Gate no longer shows as a green tick.** `workflow-state.js` rendered a
|
|
387
|
+
`SKIPPED` verdict — a row that says *judging did not happen* — with the same ✓ it uses for a real
|
|
388
|
+
pass, and a `FAIL` likewise. A skip is now `pending` and a fail is `blocked`. If your board looked
|
|
389
|
+
greener than your ledger, this is why.
|
|
390
|
+
- **A chore that carries a rubric now shows its Review Gate.** The rung decided from the
|
|
391
|
+
initiative's **lane**, so any lane that *may* skip a rubric could never surface a verdict it
|
|
392
|
+
actually had — the ladder printed *"chore lane carries no rubric"* while a verdict sat unread. It
|
|
393
|
+
reads the rubric now, in every lane. This is how a real initiative reached `accept` with its gate
|
|
394
|
+
never run.
|
|
395
|
+
- **`evaluator.skill`'s final step is runnable.** It told you to persist the verdict through
|
|
396
|
+
`save_eval` — a hosted-backend tool that **does not exist in file mode**, which is the mode the kit
|
|
397
|
+
runs in. The last instruction of the Review Gate could not be followed. It now names the writer for
|
|
398
|
+
your context: `pmos/scripts/pmos-log.sh eval …`.
|
|
399
|
+
- **`pmos-log.sh` can record a skipped gate.** Its verdict enum was `PASS|PASS-WITH-FINDINGS|FAIL`,
|
|
400
|
+
while `AGENTS.md` tells you *"a gate that was skipped is recorded as skipped — never leave a missing
|
|
401
|
+
eval to be read as a pass."* Following that instruction hit an error, and the only compliant option
|
|
402
|
+
was to edit the ledger by hand. Now: `pmos-log.sh eval <run> <initiative> SKIPPED "<reason>"`,
|
|
403
|
+
which requires the reason and records `score: null`.
|
|
404
|
+
- **The kit build now refuses to ship a skill that names a tool you do not have.** That class had
|
|
405
|
+
already shipped twice in the same sentence of the same skill. The check runs at build time and
|
|
406
|
+
fails the build; its one bound (per-reference, not per-line) is documented in `build-kit.sh`.
|
|
407
|
+
|
|
408
|
+
*Not in this release, stated so you do not go looking:* the same upstream round hardened two modules
|
|
409
|
+
that the kit does not run — `drive-guard.js` ships inside the workspace bundle but nothing requires
|
|
410
|
+
it, and `record-eval.js` is not in the payload at all. Those fixes are real, and they are not
|
|
411
|
+
something your kit was relying on.
|
|
412
|
+
|
|
336
413
|
## What's new in 0.28.0
|
|
337
414
|
|
|
338
415
|
**`search everything…` now searches every file in the folder, and any file it finds opens in a tab.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit \u2014 run the PMOS product-management workflow (initiative \u2192 lane-fit spec \u2192 contract \u2192 build \u2192 three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -57,12 +57,47 @@ The enforced list lives in [`scripts/frontmatter_registry.txt`](/scripts/frontma
|
|
|
57
57
|
the single source `frontmatter_lint.py` reads. A `type:` value not in it fails CI (this catches typos).
|
|
58
58
|
|
|
59
59
|
- **OKF core:** `Concept`, `ADR`, `Template`, `Playbook`, `Reference`
|
|
60
|
-
- **Planning:** `Initiative`, `PRD`, `Rubric`, `OKR`, `Radar`, `Checklist`, `Calibration`, `Research`, `Design`, `Prototype`
|
|
60
|
+
- **Planning:** `Initiative`, `PRD`, `Story`, `Rubric`, `OKR`, `Radar`, `Checklist`, `Calibration`, `Research`, `Design`, `Prototype`
|
|
61
61
|
- `Initiative` is the repo-native initiative record in [`planning/initiatives/`](/planning/initiatives/), read by the
|
|
62
62
|
[Initiative Gate](/.github/scripts/initiative-gate.sh). Its D56 **work-type lane** rides a separate `lane:` key —
|
|
63
63
|
not `type:` — so the lane vocabulary and this registry never collide.
|
|
64
|
+
- `Story` is a **user-story document** — the decomposition of a spec into the units of work handed
|
|
65
|
+
to whoever builds them. It is the one type in this registry that is also **checked positionally**
|
|
66
|
+
(see below), and it may carry an optional `jira:` / `confluence:` / `google_doc:` key naming the
|
|
67
|
+
tracker record it corresponds to. That key is a *declaration* the workspace displays, never a
|
|
68
|
+
lookup: PMOS holds no credential and makes no outbound request.
|
|
64
69
|
- **Root governance:** `Decision`, `Principle`, `Debt`, `Discovery`, `Guide`
|
|
65
70
|
|
|
71
|
+
### When the path decides the type
|
|
72
|
+
|
|
73
|
+
Most types are the author's to choose. A few document kinds are identifiable from the path alone, and
|
|
74
|
+
for those `type ∈ registry` is not enough — the gate also checks the type is the RIGHT one, because a
|
|
75
|
+
file wearing a plausible wrong type passes the membership test forever.
|
|
76
|
+
|
|
77
|
+
**User stories** are the first such kind. A tracked `.md` whose filename ends `-user-stories.md`,
|
|
78
|
+
`-user-story.md` or `-stories.md`, **or** whose immediate parent directory is `stories/` (or
|
|
79
|
+
`user-stories/`), must declare `type: Story`. These are exactly the shapes the workspace's library
|
|
80
|
+
index calls a story (`ROLE_SUFFIXES` / `ROLE_BY_DIR` in `workspace/server.js`) — the board and the
|
|
81
|
+
gate are held to one definition of what a story is, rather than two that can drift.
|
|
82
|
+
|
|
83
|
+
The boundary is deliberately narrow: `planning/stories/archive/notes.md` is a note filed beside
|
|
84
|
+
stories, not a story, and `planning/research/story-mapping.md` is research. Both directions are
|
|
85
|
+
asserted in [`scripts/tests/frontmatter-story.sh`](/scripts/tests/frontmatter-story.sh).
|
|
86
|
+
|
|
87
|
+
**Retroactive repair (PMOS-self — the kit does not carry this tool).** Story files written before
|
|
88
|
+
`Story` existed declare something else — this repo's own test fixture declared `type: PRD`, because
|
|
89
|
+
nothing else was available. `python3 scripts/frontmatter_lint.py --fix` corrects them, and
|
|
90
|
+
`--root <dir>` points it at another repository so a product repo's stories can be repaired too. It
|
|
91
|
+
is bounded on purpose: story files only, the `type:` key only, and it never invents a `title` or a
|
|
92
|
+
`timestamp` — a story file missing those is reported rather than guessed at.
|
|
93
|
+
|
|
94
|
+
The linter and this registry are **not** in the create-pmos payload, and that is a decision rather
|
|
95
|
+
than an omission: the registry is *PMOS's* vocabulary, and enforcing it inside an adopter's
|
|
96
|
+
repository would fail files written to their own conventions. Whose vocabulary governs an adopter's
|
|
97
|
+
repo is its own question, and it deserves its own initiative. What adopters *do* get is the reading
|
|
98
|
+
half — the workspace draws a story on the artifacts row and shows the destination its frontmatter
|
|
99
|
+
declares, whatever `type:` the file carries.
|
|
100
|
+
|
|
66
101
|
### Extending the vocabulary
|
|
67
102
|
|
|
68
103
|
Add **one line** to `scripts/frontmatter_registry.txt` and a matching row above. A new `type` is a
|
|
@@ -202,11 +202,28 @@ const DISPOSITION_INTERNAL_RE = new RegExp(
|
|
|
202
202
|
*/
|
|
203
203
|
const DISPOSITION_VERB = '(?:PROMOTE[SD]?|ITERATE[SD]?|KILL(?:ED|S)?)';
|
|
204
204
|
|
|
205
|
-
/**
|
|
205
|
+
/**
|
|
206
|
+
* DETERMINERS ONLY, and the narrowing is measured rather than argued.
|
|
207
|
+
*
|
|
208
|
+
* The first version of this set carried 40 words — determiners, auxiliaries and modals — on the
|
|
209
|
+
* reasoning that a passive clause ("was killed off upstream") is prose too. A third independent pass
|
|
210
|
+
* showed what that bought an attacker:
|
|
211
|
+
*
|
|
212
|
+
* status: got PROMOTED by PM wawan on 2026-07-28 prev=refused → PERMITTED
|
|
213
|
+
* status: not KILLED — shipped 2026-07-28 prev=refused → PERMITTED
|
|
214
|
+
*
|
|
215
|
+
* Neither is prose. Both are forged dispositions wearing an auxiliary. Swept against this
|
|
216
|
+
* repository's own records, the ONLY genuine prose that needs an exemption at all is
|
|
217
|
+
* determiner-led — "derived from *the promoted* prototype" — which is exactly the false positive the
|
|
218
|
+
* previous round was contracted to fix. The auxiliaries were paying for a case the corpus does not
|
|
219
|
+
* contain, in forgeable forms.
|
|
220
|
+
*
|
|
221
|
+
* A status line that reads "if a specimen is promoted" is now REFUSED. That is over-refusal, it is
|
|
222
|
+
* the safe direction, and it is the trade this guard exists to make: the cost is a driver rephrasing
|
|
223
|
+
* one status line; the cost of the other direction is a forged PM disposition.
|
|
224
|
+
*/
|
|
206
225
|
const PROSE_LEAD = new Set([
|
|
207
226
|
'the', 'a', 'an', 'this', 'that', 'these', 'those', 'its', 'their', 'our', 'his', 'her',
|
|
208
|
-
'was', 'were', 'been', 'being', 'is', 'are', 'be', 'get', 'gets', 'got', 'gotten',
|
|
209
|
-
'not', 'never', 'unless', 'if', 'when', 'would', 'could', 'should', 'may', 'might',
|
|
210
227
|
]);
|
|
211
228
|
|
|
212
229
|
/**
|
|
@@ -220,8 +237,20 @@ function isDisposition(value) {
|
|
|
220
237
|
let m;
|
|
221
238
|
while ((m = re.exec(v)) !== null) {
|
|
222
239
|
const before = v.slice(0, m.index + m[1].length);
|
|
223
|
-
|
|
224
|
-
|
|
240
|
+
/* ADJACENT, separated by whitespace and nothing else.
|
|
241
|
+
*
|
|
242
|
+
* This was `[^A-Za-z']*$` — "the last run of letters, skipping ANY non-letters" — so a colon, a
|
|
243
|
+
* comma, a quote or an em dash between the determiner and the verb kept the carve-out firing
|
|
244
|
+
* while destroying the grammar that justifies it. A third independent pass walked through it:
|
|
245
|
+
*
|
|
246
|
+
* status: CLOSED — verdict is "PROMOTED" (PM wawan) prev=refused → PERMITTED
|
|
247
|
+
* status: CLOSED. Leg A is: PROMOTED 2026-07-28 prev=refused → PERMITTED
|
|
248
|
+
*
|
|
249
|
+
* "is" is not modifying "PROMOTED" across a colon and a quote mark; it is a different clause.
|
|
250
|
+
* An exemption that ignores what sits between its two words is not a grammatical rule, it is a
|
|
251
|
+
* proximity coincidence. */
|
|
252
|
+
const prior = (before.match(/([A-Za-z']+)[ \t]*$/) || [])[1];
|
|
253
|
+
if (prior && PROSE_LEAD.has(prior.toLowerCase())) continue; // adjectival — prose
|
|
225
254
|
return true;
|
|
226
255
|
}
|
|
227
256
|
return false;
|
|
@@ -343,10 +372,26 @@ function carriesDisposition(value, seen) {
|
|
|
343
372
|
const isStatusKey = k.toLowerCase() === 'status' || k.toLowerCase() === 'disposition';
|
|
344
373
|
if (isStatusKey) {
|
|
345
374
|
// A status value may be a string, a Buffer, or a list of candidate values. All are inspected.
|
|
375
|
+
/* Judge what will be WRITTEN, not what the value's JS type happens to be.
|
|
376
|
+
*
|
|
377
|
+
* `typeof c === 'string'` let two shapes through that both serialize to a real disposition:
|
|
378
|
+
*
|
|
379
|
+
* { status: { toJSON() { return 'PROMOTED'; } } } → {"status":"PROMOTED"}
|
|
380
|
+
* { status: new String('PROMOTED') } → {"status":"PROMOTED"}
|
|
381
|
+
*
|
|
382
|
+
* The bytes that land on disk are a forged PM disposition; only the in-memory type differed.
|
|
383
|
+
* A third independent pass found both, and noted the asymmetry that gives it away: the same
|
|
384
|
+
* trick aimed at PM_approved was already caught, because THAT check reads the serialized form.
|
|
385
|
+
* This one now does too. */
|
|
346
386
|
const candidates = Array.isArray(v) ? v : [v];
|
|
347
387
|
for (const c of candidates) {
|
|
348
388
|
if (typeof c === 'string' && isDisposition(c)) return true;
|
|
349
389
|
if (Buffer.isBuffer(c) && isDisposition(c.toString('utf8'))) return true;
|
|
390
|
+
if (c !== null && typeof c === 'object' && !Buffer.isBuffer(c)) {
|
|
391
|
+
let ser;
|
|
392
|
+
try { ser = JSON.stringify(c); } catch { return true; } // uninspectable → refuse
|
|
393
|
+
if (typeof ser === 'string' && isDisposition(ser)) return true;
|
|
394
|
+
}
|
|
350
395
|
}
|
|
351
396
|
}
|
|
352
397
|
if (Buffer.isBuffer(v)) continue; // a Buffer's numeric indices are not an object graph
|
|
@@ -484,6 +529,6 @@ function guardedWriter(write) {
|
|
|
484
529
|
}
|
|
485
530
|
|
|
486
531
|
module.exports = {
|
|
487
|
-
assertWritable, guardedWriter, carriesApproval, carriesDisposition, ForbiddenWrite,
|
|
532
|
+
assertWritable, guardedWriter, carriesApproval, carriesDisposition, isDisposition, PROSE_LEAD, ForbiddenWrite,
|
|
488
533
|
ALLOWED_DIR_PREFIXES, ALLOWED_BASENAMES, HUMAN_DECISION_SINKS, DISPOSITION_RE,
|
|
489
534
|
};
|
|
@@ -272,7 +272,36 @@ function artifactsFor(index, id) {
|
|
|
272
272
|
const rows = (index && Array.isArray(index.initiatives)) ? index.initiatives : [];
|
|
273
273
|
const hit = rows.find((r) => r && r.id === id);
|
|
274
274
|
if (!hit || !Array.isArray(hit.artifacts)) return [];
|
|
275
|
-
return hit.artifacts.map((a) => ({
|
|
275
|
+
return hit.artifacts.map((a) => ({
|
|
276
|
+
role: a.role, name: a.name, path: a.path, title: a.title || null,
|
|
277
|
+
// The destination the document declares about ITSELF, from its frontmatter (server.js
|
|
278
|
+
// `frontmatterDest`). It is carried, not resolved — see `withFrontmatterDest` for the one rule
|
|
279
|
+
// that decides when the panel is allowed to see it.
|
|
280
|
+
dest: a.dest || null,
|
|
281
|
+
}));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Decide, per artifact, whether the file's own declared destination is the one the panel shows.
|
|
286
|
+
*
|
|
287
|
+
* THE RULE: **if the store has spoken about an artifact at all, the store owns it.** The file fills
|
|
288
|
+
* the silence, and only the silence.
|
|
289
|
+
*
|
|
290
|
+
* "At all" includes a `retracted` row, and that clause is the whole reason this function exists
|
|
291
|
+
* rather than a `||` at the call site. `unlink` appends a retraction; `linkView` then correctly
|
|
292
|
+
* reports no link. If the fallback consulted the file at that point, the pill the PM just removed
|
|
293
|
+
* would be back on the next reload and the button would be a button that undoes itself. A
|
|
294
|
+
* retraction is a decision about this artifact, so it counts as the store having spoken.
|
|
295
|
+
*
|
|
296
|
+
* The precedence direction is the other half: a row in the store is the PM's own act in a UI, made
|
|
297
|
+
* later than the file was written, against a destination they were looking at. The file is the
|
|
298
|
+
* artifact's own statement and travels with the repository. When they disagree, the later explicit
|
|
299
|
+
* act wins — and the file is left exactly as it is, because PMOS does not edit the PM's documents.
|
|
300
|
+
*/
|
|
301
|
+
function withFrontmatterDest(artifacts, storeRows) {
|
|
302
|
+
const spoken = new Set();
|
|
303
|
+
for (const r of storeRows || []) if (r && r.artifact_path) spoken.add(r.artifact_path);
|
|
304
|
+
return (artifacts || []).map((a) => (a.dest && spoken.has(a.path) ? { ...a, dest: null } : a));
|
|
276
305
|
}
|
|
277
306
|
|
|
278
307
|
/* ── sessions ───────────────────────────────────────────────────────────────────────────────── */
|
|
@@ -727,11 +756,19 @@ function lookupTask({ kind, subject, hint, profile } = {}) {
|
|
|
727
756
|
*/
|
|
728
757
|
function assemble({ id, rec, ladder, links, index, sessions, workspaces, anchor, artifactLinks, initiativeLinks, artifactSync, homeDir }) {
|
|
729
758
|
if (!rec.ok) return { ok: false, id, error: rec.error };
|
|
730
|
-
const
|
|
759
|
+
const indexed = artifactsFor(index, id);
|
|
731
760
|
const sess = sessionsFor({ sessions, workspaces, id });
|
|
732
761
|
// linkView LAST, so superseding happens across this initiative's rows only — collapsing the whole
|
|
733
762
|
// store first would let another initiative's link to the same path win a pair that is not its own.
|
|
734
763
|
const mine = (rows, pred) => linkView((rows && rows.ok ? rows.rows.filter(pred) : []));
|
|
764
|
+
// …and `includeRetracted` here, because "the PM withdrew this" is the store having spoken about
|
|
765
|
+
// the artifact. See withFrontmatterDest: without it, unlink would undo itself on the next reload.
|
|
766
|
+
const spokenFor = linkView(
|
|
767
|
+
(artifactLinks && artifactLinks.ok ? artifactLinks.rows : []).filter((r) => r
|
|
768
|
+
&& (r.initiative_id === id || indexed.some((a) => a.path === r.artifact_path))),
|
|
769
|
+
{ includeRetracted: true },
|
|
770
|
+
);
|
|
771
|
+
const artifacts = withFrontmatterDest(indexed, spokenFor);
|
|
735
772
|
return {
|
|
736
773
|
ok: true,
|
|
737
774
|
id,
|
|
@@ -769,7 +806,7 @@ function assemble({ id, rec, ladder, links, index, sessions, workspaces, anchor,
|
|
|
769
806
|
module.exports = {
|
|
770
807
|
ARTIFACT_LINKS, INITIATIVE_LINKS, ARTIFACT_SYNC,
|
|
771
808
|
LINK_KINDS, LINK_STATES, LINK_ORIGINS, MCP_FOR_KIND, SYNC_DIRECTIONS, SYNC_STATES,
|
|
772
|
-
validId, connectors, readRecord, parseTasks, artifactsFor, sessionsFor, anchorFor,
|
|
809
|
+
validId, connectors, readRecord, parseTasks, artifactsFor, withFrontmatterDest, sessionsFor, anchorFor,
|
|
773
810
|
storeDir, readDeclarations, appendDeclaration, artifactLinkRow, initiativeLinkRow, assemble,
|
|
774
811
|
linkView, lookupTask, syncRow, syncView, syncTask,
|
|
775
812
|
};
|
package/workspace/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"private": true,
|
|
5
|
-
"description": "PMOS Terminal Workspace
|
|
5
|
+
"description": "PMOS Terminal Workspace \u2014 local companion hosting real PTYs beside the library and gates (D62)",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
8
8
|
"pmos": "bin/pmos.js"
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node server.js",
|
|
12
12
|
"test": "node test.js && npm run test:modules",
|
|
13
|
-
"test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node no-network.test.js && node fixture-parity.test.mjs && node start-policy.test.js && node state-isolation.test.js",
|
|
13
|
+
"test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node fixture-parity.test.mjs && node start-policy.test.js && node state-isolation.test.js",
|
|
14
14
|
"test:ui": "node ui-test.js",
|
|
15
15
|
"test:parity": "node design-parity.js --states",
|
|
16
16
|
"test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"playwright-core": "^1.61.1",
|
|
30
30
|
"ws": "^8.18.0"
|
|
31
31
|
},
|
|
32
|
-
"//devDependencies": "design-parity's pixel axis only. Kept OUT of dependencies deliberately: `create-pmos workspace` installs this package with `npm install -g`, which takes dependencies and not devDependencies, so an adopter installing a local PM tool does not also install a screenshot differ. design-parity.js requires both optionally and degrades to text+style+geometry when they are absent, so the parity check still runs
|
|
32
|
+
"//devDependencies": "design-parity's pixel axis only. Kept OUT of dependencies deliberately: `create-pmos workspace` installs this package with `npm install -g`, which takes dependencies and not devDependencies, so an adopter installing a local PM tool does not also install a screenshot differ. design-parity.js requires both optionally and degrades to text+style+geometry when they are absent, so the parity check still runs \u2014 it just stops measuring pixels, and says so.",
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"pixelmatch": "^7.1.0",
|
|
35
35
|
"pngjs": "^7.0.0"
|
|
@@ -332,7 +332,12 @@ function artsFor(payload) {
|
|
|
332
332
|
sync.set(r.artifact_path + '|' + r.kind, r);
|
|
333
333
|
}
|
|
334
334
|
return (payload.artifacts || []).map((a) => {
|
|
335
|
-
|
|
335
|
+
// A row in the store, else the destination the FILE declares about itself (server.js
|
|
336
|
+
// `frontmatterDest`, gated by `withFrontmatterDest` — the server has already decided which of
|
|
337
|
+
// the two the panel is allowed to see, including for an artifact whose link the PM retracted).
|
|
338
|
+
// This used to return `{role, path}` here and drop `a.dest` on the floor, even though
|
|
339
|
+
// `artifactRow` reads it as its fallback: the reader existed and the producer did not.
|
|
340
|
+
const d = dest.get(a.path) || a.dest || null;
|
|
336
341
|
if (!d) return { role: a.role, path: a.path };
|
|
337
342
|
return { role: a.role, path: a.path, dest: d, sync: sync.get(a.path + '|' + d.kind) || null };
|
|
338
343
|
});
|
|
@@ -679,6 +679,20 @@ function syncSummary(sy, dest) {
|
|
|
679
679
|
+ '. the task below is what performs it, and no session has reported back.';
|
|
680
680
|
}
|
|
681
681
|
|
|
682
|
+
/* ── the role chip's short form ───────────────────────────────────────────────────────────────
|
|
683
|
+
* The chip is a fixed 52px box — roughly eight characters of 10px mono with `.1em` tracking — and
|
|
684
|
+
* the design drew it that way. The index's canonical role for a user-story document is
|
|
685
|
+
* `user stories`, which is twelve, so it ran under the path beside it.
|
|
686
|
+
*
|
|
687
|
+
* The canonical role is NOT renamed. It is asserted as `user stories` in two suites, and it is the
|
|
688
|
+
* string the search results (`artifact · user stories`) and a document tab's label read — both
|
|
689
|
+
* places where the longer, clearer word is the right one. Only the CHIP is abbreviated, and only
|
|
690
|
+
* where a role is genuinely too long for the box the design specified. The prototype's own stories
|
|
691
|
+
* row says `STORIES`, so this is the design's word, shortened to the case the real index uses.
|
|
692
|
+
*/
|
|
693
|
+
const ROLE_CHIP = { 'user stories': 'stories' };
|
|
694
|
+
function roleChip(role) { return ROLE_CHIP[role] || role; }
|
|
695
|
+
|
|
682
696
|
function artifactRow(slug, a) {
|
|
683
697
|
const key = FX.artKey(slug, a.path);
|
|
684
698
|
// `hasOwnProperty`, not `||`. An unlinked artifact records `null` under its key, and a falsy check
|
|
@@ -693,7 +707,7 @@ function artifactRow(slug, a) {
|
|
|
693
707
|
style: 'display:flex;gap:8px;align-items:center;width:100%;padding:4px 6px;margin:0 -6px;min-width:0;min-height:30px',
|
|
694
708
|
hover: 'background:var(--accent)',
|
|
695
709
|
},
|
|
696
|
-
el('span', { style: 'flex:none;width:52px;font-family:var(--mono);font-size:10px;letter-spacing:.1em;color:var(--info)' }, iv(a.role)),
|
|
710
|
+
el('span', { style: 'flex:none;width:52px;font-family:var(--mono);font-size:10px;letter-spacing:.1em;color:var(--info)' }, iv(roleChip(a.role))),
|
|
697
711
|
el('span', { style: 'font-family:var(--mono);font-size:10px;color:var(--muted-foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(a.path)),
|
|
698
712
|
);
|
|
699
713
|
|
|
@@ -795,6 +809,16 @@ function artifactRow(slug, a) {
|
|
|
795
809
|
const pillRow = el('div', { style: 'display:flex;gap:7px;align-items:center;flex-wrap:wrap;padding:3px 0 0 0' },
|
|
796
810
|
el('span', { style: 'font-family:var(--mono);font-size:10px;padding:1px 6px;background:var(--brand-wash);color:var(--brand)' },
|
|
797
811
|
'⇄ ', iv(dest.kind), ' · ', iv(dest.target)),
|
|
812
|
+
/* WHERE THE CLAIM CAME FROM. A destination read out of the file's own frontmatter is exactly as
|
|
813
|
+
* unverified as one the PM typed — PMOS has not looked at either — but it arrived without
|
|
814
|
+
* anybody in this window doing anything, so the row has to say so or it reads as something the
|
|
815
|
+
* app established. This region already lost that argument once: its predecessor existed because
|
|
816
|
+
* a row said `connected` about a record nobody had checked. Real mode only; the prototype has
|
|
817
|
+
* no such row, and the parity comparator measures the fixture surface against it. */
|
|
818
|
+
(!FIXTURE && dest.origin === 'frontmatter')
|
|
819
|
+
? el('span', { style: 'font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' },
|
|
820
|
+
'declared in the file · nothing has been checked')
|
|
821
|
+
: null,
|
|
798
822
|
el('button', {
|
|
799
823
|
onClick: () => setState((s) => ({ artSyncOpen: { ...s.artSyncOpen, [key]: !s.artSyncOpen[key] } })),
|
|
800
824
|
style: 'font-family:var(--mono);font-size:10.5px;padding:3px 10px;min-height:26px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
|
|
@@ -361,6 +361,84 @@ const scratchTa = el('textarea', {
|
|
|
361
361
|
onInput: (e) => onScratchInput(e.target.value),
|
|
362
362
|
});
|
|
363
363
|
|
|
364
|
+
/* ── the drawer's height ────────────────────────────────────────────────────────────────────
|
|
365
|
+
* 96px — four lines of the 12px mono face — was a literal in the style string above, and it was the
|
|
366
|
+
* pad's only size. The pad's job (see the block comment above) is to stage the next thing to type
|
|
367
|
+
* into a particular agent, and a staged instruction is routinely longer than four lines, so the PM
|
|
368
|
+
* composed in a box that scrolled internally while a large empty pane sat above it.
|
|
369
|
+
*
|
|
370
|
+
* The handle is REAL MODE ONLY. The parity comparator renders the app at `?fixture=proto` against
|
|
371
|
+
* the PM's own prototype and diffs text nodes, computed styles, geometry and pixels; the prototype
|
|
372
|
+
* has no such control, so drawing one in fixture mode would be a difference the comparator is right
|
|
373
|
+
* to report. This module already draws that line for the placeholder string, and for the same
|
|
374
|
+
* reason.
|
|
375
|
+
*
|
|
376
|
+
* The clamps are the two ways this could damage the surface it sits in. `SCRATCH_MIN` keeps two
|
|
377
|
+
* lines and the button row reachable — a drawer that can be dragged shut is a drawer with two
|
|
378
|
+
* different closed states, one of which has no way back. The 70% ceiling keeps the pane above from
|
|
379
|
+
* being driven to nothing, which matters more than it sounds: that pane is frequently a live PTY.
|
|
380
|
+
*
|
|
381
|
+
* NOTHING HERE TOUCHES THE PAD. Resizing does not read or write `state.scratchText`, does not call
|
|
382
|
+
* `syncPad` or `flushPad`, and cannot change `state.scratchKey`. The height is a property of the
|
|
383
|
+
* window, the text is a property of the session, and the two stores stay separate.
|
|
384
|
+
*/
|
|
385
|
+
const SCRATCH_MIN = 56;
|
|
386
|
+
const SCRATCH_DEFAULT = 96;
|
|
387
|
+
const SCRATCH_MAX_FRACTION = 0.7;
|
|
388
|
+
const SCRATCH_STEP = 16;
|
|
389
|
+
|
|
390
|
+
/** The tallest the pad may be right now: 70% of panel 4, never below the minimum. */
|
|
391
|
+
function scratchMax() {
|
|
392
|
+
const host = scratchBlock && scratchBlock.parentElement;
|
|
393
|
+
const h = host && host.clientHeight ? host.clientHeight : 0;
|
|
394
|
+
// No host to measure (the drawer is not mounted yet) — fall back to the viewport, and to a plain
|
|
395
|
+
// number if there is no window either, so this is safe to call from a test harness.
|
|
396
|
+
const basis = h || (typeof window !== 'undefined' && window.innerHeight) || 600;
|
|
397
|
+
return Math.max(SCRATCH_MIN, Math.round(basis * SCRATCH_MAX_FRACTION));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function clampScratchH(v) {
|
|
401
|
+
const n = Math.round(Number(v));
|
|
402
|
+
if (!Number.isFinite(n)) return SCRATCH_DEFAULT;
|
|
403
|
+
return Math.min(scratchMax(), Math.max(SCRATCH_MIN, n));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function setScratchH(v) {
|
|
407
|
+
const h = clampScratchH(v);
|
|
408
|
+
if (h === state.scratchH) return;
|
|
409
|
+
setState({ scratchH: h });
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let dragFrom = null; // {y, h} while the handle is held; null otherwise
|
|
413
|
+
|
|
414
|
+
const scratchGrip = el('div', {
|
|
415
|
+
tabindex: '0',
|
|
416
|
+
role: 'separator',
|
|
417
|
+
'aria-orientation': 'horizontal',
|
|
418
|
+
'aria-label': 'resize the scratchpad — drag, or ↑ / ↓',
|
|
419
|
+
title: 'drag to resize · ↑ / ↓ to nudge · double-click to reset',
|
|
420
|
+
style: 'flex:none;height:7px;cursor:ns-resize;background:oklch(0.11 0.004 228.8);'
|
|
421
|
+
+ 'border-top:1px solid oklch(1 0 0 / 10%);touch-action:none',
|
|
422
|
+
hover: 'background:oklch(1 0 0 / 10%)',
|
|
423
|
+
onPointerdown: (e) => {
|
|
424
|
+
dragFrom = { y: e.clientY, h: state.scratchH || SCRATCH_DEFAULT };
|
|
425
|
+
try { e.target.setPointerCapture(e.pointerId); } catch { /* not captured — the move still works */ }
|
|
426
|
+
e.preventDefault();
|
|
427
|
+
},
|
|
428
|
+
// Up is taller: the drawer grows toward the top edge the PM is pulling, which is the direction
|
|
429
|
+
// the handle itself moves. `clientY` decreases as the pointer rises, hence `from.y - clientY`.
|
|
430
|
+
onPointermove: (e) => { if (dragFrom) setScratchH(dragFrom.h + (dragFrom.y - e.clientY)); },
|
|
431
|
+
onPointerup: () => { dragFrom = null; },
|
|
432
|
+
onPointercancel: () => { dragFrom = null; },
|
|
433
|
+
onDblclick: () => setScratchH(SCRATCH_DEFAULT),
|
|
434
|
+
onKeydown: (e) => {
|
|
435
|
+
const h = state.scratchH || SCRATCH_DEFAULT;
|
|
436
|
+
if (e.key === 'ArrowUp') { setScratchH(h + SCRATCH_STEP); e.preventDefault(); }
|
|
437
|
+
else if (e.key === 'ArrowDown') { setScratchH(h - SCRATCH_STEP); e.preventDefault(); }
|
|
438
|
+
else if (e.key === 'Home') { setScratchH(SCRATCH_DEFAULT); e.preventDefault(); }
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
|
|
364
442
|
/* ── pad identity, loading and autosave ─────────────────────────────────────────────────────── */
|
|
365
443
|
|
|
366
444
|
const padLoading = new Set(); // keys with a read in flight — one read per key, ever
|
|
@@ -467,6 +545,7 @@ const scratchSendBtn = el('button', {
|
|
|
467
545
|
const scratchBlock = el('div', {
|
|
468
546
|
style: 'display:flex;flex-direction:column;border-top:1px solid oklch(1 0 0 / 10%)',
|
|
469
547
|
},
|
|
548
|
+
FIXTURE ? null : scratchGrip,
|
|
470
549
|
scratchTa,
|
|
471
550
|
el('div', { style: 'display:flex;gap:6px;padding:5px 12px 7px;background:oklch(0.11 0.004 228.8)' },
|
|
472
551
|
scratchCopyBtn, scratchSendBtn),
|
|
@@ -814,6 +893,14 @@ export function renderTabs(host) {
|
|
|
814
893
|
const scratchText = state.scratchText || '';
|
|
815
894
|
if (scratchTa.defaultValue !== scratchText) scratchTa.defaultValue = scratchText;
|
|
816
895
|
if (scratchTa.value !== scratchText) scratchTa.value = scratchText;
|
|
896
|
+
/* The drawer's height, real mode only. The style ATTRIBUTE keeps the prototype's string; only the
|
|
897
|
+
* resolved `height` property is written, so the fixture render — which never reaches here — stays
|
|
898
|
+
* byte-identical to the design. Written on every paint rather than only on resize, because the
|
|
899
|
+
* restored value has to land on the first one. */
|
|
900
|
+
if (!FIXTURE) {
|
|
901
|
+
const h = clampScratchH(state.scratchH || SCRATCH_DEFAULT) + 'px';
|
|
902
|
+
if (scratchTa.style.height !== h) scratchTa.style.height = h;
|
|
903
|
+
}
|
|
817
904
|
scratchCopyLabel.textContent = state.scratchCopied ? 'copied ✓' : '⧉ copy';
|
|
818
905
|
const canSend = !!(act && act.kind === 'term');
|
|
819
906
|
scratchSendLabel.textContent = canSend ? '⇥ send to terminal' : 'open a terminal to send';
|
|
@@ -88,6 +88,10 @@ function initial() {
|
|
|
88
88
|
newSessIsolate: null, newSessError: null, newSessBusy: false,
|
|
89
89
|
newSessBranches: null, preflightProfile: null, profiles: null, branchInfo: null,
|
|
90
90
|
col0: true, col1: true, col2: true, scratchOpen: false, scratchText: '', copied: false,
|
|
91
|
+
/* The scratchpad drawer's height in px (panel-tabs.js). 96 is the prototype's literal and stays
|
|
92
|
+
* the default; the PM can drag it, and the drag is clamped there rather than here — this store
|
|
93
|
+
* only has to refuse a value that could not have come from the clamp. */
|
|
94
|
+
scratchH: 96,
|
|
91
95
|
flowsMap: null, schedList: null, agentList: null, memory: null,
|
|
92
96
|
savedDocs: {}, scratchCopied: false,
|
|
93
97
|
/* The scratchpad is ONE PAD PER SESSION, not one per page (see panel-tabs.js). `scratchPads`
|
|
@@ -169,6 +173,12 @@ const PERSIST = {
|
|
|
169
173
|
active: isSlug,
|
|
170
174
|
splitId: isSlug,
|
|
171
175
|
scratchOpen: isBool,
|
|
176
|
+
/* A restored height is re-validated on the way IN, not merely on the way out: a corrupted or
|
|
177
|
+
* hand-edited record must not be able to render a drawer with no textarea in it, or one taller
|
|
178
|
+
* than any window it could be restored into. The upper bound here is deliberately generous (the
|
|
179
|
+
* real ceiling is 70% of panel 4, which this module cannot measure) — panel-tabs.js clamps the
|
|
180
|
+
* value again against the live panel before it reaches the DOM. */
|
|
181
|
+
scratchH: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 56 && v <= 4000,
|
|
172
182
|
};
|
|
173
183
|
|
|
174
184
|
/** What is WRITTEN for a key, when it is not the live value verbatim. */
|
package/workspace/server.js
CHANGED
|
@@ -866,6 +866,25 @@ function searchIndex(root) {
|
|
|
866
866
|
return entry;
|
|
867
867
|
}
|
|
868
868
|
|
|
869
|
+
/**
|
|
870
|
+
* Is the FIRST chunk of this file free of NUL bytes? The bounded form of the reader's own refusal.
|
|
871
|
+
*
|
|
872
|
+
* `readTextDoc` scans the whole file, which is right for serving it and wrong for labelling 200
|
|
873
|
+
* search hits. This reads a fixed head — enough to catch the binary-masquerading-as-text case the
|
|
874
|
+
* reader refuses on, at a cost that does not scale with file size. A file whose head is clean and
|
|
875
|
+
* whose tail hides a NUL will still be labelled openable and still fail to open; that is a strictly
|
|
876
|
+
* smaller lie than labelling every large text file unopenable, and it is stated rather than hidden.
|
|
877
|
+
*/
|
|
878
|
+
function headIsText(full) {
|
|
879
|
+
let fd = null;
|
|
880
|
+
try {
|
|
881
|
+
fd = fs.openSync(full, 'r');
|
|
882
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
883
|
+
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
884
|
+
return buf.subarray(0, n).indexOf(0) === -1;
|
|
885
|
+
} catch { return false; } finally { if (fd !== null) { try { fs.closeSync(fd); } catch { /* ignore */ } } }
|
|
886
|
+
}
|
|
887
|
+
|
|
869
888
|
/** One matched line, trimmed and centred on the needle, so a minified file cannot flood the row. */
|
|
870
889
|
function searchSnippet(line, at) {
|
|
871
890
|
const s = String(line || '').replace(/\t/g, ' ');
|
|
@@ -918,7 +937,13 @@ function searchWorkspace(root, query, limit) {
|
|
|
918
937
|
* the honest answer is "we did not look", and the safe rendering of that is a hit the PM
|
|
919
938
|
* cannot click into an unknown. */
|
|
920
939
|
const st = fs.statSync(path.join(root, rel));
|
|
921
|
-
if (!st.isFile()
|
|
940
|
+
if (!st.isFile()) return false;
|
|
941
|
+
/* Past the byte budget we do not READ the file — but "we did not look" is not "the reader
|
|
942
|
+
* refuses it". Returning false there swapped one false label for its opposite: a legitimate
|
|
943
|
+
* 316 KB markdown doc reported openable:false while /api/doc served it 200 with 324,017 bytes.
|
|
944
|
+
* An independent pass measured it. The NUL test only needs the HEAD of the file, so bound the
|
|
945
|
+
* read instead of abandoning the question. */
|
|
946
|
+
if (st.size > SEARCH_CONTENT_BYTES) return headIsText(path.join(root, rel));
|
|
922
947
|
return readTextDoc(root, rel) !== null;
|
|
923
948
|
} catch { return false; }
|
|
924
949
|
};
|
|
@@ -1026,6 +1051,11 @@ const ROLE_BY_DIR = {
|
|
|
1026
1051
|
// the only artifact that exists for work backfilled from a merge. Before it was a role, a record
|
|
1027
1052
|
// with no sibling PRD landed in `ungrouped` and the initiative was invisible here.
|
|
1028
1053
|
initiatives: 'record',
|
|
1054
|
+
// A story file that does NOT wear one of the suffixes above — `planning/stories/checkout.md` —
|
|
1055
|
+
// was role `doc` and therefore indistinguishable from a stray note. These two directory names are
|
|
1056
|
+
// the same set `scripts/frontmatter_lint.py` holds to `type: Story`; the board and the gate are
|
|
1057
|
+
// deliberately one definition of what a story is.
|
|
1058
|
+
stories: 'user stories', 'user-stories': 'user stories',
|
|
1029
1059
|
};
|
|
1030
1060
|
// ── D60 specimens: prototype/<id>/, deliberately OUTSIDE planning/ ───────────────────────────
|
|
1031
1061
|
// The planning walk cannot reach them (planningRoots only finds `planning`), so a prototype leg was
|
|
@@ -1095,8 +1125,44 @@ function parseHead(full) {
|
|
|
1095
1125
|
recordId: rid ? rid[1] : null,
|
|
1096
1126
|
title: h1 ? h1[1].replace(/^(PRD|Eval Rubric|Design Brief)\s*:\s*/i, '').trim() : null,
|
|
1097
1127
|
kr: clean(kr), lane: clean(lane),
|
|
1128
|
+
dest: frontmatterDest(head),
|
|
1098
1129
|
};
|
|
1099
1130
|
}
|
|
1131
|
+
|
|
1132
|
+
/* ── the destination a document declares about ITSELF ────────────────────────────────────────
|
|
1133
|
+
* A user story is written to be handed to a tracker, and until now the only place that tie could
|
|
1134
|
+
* live was `artifact-links.jsonl` in the workspace's runtime state directory — outside the
|
|
1135
|
+
* repository, invisible to git, invisible to an agent reading the file, and absent on every other
|
|
1136
|
+
* machine that opens the same repo. A key in the file's own frontmatter puts the fact where the
|
|
1137
|
+
* artifact is.
|
|
1138
|
+
*
|
|
1139
|
+
* IT IS A DECLARATION AND NOTHING MORE. This is the same claim `state: 'declared'` makes in the
|
|
1140
|
+
* store: the author says this file corresponds to that record. PMOS has not looked, holds no
|
|
1141
|
+
* credential, and makes no request — only a session carrying the PM's own MCP can produce
|
|
1142
|
+
* `resolved` (D53), and nothing on this path can. The panel attributes the row to the file so the
|
|
1143
|
+
* two are never confused, which is the whole lesson of the predecessor initiative: a row that said
|
|
1144
|
+
* `connected` about a record nobody had checked.
|
|
1145
|
+
*
|
|
1146
|
+
* ONE destination, in LINK_KINDS order, because the artifact row draws one pill. A file declaring
|
|
1147
|
+
* two has declared the first; that is a smaller surprise than silently drawing the second.
|
|
1148
|
+
* The target is capped at 300 characters — the same cap the store's own validator applies to a
|
|
1149
|
+
* typed one — and is rendered as text, never as a link target.
|
|
1150
|
+
*/
|
|
1151
|
+
const FRONTMATTER_DEST = [
|
|
1152
|
+
['jira', /^\s*[-*]?\s*jira\s*:\s*(.+?)\s*$/mi],
|
|
1153
|
+
['confluence', /^\s*[-*]?\s*confluence\s*:\s*(.+?)\s*$/mi],
|
|
1154
|
+
['google', /^\s*[-*]?\s*(?:google_doc|google-doc|gdoc)\s*:\s*(.+?)\s*$/mi],
|
|
1155
|
+
];
|
|
1156
|
+
function frontmatterDest(head) {
|
|
1157
|
+
for (const [kind, re] of FRONTMATTER_DEST) {
|
|
1158
|
+
const m = String(head || '').match(re);
|
|
1159
|
+
if (!m) continue;
|
|
1160
|
+
const target = m[1].replace(/^["']|["']$/g, '').trim().slice(0, 300);
|
|
1161
|
+
if (!target) continue;
|
|
1162
|
+
return { kind, target, origin: 'frontmatter', state: 'declared' };
|
|
1163
|
+
}
|
|
1164
|
+
return null;
|
|
1165
|
+
}
|
|
1100
1166
|
function roleAndStem(rel, base) {
|
|
1101
1167
|
const stem = base.replace(/\.md$/, '');
|
|
1102
1168
|
for (const [suf, role] of ROLE_SUFFIXES) {
|
|
@@ -1120,6 +1186,7 @@ function docMeta(root, full) {
|
|
|
1120
1186
|
initiative: head.initiative || (role === 'record' ? head.recordId : null) || null,
|
|
1121
1187
|
stem, title: head.title || null,
|
|
1122
1188
|
kr: head.kr || null, lane: head.lane || null,
|
|
1189
|
+
dest: head.dest || null,
|
|
1123
1190
|
};
|
|
1124
1191
|
metaCache.set(full, meta);
|
|
1125
1192
|
return meta;
|
|
@@ -1205,7 +1272,7 @@ function initiativeIndex(root) {
|
|
|
1205
1272
|
};
|
|
1206
1273
|
for (const m of metas) {
|
|
1207
1274
|
const k = keyFor(m);
|
|
1208
|
-
const art = { name: m.name, path: m.path, role: m.role, title: m.title };
|
|
1275
|
+
const art = { name: m.name, path: m.path, role: m.role, title: m.title, dest: m.dest || null };
|
|
1209
1276
|
if (!k) { ungrouped.push(art); continue; }
|
|
1210
1277
|
if (!groups.has(k)) groups.set(k, { id: k, title: null, kr: null, lane: null, artifacts: [] });
|
|
1211
1278
|
const g = groups.get(k);
|
|
@@ -1217,7 +1284,11 @@ function initiativeIndex(root) {
|
|
|
1217
1284
|
if (m.kr && (m.role === 'record' || !g.kr)) g.kr = m.kr;
|
|
1218
1285
|
if (m.lane && (m.role === 'record' || !g.lane)) g.lane = m.lane;
|
|
1219
1286
|
}
|
|
1220
|
-
|
|
1287
|
+
// `user stories` sits directly after `PRD` because that is what it IS: the spec decomposed into
|
|
1288
|
+
// the units of work someone builds. It was absent from this list, so `indexOf` returned -1, the
|
|
1289
|
+
// comparator mapped it to 99, and an initiative's stories sorted below every artifact they
|
|
1290
|
+
// decompose — into the trailing alphabetical block with `doc` and `research`.
|
|
1291
|
+
const ROLE_ORDER = ['record', 'PRD', 'user stories', 'rubric', 'eval', 'design brief', 'design', 'reconcile', 'dogfood'];
|
|
1221
1292
|
const initiatives = [...groups.values()].map((g) => {
|
|
1222
1293
|
g.artifacts.sort((a, b) => {
|
|
1223
1294
|
const ia = ROLE_ORDER.indexOf(a.role), ib = ROLE_ORDER.indexOf(b.role);
|
|
@@ -1443,7 +1514,7 @@ function surfaceInitiatives(root, w) {
|
|
|
1443
1514
|
accepted: state === 'accepted',
|
|
1444
1515
|
delivered: state === 'accepted' || state === 'delivered',
|
|
1445
1516
|
delivery_evidence: deliveredBy,
|
|
1446
|
-
artifacts: g.artifacts.map((a2) => ({ role: a2.role, name: a2.name, path: a2.path })),
|
|
1517
|
+
artifacts: g.artifacts.map((a2) => ({ role: a2.role, name: a2.name, path: a2.path, dest: a2.dest || null })),
|
|
1447
1518
|
// NET-NEW state — not on disk yet, so null (the surface renders "— not tracked", never a guess)
|
|
1448
1519
|
rice: null, effort: null, milestones: null, progress: null, tracked: false,
|
|
1449
1520
|
};
|