create-pmos 0.30.0 → 0.31.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 +47 -0
- package/package.json +1 -1
- package/payload/AGENTS.md +6 -2
- package/payload/KIT-VERSION +2 -2
- package/payload/okf/core/concepts/control-plane.md +10 -4
- package/payload/scripts/workflow-state.js +61 -0
- package/workspace/lib/engine/workflow-state.js +61 -0
- package/workspace/package.json +2 -2
- package/workspace/public/pm/markdown.js +420 -0
- package/workspace/public/pm/tab-doc.js +84 -33
- package/workspace/server.js +23 -3
package/README.md
CHANGED
|
@@ -333,6 +333,53 @@ 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.31.0
|
|
337
|
+
|
|
338
|
+
**Recording an acceptance can now carry the one field the calibration metric was missing — and the
|
|
339
|
+
Review Gate's own writers stopped writing where they were told not to.**
|
|
340
|
+
Run `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
|
|
341
|
+
|
|
342
|
+
- **The Acceptance gate carries `pm_label`.** Accepting work answers "is this going in?" (D12).
|
|
343
|
+
Whether the *evaluator* was right about it (D40) is a different question, and there was nowhere to
|
|
344
|
+
put the answer — so every acceptance recorded through the workspace was unlabeled, and Cohen's κ
|
|
345
|
+
had nothing to compute against. `POST /api/gate` now accepts `pm_label: pass | fail` when you state
|
|
346
|
+
it, refuses anything else rather than silently dropping it, and still never *derives* one from the
|
|
347
|
+
fact that you accepted. A label nobody typed is not a label.
|
|
348
|
+
- **`accepted: true` with `pm_label: fail` is deliberately legal.** You can accept work whose gate
|
|
349
|
+
correctly failed — that is not a contradiction, and forbidding it would erase a whole cell of the
|
|
350
|
+
matrix the metric needs.
|
|
351
|
+
- **The response tells you whether the label landed**, so a surface can show the gap instead of you
|
|
352
|
+
having to know it exists.
|
|
353
|
+
|
|
354
|
+
## What's new in 0.30.1
|
|
355
|
+
|
|
356
|
+
**Open a markdown file in the workspace and you get the document, not its source. And scrolling
|
|
357
|
+
down stays scrolled down.**
|
|
358
|
+
Run `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
|
|
359
|
+
|
|
360
|
+
- **The document pane renders markdown properly.** Bold was asterisks, inline code was backticks, a
|
|
361
|
+
table was one paragraph per row with the pipes still in it, and a numbered procedure was a single
|
|
362
|
+
run-on paragraph. An indented sub-bullet was worse than unstyled — it escaped the list it belonged
|
|
363
|
+
inside and reappeared underneath it. `view` now renders headings 1–6, soft-wrapped paragraphs,
|
|
364
|
+
ordered / nested / task lists, GFM tables with per-column alignment, fenced code, blockquotes and
|
|
365
|
+
rules, plus bold, italic, inline code, strikethrough, links and images. Rubrics and skills — the
|
|
366
|
+
documents this workflow is written in — are readable in the pane for the first time.
|
|
367
|
+
- **A `.md` link opens that document.** Click a link to another file in your repo and it opens as a
|
|
368
|
+
tab, resolved against the document you are reading (or the repo root for a `/planning/…` path). A
|
|
369
|
+
link to something the pane cannot open stays as plain text rather than becoming a dead link.
|
|
370
|
+
- **Nothing is fetched because you opened a file.** A repo-relative image is streamed by your own
|
|
371
|
+
local workspace; a remote or `data:` image stays as its alt text and is never requested. The
|
|
372
|
+
renderer builds DOM nodes and never assigns HTML, so a `<script>` in a markdown file is characters
|
|
373
|
+
on the screen, not a script.
|
|
374
|
+
- **Scrolling a document survives a repaint.** The pane rebuilt its scrolling element on every
|
|
375
|
+
redraw, so anything that changed state anywhere — including the four-second session poll, which
|
|
376
|
+
fires whenever an agent beside you is working — took a half-read document back to line 1. The
|
|
377
|
+
scroll element is now kept, per tab and per mode, the same way the edit box keeps your caret.
|
|
378
|
+
Holds for `view`, `raw` and `diff`.
|
|
379
|
+
- **Not in this release, said plainly:** this is a workspace-app fix. The file-mode kit — skills,
|
|
380
|
+
scripts, state layout — is unchanged, so `--update` has nothing new to give you. Syntax
|
|
381
|
+
highlighting inside code fences, footnotes and rendered inline HTML are deliberately not included.
|
|
382
|
+
|
|
336
383
|
## What's new in 0.30.0
|
|
337
384
|
|
|
338
385
|
**Your user stories are on the board, and a story can name the Jira issue, Confluence page or
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit \u2014 run the PMOS product-management workflow (initiative \u2192 lane-fit spec \u2192 contract \u2192 build \u2192 three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/AGENTS.md
CHANGED
|
@@ -10,8 +10,12 @@ timestamp: 2026-07-22
|
|
|
10
10
|
> if-else rules engines, and not vague mandates that assume shared context.
|
|
11
11
|
|
|
12
12
|
You are working in a product repo that vendors the **PMOS portable kit** (D54). PMOS here is a
|
|
13
|
-
**control plane** for how work is specced, evaluated, and accepted
|
|
14
|
-
|
|
13
|
+
**control plane** for how work is specced, evaluated, and accepted: you build the product, PMOS
|
|
14
|
+
disciplines the loop.
|
|
15
|
+
|
|
16
|
+
(PMOS itself is a control plane **and a factory** since D65 — but the factory half is not in this
|
|
17
|
+
kit. What you have vendored is the governing half. If a future kit ships execution, it will arrive
|
|
18
|
+
propose-only: opening pull requests, never holding push or merge rights.)
|
|
15
19
|
|
|
16
20
|
## The containment rule (non-negotiable)
|
|
17
21
|
|
package/payload/KIT-VERSION
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
kernel-source: pmos@
|
|
2
|
-
assembled: 2026-08-
|
|
1
|
+
kernel-source: pmos@fd42042
|
|
2
|
+
assembled: 2026-08-05
|
|
3
3
|
mode: vendor
|
|
@@ -8,10 +8,16 @@ timestamp: 2026-06-28
|
|
|
8
8
|
|
|
9
9
|
# The Control Plane
|
|
10
10
|
|
|
11
|
-
**Situating context:** "PMOS is a control plane, not a code generator"
|
|
12
|
-
claim of the whole product
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
**Situating context:** "PMOS is a control plane, not a code generator" was the load-bearing identity
|
|
12
|
+
claim of the whole product. **As of D65 (PM-ratified 2026-08-04) the second half of that sentence is
|
|
13
|
+
retired:** PMOS is a control plane *and a factory*. This concept still fixes what "control plane"
|
|
14
|
+
means — the distinction is more useful now, not less, because there are two graphs and you must know
|
|
15
|
+
which one you are in.
|
|
16
|
+
|
|
17
|
+
The line the old phrasing drew is still drawn, in a different place. It is no longer "PMOS does not
|
|
18
|
+
emit app code"; it is **"the executing half may not write the governing half's records, and may not
|
|
19
|
+
push or merge."** If a thing anchors, accepts, disposes or approves a contract, it belongs to the
|
|
20
|
+
control graph. If it opens a pull request against a product, it belongs to the factory.
|
|
15
21
|
|
|
16
22
|
## Control plane vs. data plane
|
|
17
23
|
|
|
@@ -333,6 +333,7 @@ function firstAppearance(root, range, rel) {
|
|
|
333
333
|
function buildCommits(root, range, lane, opts) {
|
|
334
334
|
if (!range.ok) return { unknown: true };
|
|
335
335
|
const id = opts && opts.id;
|
|
336
|
+
const recordIdx = opts && opts.recordIdx;
|
|
336
337
|
const whole = !!range.wholeHistory;
|
|
337
338
|
|
|
338
339
|
// Over the trunk, the id must be attributable before any commit counts. The index is built once
|
|
@@ -373,6 +374,19 @@ function buildCommits(root, range, lane, opts) {
|
|
|
373
374
|
// answerable at all. `--name-only` lists no files for a true merge commit, so a branch-attributed
|
|
374
375
|
// initiative is counted from its merge subject below rather than from this per-file loop.
|
|
375
376
|
if (whole && id && !commitNamesId(range, i, id)) continue;
|
|
377
|
+
/* Inside a PR RANGE the id filter above does not apply, because a pull request was assumed to
|
|
378
|
+
* carry one initiative and everything in it to be that initiative's build. A branch carrying
|
|
379
|
+
* several breaks that: PR #287 held five, and the FIRST commit on it — a D01 propagation touching
|
|
380
|
+
* supabase/functions/mcp/index.ts — was attributed to `sink-discipline-backport`, whose record did
|
|
381
|
+
* not yet exist. The gate reported ORDER VIOLATION, spec-after-build, for an initiative whose spec
|
|
382
|
+
* demonstrably preceded its build by three commits.
|
|
383
|
+
*
|
|
384
|
+
* A commit made BEFORE an initiative's record first appeared cannot be that initiative's
|
|
385
|
+
* implementation — the initiative did not exist to be built. That is the whole rule, and it is
|
|
386
|
+
* narrow on purpose: an implementation commit landing AFTER the record but BEFORE the PRD still
|
|
387
|
+
* fails, which is the case D13 actually exists to catch. This removes a false violation without
|
|
388
|
+
* removing a true one. */
|
|
389
|
+
if (!whole && recordIdx != null && recordIdx !== undefined && recordIdx >= 0 && i < recordIdx) continue;
|
|
376
390
|
count++;
|
|
377
391
|
if (first === null) { first = i; sample = hit; }
|
|
378
392
|
}
|
|
@@ -574,6 +588,9 @@ function computeWorkflow(id, opts) {
|
|
|
574
588
|
const prdIdx = prd ? firstAppearance(root, range, prd) : null;
|
|
575
589
|
const build = buildCommits(root, range, effLane, {
|
|
576
590
|
id,
|
|
591
|
+
// Where this initiative's own record first appears in the range — the earliest commit that could
|
|
592
|
+
// possibly be its build. See the note in buildCommits' attribution loop.
|
|
593
|
+
recordIdx: firstAppearance(root, range, recRel),
|
|
577
594
|
deliveryIndex: opts && opts.deliveryIndex,
|
|
578
595
|
declaredDelivery: fm.delivered_by,
|
|
579
596
|
});
|
|
@@ -945,6 +962,50 @@ function computeWorkflow(id, opts) {
|
|
|
945
962
|
}
|
|
946
963
|
}
|
|
947
964
|
|
|
965
|
+
/* ── outcome ─ D57: did the bet pay off? A SECOND verdict, at a second time ───────────────────
|
|
966
|
+
*
|
|
967
|
+
* This rung did not exist, and its absence is why the loop never ran: `outcome_metric` is optional
|
|
968
|
+
* in D57, nothing ever asked for it, and 0 of ~170 records declared one. An optional field with no
|
|
969
|
+
* prompt and no surface is a field that does not exist.
|
|
970
|
+
*
|
|
971
|
+
* `na` is a first-class, non-failing state on purpose. Most work is not a bet, and a rung that
|
|
972
|
+
* renders red for every initiative without a metric becomes noise and gets ignored — which is how
|
|
973
|
+
* you end up with a board nobody reads. */
|
|
974
|
+
{
|
|
975
|
+
const metric = String(fm.outcome_metric || '').trim();
|
|
976
|
+
const horizon = String(fm.outcome_horizon || '').trim();
|
|
977
|
+
const checks = readJsonl(path.join(L.runsDir, 'outcome_checks.jsonl'))
|
|
978
|
+
.filter((c) => c && c.initiative_id === id && ['validated', 'refuted', 'inconclusive'].includes(c.disposition));
|
|
979
|
+
const latest = checks.length ? checks[checks.length - 1] : null;
|
|
980
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
981
|
+
|
|
982
|
+
if (!metric) {
|
|
983
|
+
steps.push(step('outcome', 'outcome', 'na', 'no outcome declared — most work is not a bet (D57)'));
|
|
984
|
+
} else if (!horizon) {
|
|
985
|
+
/* A metric with no horizon can never come due, so it reads as diligence for ever while
|
|
986
|
+
* committing to nothing. That is worse than declaring nothing at all. */
|
|
987
|
+
steps.push(step('outcome', 'outcome', 'blocked',
|
|
988
|
+
`outcome_metric declared with NO outcome_horizon — a bet that can never come due`,
|
|
989
|
+
'add outcome_horizon: YYYY-MM-DD to the initiative record, or remove the metric', { rule: 'D57' }));
|
|
990
|
+
} else if (latest) {
|
|
991
|
+
/* A refuted bet is a COMPLETED check and an UNSUCCESSFUL bet. The rung says both — rendering
|
|
992
|
+
* it as a plain green tick is the same watermelon the review rung was fixed for. */
|
|
993
|
+
const done = latest.disposition === 'refuted' ? 'blocked' : 'done';
|
|
994
|
+
steps.push(step('outcome', 'outcome', done,
|
|
995
|
+
`${latest.disposition}${latest.evidence ? ` — ${String(latest.evidence).slice(0, 60)}` : ''}`,
|
|
996
|
+
latest.disposition === 'refuted'
|
|
997
|
+
? 'the check is complete and the bet did not pay off — that is a result, not a failure to record'
|
|
998
|
+
: undefined));
|
|
999
|
+
} else if (horizon <= today) {
|
|
1000
|
+
steps.push(step('outcome', 'outcome', 'blocked',
|
|
1001
|
+
`outcome check DUE — horizon ${horizon} has passed and no disposition is recorded`,
|
|
1002
|
+
'dispose it: node scripts/record-outcome.js (validated | refuted | inconclusive, evidence required)',
|
|
1003
|
+
{ rule: 'D57' }));
|
|
1004
|
+
} else {
|
|
1005
|
+
steps.push(step('outcome', 'outcome', 'pending', `outcome pending — horizon ${horizon}`));
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
948
1009
|
// ── stage, derived from what the steps found ─────────────────────────────────────────────────
|
|
949
1010
|
const by = Object.fromEntries(steps.map((s) => [s.key, s]));
|
|
950
1011
|
let stage = 'intake';
|
|
@@ -333,6 +333,7 @@ function firstAppearance(root, range, rel) {
|
|
|
333
333
|
function buildCommits(root, range, lane, opts) {
|
|
334
334
|
if (!range.ok) return { unknown: true };
|
|
335
335
|
const id = opts && opts.id;
|
|
336
|
+
const recordIdx = opts && opts.recordIdx;
|
|
336
337
|
const whole = !!range.wholeHistory;
|
|
337
338
|
|
|
338
339
|
// Over the trunk, the id must be attributable before any commit counts. The index is built once
|
|
@@ -373,6 +374,19 @@ function buildCommits(root, range, lane, opts) {
|
|
|
373
374
|
// answerable at all. `--name-only` lists no files for a true merge commit, so a branch-attributed
|
|
374
375
|
// initiative is counted from its merge subject below rather than from this per-file loop.
|
|
375
376
|
if (whole && id && !commitNamesId(range, i, id)) continue;
|
|
377
|
+
/* Inside a PR RANGE the id filter above does not apply, because a pull request was assumed to
|
|
378
|
+
* carry one initiative and everything in it to be that initiative's build. A branch carrying
|
|
379
|
+
* several breaks that: PR #287 held five, and the FIRST commit on it — a D01 propagation touching
|
|
380
|
+
* supabase/functions/mcp/index.ts — was attributed to `sink-discipline-backport`, whose record did
|
|
381
|
+
* not yet exist. The gate reported ORDER VIOLATION, spec-after-build, for an initiative whose spec
|
|
382
|
+
* demonstrably preceded its build by three commits.
|
|
383
|
+
*
|
|
384
|
+
* A commit made BEFORE an initiative's record first appeared cannot be that initiative's
|
|
385
|
+
* implementation — the initiative did not exist to be built. That is the whole rule, and it is
|
|
386
|
+
* narrow on purpose: an implementation commit landing AFTER the record but BEFORE the PRD still
|
|
387
|
+
* fails, which is the case D13 actually exists to catch. This removes a false violation without
|
|
388
|
+
* removing a true one. */
|
|
389
|
+
if (!whole && recordIdx != null && recordIdx !== undefined && recordIdx >= 0 && i < recordIdx) continue;
|
|
376
390
|
count++;
|
|
377
391
|
if (first === null) { first = i; sample = hit; }
|
|
378
392
|
}
|
|
@@ -574,6 +588,9 @@ function computeWorkflow(id, opts) {
|
|
|
574
588
|
const prdIdx = prd ? firstAppearance(root, range, prd) : null;
|
|
575
589
|
const build = buildCommits(root, range, effLane, {
|
|
576
590
|
id,
|
|
591
|
+
// Where this initiative's own record first appears in the range — the earliest commit that could
|
|
592
|
+
// possibly be its build. See the note in buildCommits' attribution loop.
|
|
593
|
+
recordIdx: firstAppearance(root, range, recRel),
|
|
577
594
|
deliveryIndex: opts && opts.deliveryIndex,
|
|
578
595
|
declaredDelivery: fm.delivered_by,
|
|
579
596
|
});
|
|
@@ -945,6 +962,50 @@ function computeWorkflow(id, opts) {
|
|
|
945
962
|
}
|
|
946
963
|
}
|
|
947
964
|
|
|
965
|
+
/* ── outcome ─ D57: did the bet pay off? A SECOND verdict, at a second time ───────────────────
|
|
966
|
+
*
|
|
967
|
+
* This rung did not exist, and its absence is why the loop never ran: `outcome_metric` is optional
|
|
968
|
+
* in D57, nothing ever asked for it, and 0 of ~170 records declared one. An optional field with no
|
|
969
|
+
* prompt and no surface is a field that does not exist.
|
|
970
|
+
*
|
|
971
|
+
* `na` is a first-class, non-failing state on purpose. Most work is not a bet, and a rung that
|
|
972
|
+
* renders red for every initiative without a metric becomes noise and gets ignored — which is how
|
|
973
|
+
* you end up with a board nobody reads. */
|
|
974
|
+
{
|
|
975
|
+
const metric = String(fm.outcome_metric || '').trim();
|
|
976
|
+
const horizon = String(fm.outcome_horizon || '').trim();
|
|
977
|
+
const checks = readJsonl(path.join(L.runsDir, 'outcome_checks.jsonl'))
|
|
978
|
+
.filter((c) => c && c.initiative_id === id && ['validated', 'refuted', 'inconclusive'].includes(c.disposition));
|
|
979
|
+
const latest = checks.length ? checks[checks.length - 1] : null;
|
|
980
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
981
|
+
|
|
982
|
+
if (!metric) {
|
|
983
|
+
steps.push(step('outcome', 'outcome', 'na', 'no outcome declared — most work is not a bet (D57)'));
|
|
984
|
+
} else if (!horizon) {
|
|
985
|
+
/* A metric with no horizon can never come due, so it reads as diligence for ever while
|
|
986
|
+
* committing to nothing. That is worse than declaring nothing at all. */
|
|
987
|
+
steps.push(step('outcome', 'outcome', 'blocked',
|
|
988
|
+
`outcome_metric declared with NO outcome_horizon — a bet that can never come due`,
|
|
989
|
+
'add outcome_horizon: YYYY-MM-DD to the initiative record, or remove the metric', { rule: 'D57' }));
|
|
990
|
+
} else if (latest) {
|
|
991
|
+
/* A refuted bet is a COMPLETED check and an UNSUCCESSFUL bet. The rung says both — rendering
|
|
992
|
+
* it as a plain green tick is the same watermelon the review rung was fixed for. */
|
|
993
|
+
const done = latest.disposition === 'refuted' ? 'blocked' : 'done';
|
|
994
|
+
steps.push(step('outcome', 'outcome', done,
|
|
995
|
+
`${latest.disposition}${latest.evidence ? ` — ${String(latest.evidence).slice(0, 60)}` : ''}`,
|
|
996
|
+
latest.disposition === 'refuted'
|
|
997
|
+
? 'the check is complete and the bet did not pay off — that is a result, not a failure to record'
|
|
998
|
+
: undefined));
|
|
999
|
+
} else if (horizon <= today) {
|
|
1000
|
+
steps.push(step('outcome', 'outcome', 'blocked',
|
|
1001
|
+
`outcome check DUE — horizon ${horizon} has passed and no disposition is recorded`,
|
|
1002
|
+
'dispose it: node scripts/record-outcome.js (validated | refuted | inconclusive, evidence required)',
|
|
1003
|
+
{ rule: 'D57' }));
|
|
1004
|
+
} else {
|
|
1005
|
+
steps.push(step('outcome', 'outcome', 'pending', `outcome pending — horizon ${horizon}`));
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
948
1009
|
// ── stage, derived from what the steps found ─────────────────────────────────────────────────
|
|
949
1010
|
const by = Object.fromEntries(steps.map((s) => [s.key, s]));
|
|
950
1011
|
let stage = 'intake';
|
package/workspace/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
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 story-artifacts.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",
|
|
@@ -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;
|
|
@@ -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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
},
|
|
169
|
-
doc.blocks.map((bk) => el('div', null,
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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;
|
package/workspace/server.js
CHANGED
|
@@ -4644,10 +4644,23 @@ async function handleRequest(req, res) {
|
|
|
4644
4644
|
action: body.action, run_id: body.run_id, initiative_id: body.initiative_id,
|
|
4645
4645
|
// `accepted` is the kit's field (D54) — what metrics.py counts for the north star and
|
|
4646
4646
|
// throughput. Only the explicit Acceptance action sets it; `approve` is a Review-gate
|
|
4647
|
-
// disposition and leaves it unset. `pm_label` is
|
|
4648
|
-
//
|
|
4647
|
+
// disposition and leaves it unset. `pm_label` is still never DERIVED here: κ labels are the
|
|
4648
|
+
// PM's own word (D40 — never manufacture calibration data from a UI click).
|
|
4649
|
+
//
|
|
4650
|
+
// But refusing to derive it is not the same as refusing to CARRY it, and the two were
|
|
4651
|
+
// conflated. This endpoint is the PM's real click path under D62/D64, so every acceptance it
|
|
4652
|
+
// wrote was unlabeled — 38 of 42 distinct runs in the live ledger — and κ stayed degenerate
|
|
4653
|
+
// because tn and fp had nowhere to come from. `record-acceptance.js` requires the label and
|
|
4654
|
+
// nothing routes the PM to it. So the label is accepted from the client when the PM states
|
|
4655
|
+
// it, validated against the same closed set, and simply absent when they do not.
|
|
4649
4656
|
accepted: body.action === 'accept' ? true : body.action === 'return' ? false : undefined,
|
|
4657
|
+
...(body.pm_label === 'pass' || body.pm_label === 'fail' ? { pm_label: body.pm_label } : {}),
|
|
4650
4658
|
note: body.note || '', workspace: w ? w.name : null, root: w ? w.root : null, pm: true };
|
|
4659
|
+
// A malformed label is refused rather than dropped. Silently discarding it would put the row in
|
|
4660
|
+
// the unlabeled pile while the PM believed they had labelled it — the same shape as inferring.
|
|
4661
|
+
if (body.pm_label !== undefined && body.pm_label !== 'pass' && body.pm_label !== 'fail') {
|
|
4662
|
+
return json(res, { ok: false, error: "pm_label must be 'pass' or 'fail' — it answers whether the EVALUATOR was right (D40), separately from whether the work goes in (D12)" }, 400);
|
|
4663
|
+
}
|
|
4651
4664
|
// Record into the workspace's OWN state, creating the kit layout for a PMOS-shaped repo.
|
|
4652
4665
|
// If it can't be resolved, still record — but in the server's state/, and say so in the
|
|
4653
4666
|
// response so the client can be honest about where the decision landed.
|
|
@@ -4677,7 +4690,14 @@ async function handleRequest(req, res) {
|
|
|
4677
4690
|
fs.writeFileSync(nf, JSON.stringify(needs.filter((n) => n.run_id !== body.run_id)));
|
|
4678
4691
|
}
|
|
4679
4692
|
} catch (e) { warnWriteFail('needs.json (clearing the accepted need)', e); }
|
|
4680
|
-
|
|
4693
|
+
/* `pm_label_recorded` is reported so the surface can show the gap rather than the PM having to
|
|
4694
|
+
* know it exists. An acceptance with no label is legal and always will be — the PM's attention
|
|
4695
|
+
* is the scarce thing — but it is the reason κ is degenerate, and a gap nobody is shown is a gap
|
|
4696
|
+
* nobody closes. Reported, never enforced. */
|
|
4697
|
+
return json(res, {
|
|
4698
|
+
ok: true, recorded: rec, sink, fallback,
|
|
4699
|
+
pm_label_recorded: rec.pm_label === 'pass' || rec.pm_label === 'fail',
|
|
4700
|
+
});
|
|
4681
4701
|
}
|
|
4682
4702
|
if (p === '/api/doc/save' && req.method === 'POST') {
|
|
4683
4703
|
// The editor's save (workspace-doc-editor R3/R4). The ONLY write path on this surface, and it is
|