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 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.29.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": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@4104fde
1
+ kernel-source: pmos@ace75db
2
2
  assembled: 2026-08-04
3
3
  mode: vendor
@@ -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
- /** Immediately-preceding words that make the verb a modifier or a passive, not an act. */
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
- const prior = (before.match(/([A-Za-z']+)[^A-Za-z']*$/) || [])[1];
224
- if (prior && PROSE_LEAD.has(prior.toLowerCase())) continue; // adjectival or passive — prose
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) => ({ role: a.role, name: a.name, path: a.path, title: a.title || null }));
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 artifacts = artifactsFor(index, id);
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
  };
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "private": true,
5
- "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
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 — it just stops measuring pixels, and says so.",
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
- const d = dest.get(a.path);
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. */
@@ -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() || st.size > SEARCH_CONTENT_BYTES) return false;
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
- const ROLE_ORDER = ['record', 'PRD', 'rubric', 'eval', 'design brief', 'design', 'reconcile', 'dogfood'];
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
  };