create-pmos 0.29.1 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -333,6 +333,67 @@ 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.1
337
+
338
+ **Open a markdown file in the workspace and you get the document, not its source. And scrolling
339
+ down stays scrolled down.**
340
+ Run `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
341
+
342
+ - **The document pane renders markdown properly.** Bold was asterisks, inline code was backticks, a
343
+ table was one paragraph per row with the pipes still in it, and a numbered procedure was a single
344
+ run-on paragraph. An indented sub-bullet was worse than unstyled — it escaped the list it belonged
345
+ inside and reappeared underneath it. `view` now renders headings 1–6, soft-wrapped paragraphs,
346
+ ordered / nested / task lists, GFM tables with per-column alignment, fenced code, blockquotes and
347
+ rules, plus bold, italic, inline code, strikethrough, links and images. Rubrics and skills — the
348
+ documents this workflow is written in — are readable in the pane for the first time.
349
+ - **A `.md` link opens that document.** Click a link to another file in your repo and it opens as a
350
+ tab, resolved against the document you are reading (or the repo root for a `/planning/…` path). A
351
+ link to something the pane cannot open stays as plain text rather than becoming a dead link.
352
+ - **Nothing is fetched because you opened a file.** A repo-relative image is streamed by your own
353
+ local workspace; a remote or `data:` image stays as its alt text and is never requested. The
354
+ renderer builds DOM nodes and never assigns HTML, so a `<script>` in a markdown file is characters
355
+ on the screen, not a script.
356
+ - **Scrolling a document survives a repaint.** The pane rebuilt its scrolling element on every
357
+ redraw, so anything that changed state anywhere — including the four-second session poll, which
358
+ fires whenever an agent beside you is working — took a half-read document back to line 1. The
359
+ scroll element is now kept, per tab and per mode, the same way the edit box keeps your caret.
360
+ Holds for `view`, `raw` and `diff`.
361
+ - **Not in this release, said plainly:** this is a workspace-app fix. The file-mode kit — skills,
362
+ scripts, state layout — is unchanged, so `--update` has nothing new to give you. Syntax
363
+ highlighting inside code fences, footnotes and rendered inline HTML are deliberately not included.
364
+
365
+ ## What's new in 0.30.0
366
+
367
+ **Your user stories are on the board, and a story can name the Jira issue, Confluence page or
368
+ Google Doc it corresponds to — in the file itself. The scratchpad drags up and down.**
369
+ Run `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
370
+
371
+ - **User stories show up on an initiative's artifacts list, in the right place.** The index has
372
+ recognised `*-user-stories.md` for a while and nothing downstream used it: stories sorted *below*
373
+ the PRD and the rubric they decompose, and the role label was twelve characters in a box built for
374
+ eight, so it ran under the file path beside it. Stories now sort directly after the PRD, and the
375
+ chip fits. A file in a `planning/stories/` folder counts as a story too, with no suffix needed in
376
+ its name.
377
+ - **A story can carry its own destination.** Put `jira: ABC-123`, `confluence: SPACE / page` or
378
+ `google_doc: <url>` in a story's frontmatter and the workspace draws the ⇄ pill on its row — no
379
+ clicking, no re-typing on a second machine, and the tie is in git where an agent reading the file
380
+ can see it. It works for any planning document, not only stories.
381
+ - **It says what it knows, which is nothing.** That pill reads *declared in the file · nothing has
382
+ been checked*. PMOS holds no credential, makes no request, and cannot mark a reference resolved —
383
+ only a session carrying your own MCP can do that. A link you record in the panel still wins over
384
+ the one in the file, and `unlink` now survives a reload instead of the file quietly putting the
385
+ pill back.
386
+ - **The scratchpad resizes.** Drag the handle along its top edge — up for taller, down for shorter.
387
+ It cannot be dragged shut, it cannot take more than 70% of the panel (the pane above is often a
388
+ live terminal), the size sticks across reloads, and `↑`/`↓` work when the handle is focused with
389
+ `Home` to reset. Resizing never touches what you have staged in the pad.
390
+ - **Not in this release, said plainly:** the `Story` frontmatter type, the gate rule that enforces
391
+ it and the `--fix` repair are PMOS-self only and are **not** in this payload. The registry they
392
+ enforce is PMOS's own vocabulary, and running it inside your repository would fail files written
393
+ to your conventions. The workspace half above works whatever `type:` your story files declare.
394
+ Nothing here reads status back from a tracker — there is no "3 of 7 done", and the surface does
395
+ not imply there is.
396
+
336
397
  ## What's new in 0.29.1
337
398
 
338
399
  **0.29.0 could not reach an existing install. This is that release, delivered.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.29.1",
3
+ "version": "0.30.1",
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@b59909d
2
- assembled: 2026-08-04
1
+ kernel-source: pmos@f5b480a
2
+ assembled: 2026-08-05
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
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.29.1",
3
+ "version": "0.30.1",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace \u2014 local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -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 markdown.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",
@@ -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
  });
@@ -0,0 +1,420 @@
1
+ /* pm/markdown.js — the doc pane's markdown renderer.
2
+ *
3
+ * The doc tab's `view` mode used to render through `fixture.js`'s `mdParse`, which is a
4
+ * TRANSCRIPTION of the design prototype's parser — four branches (`# `, `## `, `- `, paragraph) and
5
+ * no inline pass at all. That is exactly big enough for the prototype's four seed documents and
6
+ * nowhere near big enough for this repository's own artifacts: a rubric IS a GFM table, a skill IS a
7
+ * numbered procedure, and both reached the PM as literal source with the pipes and the asterisks
8
+ * still in them. See planning/explainers/workspace-doc-render-surface-explainer.md.
9
+ *
10
+ * `mdParse` is not touched — `fixture-parity.test.mjs` pins it byte-for-byte against the prototype
11
+ * source, and that transcription proof is the reason the design comparator can be trusted. This is a
12
+ * separate renderer, and `tab-doc.js` chooses it for `view`.
13
+ *
14
+ * ── THE PARITY CONTRACT ────────────────────────────────────────────────────────────────────────
15
+ * The fixture documents contain only `#`, `##`, `- ` and paragraphs, and `design-parity.js` diffs
16
+ * the app against the prototype on text nodes, computed style, geometry and pixels. So for those
17
+ * four constructs this renderer emits NODE-FOR-NODE what the old path emitted, including the
18
+ * wrapping <span> that `iv()` put around unmarked text: an inline run with no markup in it comes
19
+ * back as exactly one span holding the whole run. A failed marker match does NOT flush the text
20
+ * buffer — `planning/initiatives/*.md` is one span, not three.
21
+ *
22
+ * ── WHAT IT NEVER DOES ─────────────────────────────────────────────────────────────────────────
23
+ * It builds DOM NODES. No HTML string is constructed anywhere in this file and `innerHTML` is never
24
+ * assigned, so a `<script>` line in a document is a `<script>` on the screen, as characters, by
25
+ * construction rather than by remembering to escape (DOC7). And an image is fetched only when it is
26
+ * repo-relative, through the guarded /api/asset — a remote or `data:` source stays alt text and is
27
+ * never requested, which is R4: opening a document must not tell anyone it was opened.
28
+ */
29
+
30
+ import { el } from './dom.js';
31
+
32
+ /* ── styles ─────────────────────────────────────────────────────────────────────────────────────
33
+ * H2, P, UL and LI are the prototype's strings, verbatim, and must stay that way — they are what
34
+ * the parity diff measures. Everything below them is new surface, styled in the same idiom: the
35
+ * semantic tokens only, no raw colour anywhere. */
36
+ const ST = {
37
+ h1: 'font-size:19px;font-weight:600;line-height:1.3;margin:26px 0 10px;text-wrap:balance;font-family:var(--sans)',
38
+ h2: 'font-size:16px;font-weight:600;line-height:1.35;margin:24px 0 8px;padding-bottom:5px;border-bottom:1px solid var(--border)',
39
+ h3: 'font-size:14px;font-weight:600;line-height:1.4;margin:20px 0 7px',
40
+ h4: 'font-size:13px;font-weight:600;line-height:1.4;margin:18px 0 6px',
41
+ h5: 'font-size:12.5px;font-weight:600;line-height:1.4;margin:16px 0 6px;color:var(--muted-foreground)',
42
+ h6: 'font-size:12px;font-weight:600;line-height:1.4;margin:16px 0 6px;color:var(--muted-foreground);text-transform:uppercase;letter-spacing:0.04em',
43
+ p: 'margin:0 0 12px',
44
+ ul: 'margin:0 0 12px;padding-left:22px',
45
+ ol: 'margin:0 0 12px;padding-left:24px',
46
+ subList: 'margin:5px 0 0;padding-left:22px',
47
+ li: 'margin:0 0 5px;padding-left:2px',
48
+ taskUl: 'margin:0 0 12px;padding-left:2px;list-style:none',
49
+ taskLi: 'margin:0 0 5px;display:flex;gap:8px;align-items:baseline',
50
+ box: 'font-family:var(--mono);font-size:12px;color:var(--muted-foreground);flex:none',
51
+ boxDone: 'font-family:var(--mono);font-size:12px;color:var(--success);flex:none',
52
+ code: 'font-family:var(--mono);font-size:0.88em;background:var(--muted);padding:1px 4px;overflow-wrap:anywhere',
53
+ pre: 'margin:0 0 14px;background:var(--card);border:1px solid var(--border);padding:10px 12px;overflow-x:auto',
54
+ preCode: 'font-family:var(--mono);font-size:12px;line-height:1.6;white-space:pre;display:block',
55
+ lang: 'display:block;font-family:var(--mono);font-size:10px;color:var(--muted-foreground);margin-bottom:6px;letter-spacing:0.04em',
56
+ quote: 'margin:0 0 14px;padding:2px 0 2px 14px;border-left:2px solid var(--border);color:var(--muted-foreground)',
57
+ hr: 'border:0;border-top:1px solid var(--border);margin:22px 0',
58
+ tableWrap: 'margin:0 0 14px;overflow-x:auto;border:1px solid var(--border)',
59
+ table: 'border-collapse:collapse;width:100%;font-size:12.5px',
60
+ th: 'font-weight:600;padding:7px 10px;border-bottom:1px solid var(--border);background:var(--card);vertical-align:top',
61
+ td: 'padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:top',
62
+ a: 'color:var(--brand);text-decoration:underline;text-underline-offset:2px;overflow-wrap:anywhere',
63
+ img: 'max-width:100%;height:auto;display:block;margin:0 0 14px;border:1px solid var(--border)',
64
+ imgAlt: 'color:var(--muted-foreground)',
65
+ };
66
+
67
+ const HEAD_ST = [ST.h1, ST.h2, ST.h3, ST.h4, ST.h5, ST.h6];
68
+
69
+ /* ── inline ─────────────────────────────────────────────────────────────────────────────────── */
70
+
71
+ const RE = {
72
+ code: /^(`+)([\s\S]*?[^`])\1(?!`)/,
73
+ image: /^!\[([^\]]*)\]\(\s*([^)\s]*)(?:\s+"[^"]*")?\s*\)/,
74
+ link: /^\[([^\]]+)\]\(\s*([^)\s]*)(?:\s+"[^"]*")?\s*\)/,
75
+ autolink: /^<((?:https?:\/\/|mailto:)[^>\s]+)>/,
76
+ strongStar: /^\*\*(?=\S)([\s\S]*?\S)\*\*/,
77
+ strongUnder: /^__(?=\S)([\s\S]*?\S)__/,
78
+ emStar: /^\*(?=[^\s*])((?:[^*]|\*\*)+?)\*(?!\*)/,
79
+ emUnder: /^_(?=[^\s_])([^_]+?)_(?!\w)/,
80
+ del: /^~~(?=\S)([\s\S]*?\S)~~/,
81
+ };
82
+
83
+ const isWord = (c) => !!c && /[A-Za-z0-9]/.test(c);
84
+
85
+ /**
86
+ * One line (or cell) of markdown → an array of DOM nodes.
87
+ *
88
+ * The scan is left-to-right and a marker that does not resolve is just a character: the plain-text
89
+ * buffer is flushed ONLY when a real node is emitted, which is what keeps an unmarked run a single
90
+ * text span (the parity contract) and what keeps `a/*.md` and `pre_run_contract` intact.
91
+ */
92
+ export function inline(src, ctx = {}) {
93
+ const s = String(src == null ? '' : src);
94
+ const out = [];
95
+ let buf = '';
96
+ const flush = () => { if (buf) { out.push(el('span', null, buf)); buf = ''; } };
97
+ const push = (node) => { flush(); out.push(node); };
98
+
99
+ let i = 0;
100
+ while (i < s.length) {
101
+ const c = s[i];
102
+ // Nothing can start here — the common case, and the reason a plain paragraph stays one span.
103
+ if (c !== '`' && c !== '[' && c !== '!' && c !== '*' && c !== '_' && c !== '~' && c !== '<') {
104
+ buf += c; i++; continue;
105
+ }
106
+ const rest = s.slice(i);
107
+ const prev = i > 0 ? s[i - 1] : '';
108
+ let m;
109
+
110
+ if (c === '`' && (m = RE.code.exec(rest))) {
111
+ push(el('code', { style: ST.code }, m[2].replace(/^ (.*) $/, '$1')));
112
+ i += m[0].length; continue;
113
+ }
114
+ if (c === '!' && (m = RE.image.exec(rest))) {
115
+ push(mdImage(m[1], m[2], ctx));
116
+ i += m[0].length; continue;
117
+ }
118
+ if (c === '[' && (m = RE.link.exec(rest))) {
119
+ const node = mdLink(m[1], m[2], ctx);
120
+ if (node) { push(node); i += m[0].length; continue; }
121
+ // A target that cannot be opened is not offered as a link — its TEXT stays, in the buffer,
122
+ // so it does not fragment the run around it.
123
+ buf += m[1]; i += m[0].length; continue;
124
+ }
125
+ if (c === '<' && (m = RE.autolink.exec(rest))) {
126
+ push(mdLink(m[1], m[1], ctx) || el('span', null, m[1]));
127
+ i += m[0].length; continue;
128
+ }
129
+ if (c === '~' && (m = RE.del.exec(rest))) {
130
+ push(el('del', null, inline(m[1], ctx)));
131
+ i += m[0].length; continue;
132
+ }
133
+ if (c === '*' && (m = RE.strongStar.exec(rest))) {
134
+ push(el('strong', { style: 'font-weight:600' }, inline(m[1], ctx)));
135
+ i += m[0].length; continue;
136
+ }
137
+ /* `__` and `_` are word-boundary-only, deliberately. Intraword underscores are how this
138
+ * repository writes `pre_run_contract` and `parent_kr`, and a renderer that italicised the
139
+ * middle of them would be a new defect shipped with the fix for an old one. */
140
+ if (c === '_' && !isWord(prev) && (m = RE.strongUnder.exec(rest))) {
141
+ const after = s[i + m[0].length];
142
+ if (!isWord(after)) { push(el('strong', { style: 'font-weight:600' }, inline(m[1], ctx))); i += m[0].length; continue; }
143
+ }
144
+ if (c === '*' && !isWord(prev) && (m = RE.emStar.exec(rest))) {
145
+ push(el('em', null, inline(m[1], ctx)));
146
+ i += m[0].length; continue;
147
+ }
148
+ if (c === '_' && !isWord(prev) && (m = RE.emUnder.exec(rest))) {
149
+ const after = s[i + m[0].length];
150
+ if (!isWord(after)) { push(el('em', null, inline(m[1], ctx))); i += m[0].length; continue; }
151
+ }
152
+ buf += c; i++; // a marker that resolved to nothing is just a character
153
+ }
154
+ flush();
155
+ return out.length ? out : [el('span', null, '')];
156
+ }
157
+
158
+ /** bundle-relative (`/planning/…`, the OKF convention) or relative to the open document. */
159
+ export function resolvePath(dir, u) {
160
+ const clean = String(u || '').split('#')[0].split('?')[0];
161
+ const stack = clean.startsWith('/') ? [] : [...(dir || [])];
162
+ for (const p of clean.split('/')) {
163
+ if (!p || p === '.') continue;
164
+ if (p === '..') stack.pop(); else stack.push(p);
165
+ }
166
+ return stack.join('/');
167
+ }
168
+
169
+ const REMOTE = /^(?:https?:)?\/\//i;
170
+ const MAILTO = /^mailto:/i;
171
+ /* Every extension `previewKind` calls a document, plus the JSONL ledgers panel 3 links to. A link
172
+ * to something the doc tab cannot open is not made clickable — a dead link is worse than text. */
173
+ const OPENABLE = /\.(md|markdown|mdown|mkd|mdx|skill|txt|json|jsonl|ya?ml|toml|csv|js|mjs|cjs|ts|tsx|py|sh|css|html?)(#.*)?$/i;
174
+
175
+ function mdLink(txt, url, ctx) {
176
+ const u = String(url || '').trim();
177
+ if (!u) return null;
178
+ if (REMOTE.test(u) || MAILTO.test(u)) {
179
+ return el('a', {
180
+ href: u, target: '_blank', rel: 'noopener noreferrer', style: ST.a,
181
+ }, inline(txt, ctx));
182
+ }
183
+ if (u.startsWith('#')) return null; // an in-page anchor has nowhere to go here
184
+ if (!OPENABLE.test(u)) return null;
185
+ const path = resolvePath(ctx.dir, u);
186
+ if (!path || !ctx.onDoc) return null;
187
+ /* Opening a document from inside a document is the one navigation affordance the retired reader
188
+ * column had and the doc tab never got. `href="#"` keeps it a real link for the keyboard; the
189
+ * click never navigates the shell. */
190
+ return el('a', {
191
+ href: '#', style: ST.a, title: path,
192
+ onClick: (e) => { e.preventDefault(); ctx.onDoc(path); },
193
+ }, inline(txt, ctx));
194
+ }
195
+
196
+ /**
197
+ * R4, the no-beacon invariant. A repo-relative source is streamed by the workspace's own guarded
198
+ * /api/asset; ANYTHING else — remote, protocol-relative, data:, any scheme — is never fetched and
199
+ * renders as its alt text. The classifier is `preview-kind.js`'s, the one both sides of the app
200
+ * already share, so "what counts as repo-relative" cannot drift; if it did not load, nothing is
201
+ * fetched at all.
202
+ */
203
+ function mdImage(alt, src, ctx) {
204
+ const label = alt || 'image';
205
+ const PK = typeof window !== 'undefined' ? window.PreviewKind : null;
206
+ const repoRel = PK && typeof PK.isRepoRelativeSrc === 'function' ? PK.isRepoRelativeSrc(src) : false;
207
+ const path = repoRel ? resolvePath(ctx.dir, src) : '';
208
+ if (!path) return el('em', { style: ST.imgAlt }, label);
209
+ return el('img', {
210
+ style: ST.img, alt: label, loading: 'lazy',
211
+ src: '/api/asset?ws=' + encodeURIComponent(ctx.ws || '') + '&path=' + encodeURIComponent(path),
212
+ });
213
+ }
214
+
215
+ /* ── blocks ─────────────────────────────────────────────────────────────────────────────────── */
216
+
217
+ const RE_ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/;
218
+ const RE_HR = /^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/;
219
+ const RE_HEAD = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
220
+ const RE_FENCE = /^\s*(`{3,}|~{3,})\s*([^\s`]*)/;
221
+ const RE_QUOTE = /^\s{0,3}>/;
222
+ const RE_TASK = /^\[([ xX])\]\s+(.*)$/;
223
+ const RE_DELIM = /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/;
224
+
225
+ const cells = (row) => String(row).trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/)
226
+ .map((c) => c.trim().replace(/\\\|/g, '|'));
227
+
228
+ const isTableAt = (lines, i) => lines[i].includes('|')
229
+ && RE_DELIM.test(lines[i + 1] || '') && (lines[i + 1] || '').includes('-');
230
+
231
+ /** Frontmatter → `[{k, v}]`, exactly as the pane has always read it. */
232
+ function frontmatter(lines) {
233
+ const fm = [];
234
+ if (lines[0] !== '---') return { fm, next: 0 };
235
+ let i = 1;
236
+ while (i < lines.length && lines[i] !== '---') {
237
+ const ix = lines[i].indexOf(':');
238
+ if (ix > 0) fm.push({ k: lines[i].slice(0, ix).trim(), v: lines[i].slice(ix + 1).trim() });
239
+ i++;
240
+ }
241
+ return { fm, next: i + 1 };
242
+ }
243
+
244
+ /**
245
+ * The document → `{ fm, title, titleText, blocks }`.
246
+ *
247
+ * `title` is the inline nodes of the FIRST `# ` line — the pane draws it as the document's heading,
248
+ * the way it always has. A second `# ` is a real h1 in the body rather than silently replacing the
249
+ * title, which is what the old parser did (last one wins, the earlier ones vanish).
250
+ *
251
+ * `ctx` carries what links and images need: `{ dir, ws, onDoc }`.
252
+ */
253
+ export function mdRender(text, ctx = {}) {
254
+ const lines = String(text == null ? '' : text).replace(/\r\n?/g, '\n').split('\n');
255
+ const { fm, next } = frontmatter(lines);
256
+ const blocks = [];
257
+ let title = null, titleText = '';
258
+ let i = next;
259
+
260
+ while (i < lines.length) {
261
+ const ln = lines[i];
262
+ if (!ln.trim()) { i++; continue; }
263
+ let m;
264
+
265
+ // fenced code — verbatim, never parsed, and an unterminated fence takes the rest of the file
266
+ if ((m = RE_FENCE.exec(ln))) {
267
+ const close = new RegExp('^\\s*' + m[1][0] + '{' + m[1].length + ',}\\s*$');
268
+ const body = [];
269
+ i++;
270
+ while (i < lines.length && !close.test(lines[i])) body.push(lines[i++]);
271
+ i++;
272
+ blocks.push(el('pre', { style: ST.pre },
273
+ m[2] ? el('span', { style: ST.lang }, m[2]) : null,
274
+ el('code', { style: ST.preCode }, body.join('\n'))));
275
+ continue;
276
+ }
277
+
278
+ if ((m = RE_HEAD.exec(ln))) {
279
+ const n = m[1].length;
280
+ const nodes = inline(m[2], ctx);
281
+ if (n === 1 && title === null) { title = nodes; titleText = m[2]; }
282
+ else blocks.push(el('h' + n, { style: HEAD_ST[n - 1] }, nodes));
283
+ i++; continue;
284
+ }
285
+
286
+ if (RE_HR.test(ln)) { blocks.push(el('hr', { style: ST.hr })); i++; continue; }
287
+
288
+ // blockquote — its body is markdown, so it is rendered by the same pass
289
+ if (RE_QUOTE.test(ln)) {
290
+ const body = [];
291
+ while (i < lines.length && (RE_QUOTE.test(lines[i])
292
+ || (body.length && lines[i].trim() && !RE_ITEM.test(lines[i]) && !RE_HEAD.test(lines[i])))) {
293
+ body.push(lines[i].replace(/^\s{0,3}>\s?/, '')); i++;
294
+ }
295
+ const inner = mdRender(body.join('\n'), ctx);
296
+ blocks.push(el('blockquote', { style: ST.quote },
297
+ inner.title ? el('h3', { style: ST.h3 }, inner.title) : null, inner.blocks));
298
+ continue;
299
+ }
300
+
301
+ // GFM table: a header row followed by a delimiter row
302
+ if (isTableAt(lines, i)) {
303
+ const head = cells(ln);
304
+ const align = cells(lines[i + 1]).map((c) => (/^:.*:$/.test(c) ? 'center' : /:$/.test(c) ? 'right' : /^:/.test(c) ? 'left' : ''));
305
+ i += 2;
306
+ const rows = [];
307
+ while (i < lines.length && lines[i].includes('|') && lines[i].trim()) rows.push(cells(lines[i++]));
308
+ // Written for every column, not only the aligned ones: a `text-align` that is present on some
309
+ // cells and inherited on others is how a column silently follows its neighbour's alignment.
310
+ const al = (n) => ';text-align:' + (align[n] || 'left');
311
+ blocks.push(el('div', { style: ST.tableWrap },
312
+ el('table', { style: ST.table },
313
+ el('thead', null, el('tr', null,
314
+ head.map((c, n) => el('th', { style: ST.th + al(n) }, inline(c, ctx))))),
315
+ el('tbody', null, rows.map((r) => el('tr', null,
316
+ head.map((_, n) => el('td', { style: ST.td + al(n) }, inline(r[n] || '', ctx)))))))));
317
+ continue;
318
+ }
319
+
320
+ if (RE_ITEM.test(ln)) {
321
+ const { items, next: after } = listBlock(lines, i);
322
+ blocks.push(renderLists(items, 0, ctx, true).node);
323
+ i = after; continue;
324
+ }
325
+
326
+ /* paragraph — consecutive lines are ONE paragraph. The join is what makes a soft-wrapped
327
+ * sentence read as a sentence; the guards are what stop it swallowing the block that follows. */
328
+ const para = [];
329
+ while (i < lines.length && lines[i].trim() && !RE_ITEM.test(lines[i]) && !RE_HR.test(lines[i])
330
+ && !RE_QUOTE.test(lines[i]) && !RE_HEAD.test(lines[i]) && !RE_FENCE.test(lines[i])
331
+ && !isTableAt(lines, i)) {
332
+ para.push(lines[i]); i++;
333
+ }
334
+ if (para.length) blocks.push(el('p', { style: ST.p }, inline(para.join(' '), ctx)));
335
+ else i++; // nothing consumed — never spin
336
+ }
337
+
338
+ return { fm, title, titleText, blocks };
339
+ }
340
+
341
+ /** Collect one list block: its items, their indents, and their wrapped continuation lines. */
342
+ function listBlock(lines, start) {
343
+ const items = [];
344
+ let i = start;
345
+ while (i < lines.length) {
346
+ const ln = lines[i];
347
+ const m = RE_ITEM.exec(ln);
348
+ if (m) {
349
+ items.push({ indent: m[1].replace(/\t/g, ' ').length, ordered: /\d/.test(m[2]), lines: [m[3]] });
350
+ i++; continue;
351
+ }
352
+ if (!ln.trim()) {
353
+ const nx = lines[i + 1] || '';
354
+ if (RE_ITEM.test(nx) || /^\s{2,}\S/.test(nx)) { i++; continue; } // a loose list
355
+ break;
356
+ }
357
+ /* A fence indented under an item ENDS the list rather than being folded into the item's text.
358
+ * PMOS's own PRDs write a numbered step and then indent a block of output under it; joining
359
+ * those lines into the item would put the ``` markers on screen as characters — the exact
360
+ * defect this renderer exists to remove. The fence then renders as its own block, after the
361
+ * list: degraded placement, never degraded content. */
362
+ if (RE_FENCE.test(ln)) break;
363
+ if (items.length && /^\s+\S/.test(ln)) { items[items.length - 1].lines.push(ln.trim()); i++; continue; }
364
+ break;
365
+ }
366
+ return { items, next: i };
367
+ }
368
+
369
+ /** Every list at one indent level, as siblings. Returns one node when there is only one list. */
370
+ function renderLists(items, start, ctx, top) {
371
+ const base = items[start].indent;
372
+ const nodes = [];
373
+ let i = start;
374
+ while (i < items.length && items[i].indent >= base) {
375
+ const r = renderList(items, i, ctx, top);
376
+ nodes.push(r.node);
377
+ if (r.next === i) break; // never spin
378
+ i = r.next;
379
+ }
380
+ return { node: nodes.length === 1 ? nodes[0] : el('div', null, nodes), next: i };
381
+ }
382
+
383
+ /**
384
+ * One list. A change of marker type at the same indent — bullet ↔ number, or the first `- [ ]` —
385
+ * starts a sibling list, the way every markdown renderer does it. A deeper indent is nested INSIDE
386
+ * the item above it, which is the case the old parser got visibly wrong: an indented sub-bullet did
387
+ * not match `- `, fell through to the paragraph branch, and was emitted after the list it belonged
388
+ * inside.
389
+ */
390
+ function renderList(items, start, ctx, top) {
391
+ const base = items[start].indent;
392
+ const { ordered } = items[start];
393
+ const task = RE_TASK.test(items[start].lines[0]);
394
+ const lis = [];
395
+ let i = start;
396
+ while (i < items.length && items[i].indent >= base) {
397
+ if (items[i].indent === base
398
+ && (items[i].ordered !== ordered || RE_TASK.test(items[i].lines[0]) !== task)) break;
399
+ if (items[i].indent > base) {
400
+ const sub = renderLists(items, i, ctx, false);
401
+ if (lis.length) lis[lis.length - 1].appendChild(sub.node);
402
+ else lis.push(el('li', { style: ST.li }, sub.node));
403
+ i = sub.next; continue;
404
+ }
405
+ const body = items[i].lines.join(' ');
406
+ const t = RE_TASK.exec(body);
407
+ if (t) {
408
+ lis.push(el('li', { style: ST.taskLi },
409
+ el('span', { style: t[1] === ' ' ? ST.box : ST.boxDone, 'aria-hidden': 'true' }, t[1] === ' ' ? '☐' : '☑'),
410
+ el('span', null, inline(t[2], ctx))));
411
+ } else {
412
+ lis.push(el('li', { style: ST.li }, inline(body, ctx)));
413
+ }
414
+ i++;
415
+ }
416
+ const style = task ? ST.taskUl : (ordered ? ST.ol : (top ? ST.ul : ST.subList));
417
+ return { node: el(ordered ? 'ol' : 'ul', { style }, lis), next: i };
418
+ }
419
+
420
+ export default mdRender;
@@ -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. */
@@ -22,11 +22,24 @@
22
22
  * Every keystroke is a state change, and a state change repaints the panel. A textarea rebuilt on
23
23
  * each keystroke loses the caret, so the element is created once per tab and kept; its text is
24
24
  * written back only when it actually differs from what the box already holds.
25
+ *
26
+ * ── …and why the SCROLLING ELEMENT does too ─────────────────────────────────────────────────
27
+ * A scroll offset is the same class of state as a caret, and it was not getting the same treatment:
28
+ * `view`, `raw` and `diff` each built a fresh scrolling div on every paint, so any repaint — the
29
+ * 4-second session poll included — took a half-read document back to the top. `scrollPane` keeps one
30
+ * element per tab and mode; see the block comment above it.
31
+ *
32
+ * ── What `view` renders it through ──────────────────────────────────────────────────────────
33
+ * `markdown.js`, not `fixture.js`'s `mdParse`. The latter is a transcription of the design
34
+ * prototype's four-branch stand-in (`# `/`## `/`- `/paragraph) pinned byte-for-byte by
35
+ * `fixture-parity.test.mjs`; it stays exactly as it is, and it is not a markdown renderer.
25
36
  */
26
37
 
27
38
  import { el, setChildren } from './dom.js';
28
39
  import { state, setState, FIXTURE } from './state.js';
29
40
  import * as FX from './fixture.js';
41
+ import { mdRender } from './markdown.js';
42
+ import { openDoc } from './panel-initiatives.js';
30
43
 
31
44
  /** `{{ expr }}` — one interpolated value in its own span, as the prototype's runtime emits it. */
32
45
  const iv = (v) => el('span', null, v === null || v === undefined ? '' : String(v));
@@ -152,36 +165,68 @@ function revert(tab) {
152
165
  const FM_K = 'font-size:10.5px;color:var(--muted-foreground);padding-top:2px;font-family:var(--mono)';
153
166
  const FM_V = 'overflow-wrap:anywhere;font-family:var(--mono);font-size:11.5px';
154
167
 
155
- function viewBody(text) {
156
- const doc = FX.mdParse(text);
157
- const fm = [];
158
- doc.fm.forEach((fp) => { fm.push(el('span', { style: FM_K }, iv(fp.k)), el('span', { style: FM_V }, iv(fp.v))); });
159
- return el('div', {
160
- style: 'flex:1;min-height:0;overflow-y:auto;background:var(--background);color:var(--foreground);padding:20px 24px 60px;font-size:14px;line-height:1.65',
161
- },
162
- el('div', { style: 'max-width:72ch' },
168
+ const VIEW_ST = 'flex:1;min-height:0;overflow-y:auto;background:var(--background);color:var(--foreground);padding:20px 24px 60px;font-size:14px;line-height:1.65';
169
+ const RAW_ST = 'flex:1;min-height:0;overflow-y:auto;background:var(--background);padding:14px 16px;font-family:var(--mono);font-size:12px;line-height:1.7';
170
+ const DIFF_ST = 'flex:1;min-height:0;overflow-y:auto;background:var(--background);padding:14px 0;font-family:var(--mono);font-size:12px;line-height:1.7';
171
+
172
+ /* ── THE SCROLL POSITION IS STATE, AND IT LIVES ON A NODE ──────────────────────────────────────
173
+ *
174
+ * Every `setState` anywhere in the app repaints all four panels, and panel 4 calls `mountDoc` on
175
+ * every paint. Each body used to be built from scratch, and `setChildren` reconciles by node
176
+ * IDENTITY — so the div `scrollTop` lives on was removed and a fresh one inserted, and a half-read
177
+ * document went back to line 1. With a live agent session beside it that fires on the session poll,
178
+ * at four-second intervals the PM neither caused nor can predict.
179
+ *
180
+ * This is the same problem the edit textarea has, and it gets the same answer: the element is
181
+ * created once per tab and per mode and KEPT. A repaint whose text has not changed hands back the
182
+ * identical node, which is what `setChildren` needs in order to leave it alone; when the text really
183
+ * does change the children are rebuilt INSIDE that element and the offset is restored, because the
184
+ * document being read is still the document being read.
185
+ *
186
+ * Keyed on the rendered text, not on the tab alone — that is what makes "changed nothing" decidable
187
+ * without diffing DOM.
188
+ */
189
+ const scrollers = new Map(); // tab id -> { view|raw|diff: { key, node } }
190
+
191
+ function scrollPane(tab, mode, key, style, build) {
192
+ let per = scrollers.get(tab.id);
193
+ if (!per) { per = {}; scrollers.set(tab.id, per); }
194
+ const cur = per[mode];
195
+ if (cur && cur.key === key) return cur.node; // nothing changed — the very same element
196
+ const node = (cur && cur.node) || el('div', { style });
197
+ const top = node.scrollTop;
198
+ setChildren(node, build());
199
+ if (top) node.scrollTop = top;
200
+ per[mode] = { key, node };
201
+ return node;
202
+ }
203
+
204
+ /** What links and images in this document resolve against. `onDoc` opens a .md link as a doc tab. */
205
+ const linkCtx = (tab) => ({
206
+ dir: String(tab.path || '').split('/').slice(0, -1),
207
+ ws: wsId(),
208
+ onDoc: (p) => openDoc(tab.slug, p, 'FILE'),
209
+ });
210
+
211
+ function viewBody(tab, text) {
212
+ return scrollPane(tab, 'view', text, VIEW_ST, () => {
213
+ const doc = mdRender(text, linkCtx(tab));
214
+ const fm = [];
215
+ doc.fm.forEach((fp) => { fm.push(el('span', { style: FM_K }, iv(fp.k)), el('span', { style: FM_V }, iv(fp.v))); });
216
+ return [el('div', { style: 'max-width:72ch' },
163
217
  el('div', {
164
218
  style: 'border:1px solid var(--border);background:var(--card);padding:10px 12px;margin-bottom:18px;display:grid;grid-template-columns:auto 1fr;gap:4px 14px;font-size:12.5px',
165
219
  }, fm),
166
220
  el('h1', {
167
221
  style: 'font-size:21px;font-weight:600;line-height:1.3;margin:4px 0 12px;text-wrap:balance;font-family:var(--sans)',
168
- }, iv(doc.title)),
169
- doc.blocks.map((bk) => el('div', null,
170
- bk.isH ? el('h2', {
171
- style: 'font-size:16px;font-weight:600;line-height:1.35;margin:24px 0 8px;padding-bottom:5px;border-bottom:1px solid var(--border)',
172
- }, iv(bk.h)) : null,
173
- bk.isP ? el('p', { style: 'margin:0 0 12px' }, iv(bk.p)) : null,
174
- bk.isList ? el('ul', { style: 'margin:0 0 12px;padding-left:22px' },
175
- bk.items.map((li) => el('li', { style: 'margin:0 0 5px;padding-left:2px' }, iv(li)))) : null,
176
- )),
177
- ),
178
- );
222
+ }, doc.title || iv('')),
223
+ doc.blocks.map((bk) => el('div', null, bk)),
224
+ )];
225
+ });
179
226
  }
180
227
 
181
- function rawBody(text) {
182
- return el('div', {
183
- style: 'flex:1;min-height:0;overflow-y:auto;background:var(--background);padding:14px 16px;font-family:var(--mono);font-size:12px;line-height:1.7',
184
- }, text.split('\n').map((l) => {
228
+ function rawBody(tab, text) {
229
+ return scrollPane(tab, 'raw', text, RAW_ST, () => text.split('\n').map((l) => {
185
230
  const ln = FX.rawLine(l);
186
231
  return el('div', {
187
232
  style: 'color:' + ln.c + ';font-weight:' + ln.w + ';white-space:pre-wrap;overflow-wrap:anywhere;min-height:1.2em',
@@ -254,13 +299,18 @@ function htmlFrame(tab, text) {
254
299
  return node;
255
300
  }
256
301
 
257
- function diffBody(raw, text, dirty) {
258
- const rows = dirty ? FX.diffLines(raw, text) : [FX.NO_DIFF_ROW];
259
- return el('div', {
260
- style: 'flex:1;min-height:0;overflow-y:auto;background:var(--background);padding:14px 0;font-family:var(--mono);font-size:12px;line-height:1.7',
261
- }, rows.map((dr) => el('div', {
262
- style: 'color:' + dr.c + ';background:' + dr.bg + ';padding:0 16px;white-space:pre-wrap;overflow-wrap:anywhere;min-height:1.2em',
263
- }, iv(dr.t))));
302
+ /* Two texts joined into one key. The separator is a character no document can contain — the same
303
+ * reasoning `sessionProjection` uses in data.js — so a change that only moves the boundary between
304
+ * them cannot fold into an identical key and suppress a rebuild that was real. */
305
+ const SEP = '\u0000';
306
+
307
+ function diffBody(tab, raw, text, dirty) {
308
+ return scrollPane(tab, 'diff', dirty ? raw + SEP + text : '', DIFF_ST, () => {
309
+ const rows = dirty ? FX.diffLines(raw, text) : [FX.NO_DIFF_ROW];
310
+ return rows.map((dr) => el('div', {
311
+ style: 'color:' + dr.c + ';background:' + dr.bg + ';padding:0 16px;white-space:pre-wrap;overflow-wrap:anywhere;min-height:1.2em',
312
+ }, iv(dr.t)));
313
+ });
264
314
  }
265
315
 
266
316
  /* ── per-tab state that is DOM, not data ───────────────────────────────────────────────────── */
@@ -287,6 +337,7 @@ function editBox(tab) {
287
337
  export function disposeDoc(id) {
288
338
  boxes.delete(id);
289
339
  frames.delete(id);
340
+ scrollers.delete(id);
290
341
  }
291
342
 
292
343
  /* ── mount ─────────────────────────────────────────────────────────────────────────────────── */
@@ -335,8 +386,8 @@ export function mountDoc(host, tab) {
335
386
  );
336
387
 
337
388
  let body;
338
- if (dm === 'raw') body = rawBody(text);
339
- else if (dm === 'diff') body = diffBody(raw, text, dirty);
389
+ if (dm === 'raw') body = rawBody(tab, text);
390
+ else if (dm === 'diff') body = diffBody(tab, raw, text, dirty);
340
391
  else if (dm === 'edit') {
341
392
  body = editBox(tab);
342
393
  /* `defaultValue` IS the textarea's child text node, and the prototype's runtime keeps it in
@@ -345,7 +396,7 @@ export function mountDoc(host, tab) {
345
396
  * genuinely differs, which is what keeps the caret still mid-sentence. */
346
397
  if (body.defaultValue !== text) body.defaultValue = text;
347
398
  if (body.value !== text) body.value = text;
348
- } else body = rendersAsPage(tab) ? htmlFrame(tab, text) : viewBody(text);
399
+ } else body = rendersAsPage(tab) ? htmlFrame(tab, text) : viewBody(tab, text);
349
400
 
350
401
  setChildren(host, [modeBar, body]);
351
402
  return host;
@@ -1051,6 +1051,11 @@ const ROLE_BY_DIR = {
1051
1051
  // the only artifact that exists for work backfilled from a merge. Before it was a role, a record
1052
1052
  // with no sibling PRD landed in `ungrouped` and the initiative was invisible here.
1053
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',
1054
1059
  };
1055
1060
  // ── D60 specimens: prototype/<id>/, deliberately OUTSIDE planning/ ───────────────────────────
1056
1061
  // The planning walk cannot reach them (planningRoots only finds `planning`), so a prototype leg was
@@ -1120,8 +1125,44 @@ function parseHead(full) {
1120
1125
  recordId: rid ? rid[1] : null,
1121
1126
  title: h1 ? h1[1].replace(/^(PRD|Eval Rubric|Design Brief)\s*:\s*/i, '').trim() : null,
1122
1127
  kr: clean(kr), lane: clean(lane),
1128
+ dest: frontmatterDest(head),
1123
1129
  };
1124
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
+ }
1125
1166
  function roleAndStem(rel, base) {
1126
1167
  const stem = base.replace(/\.md$/, '');
1127
1168
  for (const [suf, role] of ROLE_SUFFIXES) {
@@ -1145,6 +1186,7 @@ function docMeta(root, full) {
1145
1186
  initiative: head.initiative || (role === 'record' ? head.recordId : null) || null,
1146
1187
  stem, title: head.title || null,
1147
1188
  kr: head.kr || null, lane: head.lane || null,
1189
+ dest: head.dest || null,
1148
1190
  };
1149
1191
  metaCache.set(full, meta);
1150
1192
  return meta;
@@ -1230,7 +1272,7 @@ function initiativeIndex(root) {
1230
1272
  };
1231
1273
  for (const m of metas) {
1232
1274
  const k = keyFor(m);
1233
- 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 };
1234
1276
  if (!k) { ungrouped.push(art); continue; }
1235
1277
  if (!groups.has(k)) groups.set(k, { id: k, title: null, kr: null, lane: null, artifacts: [] });
1236
1278
  const g = groups.get(k);
@@ -1242,7 +1284,11 @@ function initiativeIndex(root) {
1242
1284
  if (m.kr && (m.role === 'record' || !g.kr)) g.kr = m.kr;
1243
1285
  if (m.lane && (m.role === 'record' || !g.lane)) g.lane = m.lane;
1244
1286
  }
1245
- 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'];
1246
1292
  const initiatives = [...groups.values()].map((g) => {
1247
1293
  g.artifacts.sort((a, b) => {
1248
1294
  const ia = ROLE_ORDER.indexOf(a.role), ib = ROLE_ORDER.indexOf(b.role);
@@ -1468,7 +1514,7 @@ function surfaceInitiatives(root, w) {
1468
1514
  accepted: state === 'accepted',
1469
1515
  delivered: state === 'accepted' || state === 'delivered',
1470
1516
  delivery_evidence: deliveredBy,
1471
- 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 })),
1472
1518
  // NET-NEW state — not on disk yet, so null (the surface renders "— not tracked", never a guess)
1473
1519
  rice: null, effort: null, milestones: null, progress: null, tracked: false,
1474
1520
  };