create-pmos 0.29.1 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/okf/core/concepts/frontmatter-format.md +36 -1
- package/workspace/lib/four-panel.js +40 -3
- package/workspace/package.json +2 -2
- package/workspace/public/pm/data.js +6 -1
- package/workspace/public/pm/panel-detail.js +25 -1
- package/workspace/public/pm/panel-tabs.js +87 -0
- package/workspace/public/pm/state.js +10 -0
- package/workspace/server.js +49 -3
package/README.md
CHANGED
|
@@ -333,6 +333,38 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
|
|
|
333
333
|
`okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
|
|
334
334
|
exactly what you're running.
|
|
335
335
|
|
|
336
|
+
## What's new in 0.30.0
|
|
337
|
+
|
|
338
|
+
**Your user stories are on the board, and a story can name the Jira issue, Confluence page or
|
|
339
|
+
Google Doc it corresponds to — in the file itself. The scratchpad drags up and down.**
|
|
340
|
+
Run `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
|
|
341
|
+
|
|
342
|
+
- **User stories show up on an initiative's artifacts list, in the right place.** The index has
|
|
343
|
+
recognised `*-user-stories.md` for a while and nothing downstream used it: stories sorted *below*
|
|
344
|
+
the PRD and the rubric they decompose, and the role label was twelve characters in a box built for
|
|
345
|
+
eight, so it ran under the file path beside it. Stories now sort directly after the PRD, and the
|
|
346
|
+
chip fits. A file in a `planning/stories/` folder counts as a story too, with no suffix needed in
|
|
347
|
+
its name.
|
|
348
|
+
- **A story can carry its own destination.** Put `jira: ABC-123`, `confluence: SPACE / page` or
|
|
349
|
+
`google_doc: <url>` in a story's frontmatter and the workspace draws the ⇄ pill on its row — no
|
|
350
|
+
clicking, no re-typing on a second machine, and the tie is in git where an agent reading the file
|
|
351
|
+
can see it. It works for any planning document, not only stories.
|
|
352
|
+
- **It says what it knows, which is nothing.** That pill reads *declared in the file · nothing has
|
|
353
|
+
been checked*. PMOS holds no credential, makes no request, and cannot mark a reference resolved —
|
|
354
|
+
only a session carrying your own MCP can do that. A link you record in the panel still wins over
|
|
355
|
+
the one in the file, and `unlink` now survives a reload instead of the file quietly putting the
|
|
356
|
+
pill back.
|
|
357
|
+
- **The scratchpad resizes.** Drag the handle along its top edge — up for taller, down for shorter.
|
|
358
|
+
It cannot be dragged shut, it cannot take more than 70% of the panel (the pane above is often a
|
|
359
|
+
live terminal), the size sticks across reloads, and `↑`/`↓` work when the handle is focused with
|
|
360
|
+
`Home` to reset. Resizing never touches what you have staged in the pad.
|
|
361
|
+
- **Not in this release, said plainly:** the `Story` frontmatter type, the gate rule that enforces
|
|
362
|
+
it and the `--fix` repair are PMOS-self only and are **not** in this payload. The registry they
|
|
363
|
+
enforce is PMOS's own vocabulary, and running it inside your repository would fail files written
|
|
364
|
+
to your conventions. The workspace half above works whatever `type:` your story files declare.
|
|
365
|
+
Nothing here reads status back from a tracker — there is no "3 of 7 done", and the surface does
|
|
366
|
+
not imply there is.
|
|
367
|
+
|
|
336
368
|
## What's new in 0.29.1
|
|
337
369
|
|
|
338
370
|
**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.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit \u2014 run the PMOS product-management workflow (initiative \u2192 lane-fit spec \u2192 contract \u2192 build \u2192 three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -57,12 +57,47 @@ The enforced list lives in [`scripts/frontmatter_registry.txt`](/scripts/frontma
|
|
|
57
57
|
the single source `frontmatter_lint.py` reads. A `type:` value not in it fails CI (this catches typos).
|
|
58
58
|
|
|
59
59
|
- **OKF core:** `Concept`, `ADR`, `Template`, `Playbook`, `Reference`
|
|
60
|
-
- **Planning:** `Initiative`, `PRD`, `Rubric`, `OKR`, `Radar`, `Checklist`, `Calibration`, `Research`, `Design`, `Prototype`
|
|
60
|
+
- **Planning:** `Initiative`, `PRD`, `Story`, `Rubric`, `OKR`, `Radar`, `Checklist`, `Calibration`, `Research`, `Design`, `Prototype`
|
|
61
61
|
- `Initiative` is the repo-native initiative record in [`planning/initiatives/`](/planning/initiatives/), read by the
|
|
62
62
|
[Initiative Gate](/.github/scripts/initiative-gate.sh). Its D56 **work-type lane** rides a separate `lane:` key —
|
|
63
63
|
not `type:` — so the lane vocabulary and this registry never collide.
|
|
64
|
+
- `Story` is a **user-story document** — the decomposition of a spec into the units of work handed
|
|
65
|
+
to whoever builds them. It is the one type in this registry that is also **checked positionally**
|
|
66
|
+
(see below), and it may carry an optional `jira:` / `confluence:` / `google_doc:` key naming the
|
|
67
|
+
tracker record it corresponds to. That key is a *declaration* the workspace displays, never a
|
|
68
|
+
lookup: PMOS holds no credential and makes no outbound request.
|
|
64
69
|
- **Root governance:** `Decision`, `Principle`, `Debt`, `Discovery`, `Guide`
|
|
65
70
|
|
|
71
|
+
### When the path decides the type
|
|
72
|
+
|
|
73
|
+
Most types are the author's to choose. A few document kinds are identifiable from the path alone, and
|
|
74
|
+
for those `type ∈ registry` is not enough — the gate also checks the type is the RIGHT one, because a
|
|
75
|
+
file wearing a plausible wrong type passes the membership test forever.
|
|
76
|
+
|
|
77
|
+
**User stories** are the first such kind. A tracked `.md` whose filename ends `-user-stories.md`,
|
|
78
|
+
`-user-story.md` or `-stories.md`, **or** whose immediate parent directory is `stories/` (or
|
|
79
|
+
`user-stories/`), must declare `type: Story`. These are exactly the shapes the workspace's library
|
|
80
|
+
index calls a story (`ROLE_SUFFIXES` / `ROLE_BY_DIR` in `workspace/server.js`) — the board and the
|
|
81
|
+
gate are held to one definition of what a story is, rather than two that can drift.
|
|
82
|
+
|
|
83
|
+
The boundary is deliberately narrow: `planning/stories/archive/notes.md` is a note filed beside
|
|
84
|
+
stories, not a story, and `planning/research/story-mapping.md` is research. Both directions are
|
|
85
|
+
asserted in [`scripts/tests/frontmatter-story.sh`](/scripts/tests/frontmatter-story.sh).
|
|
86
|
+
|
|
87
|
+
**Retroactive repair (PMOS-self — the kit does not carry this tool).** Story files written before
|
|
88
|
+
`Story` existed declare something else — this repo's own test fixture declared `type: PRD`, because
|
|
89
|
+
nothing else was available. `python3 scripts/frontmatter_lint.py --fix` corrects them, and
|
|
90
|
+
`--root <dir>` points it at another repository so a product repo's stories can be repaired too. It
|
|
91
|
+
is bounded on purpose: story files only, the `type:` key only, and it never invents a `title` or a
|
|
92
|
+
`timestamp` — a story file missing those is reported rather than guessed at.
|
|
93
|
+
|
|
94
|
+
The linter and this registry are **not** in the create-pmos payload, and that is a decision rather
|
|
95
|
+
than an omission: the registry is *PMOS's* vocabulary, and enforcing it inside an adopter's
|
|
96
|
+
repository would fail files written to their own conventions. Whose vocabulary governs an adopter's
|
|
97
|
+
repo is its own question, and it deserves its own initiative. What adopters *do* get is the reading
|
|
98
|
+
half — the workspace draws a story on the artifacts row and shows the destination its frontmatter
|
|
99
|
+
declares, whatever `type:` the file carries.
|
|
100
|
+
|
|
66
101
|
### Extending the vocabulary
|
|
67
102
|
|
|
68
103
|
Add **one line** to `scripts/frontmatter_registry.txt` and a matching row above. A new `type` is a
|
|
@@ -272,7 +272,36 @@ function artifactsFor(index, id) {
|
|
|
272
272
|
const rows = (index && Array.isArray(index.initiatives)) ? index.initiatives : [];
|
|
273
273
|
const hit = rows.find((r) => r && r.id === id);
|
|
274
274
|
if (!hit || !Array.isArray(hit.artifacts)) return [];
|
|
275
|
-
return hit.artifacts.map((a) => ({
|
|
275
|
+
return hit.artifacts.map((a) => ({
|
|
276
|
+
role: a.role, name: a.name, path: a.path, title: a.title || null,
|
|
277
|
+
// The destination the document declares about ITSELF, from its frontmatter (server.js
|
|
278
|
+
// `frontmatterDest`). It is carried, not resolved — see `withFrontmatterDest` for the one rule
|
|
279
|
+
// that decides when the panel is allowed to see it.
|
|
280
|
+
dest: a.dest || null,
|
|
281
|
+
}));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Decide, per artifact, whether the file's own declared destination is the one the panel shows.
|
|
286
|
+
*
|
|
287
|
+
* THE RULE: **if the store has spoken about an artifact at all, the store owns it.** The file fills
|
|
288
|
+
* the silence, and only the silence.
|
|
289
|
+
*
|
|
290
|
+
* "At all" includes a `retracted` row, and that clause is the whole reason this function exists
|
|
291
|
+
* rather than a `||` at the call site. `unlink` appends a retraction; `linkView` then correctly
|
|
292
|
+
* reports no link. If the fallback consulted the file at that point, the pill the PM just removed
|
|
293
|
+
* would be back on the next reload and the button would be a button that undoes itself. A
|
|
294
|
+
* retraction is a decision about this artifact, so it counts as the store having spoken.
|
|
295
|
+
*
|
|
296
|
+
* The precedence direction is the other half: a row in the store is the PM's own act in a UI, made
|
|
297
|
+
* later than the file was written, against a destination they were looking at. The file is the
|
|
298
|
+
* artifact's own statement and travels with the repository. When they disagree, the later explicit
|
|
299
|
+
* act wins — and the file is left exactly as it is, because PMOS does not edit the PM's documents.
|
|
300
|
+
*/
|
|
301
|
+
function withFrontmatterDest(artifacts, storeRows) {
|
|
302
|
+
const spoken = new Set();
|
|
303
|
+
for (const r of storeRows || []) if (r && r.artifact_path) spoken.add(r.artifact_path);
|
|
304
|
+
return (artifacts || []).map((a) => (a.dest && spoken.has(a.path) ? { ...a, dest: null } : a));
|
|
276
305
|
}
|
|
277
306
|
|
|
278
307
|
/* ── sessions ───────────────────────────────────────────────────────────────────────────────── */
|
|
@@ -727,11 +756,19 @@ function lookupTask({ kind, subject, hint, profile } = {}) {
|
|
|
727
756
|
*/
|
|
728
757
|
function assemble({ id, rec, ladder, links, index, sessions, workspaces, anchor, artifactLinks, initiativeLinks, artifactSync, homeDir }) {
|
|
729
758
|
if (!rec.ok) return { ok: false, id, error: rec.error };
|
|
730
|
-
const
|
|
759
|
+
const indexed = artifactsFor(index, id);
|
|
731
760
|
const sess = sessionsFor({ sessions, workspaces, id });
|
|
732
761
|
// linkView LAST, so superseding happens across this initiative's rows only — collapsing the whole
|
|
733
762
|
// store first would let another initiative's link to the same path win a pair that is not its own.
|
|
734
763
|
const mine = (rows, pred) => linkView((rows && rows.ok ? rows.rows.filter(pred) : []));
|
|
764
|
+
// …and `includeRetracted` here, because "the PM withdrew this" is the store having spoken about
|
|
765
|
+
// the artifact. See withFrontmatterDest: without it, unlink would undo itself on the next reload.
|
|
766
|
+
const spokenFor = linkView(
|
|
767
|
+
(artifactLinks && artifactLinks.ok ? artifactLinks.rows : []).filter((r) => r
|
|
768
|
+
&& (r.initiative_id === id || indexed.some((a) => a.path === r.artifact_path))),
|
|
769
|
+
{ includeRetracted: true },
|
|
770
|
+
);
|
|
771
|
+
const artifacts = withFrontmatterDest(indexed, spokenFor);
|
|
735
772
|
return {
|
|
736
773
|
ok: true,
|
|
737
774
|
id,
|
|
@@ -769,7 +806,7 @@ function assemble({ id, rec, ladder, links, index, sessions, workspaces, anchor,
|
|
|
769
806
|
module.exports = {
|
|
770
807
|
ARTIFACT_LINKS, INITIATIVE_LINKS, ARTIFACT_SYNC,
|
|
771
808
|
LINK_KINDS, LINK_STATES, LINK_ORIGINS, MCP_FOR_KIND, SYNC_DIRECTIONS, SYNC_STATES,
|
|
772
|
-
validId, connectors, readRecord, parseTasks, artifactsFor, sessionsFor, anchorFor,
|
|
809
|
+
validId, connectors, readRecord, parseTasks, artifactsFor, withFrontmatterDest, sessionsFor, anchorFor,
|
|
773
810
|
storeDir, readDeclarations, appendDeclaration, artifactLinkRow, initiativeLinkRow, assemble,
|
|
774
811
|
linkView, lookupTask, syncRow, syncView, syncTask,
|
|
775
812
|
};
|
package/workspace/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.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 no-network.test.js && node fixture-parity.test.mjs && node start-policy.test.js && node state-isolation.test.js",
|
|
13
|
+
"test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node fixture-parity.test.mjs && node start-policy.test.js && node state-isolation.test.js",
|
|
14
14
|
"test:ui": "node ui-test.js",
|
|
15
15
|
"test:parity": "node design-parity.js --states",
|
|
16
16
|
"test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
|
|
@@ -332,7 +332,12 @@ function artsFor(payload) {
|
|
|
332
332
|
sync.set(r.artifact_path + '|' + r.kind, r);
|
|
333
333
|
}
|
|
334
334
|
return (payload.artifacts || []).map((a) => {
|
|
335
|
-
|
|
335
|
+
// A row in the store, else the destination the FILE declares about itself (server.js
|
|
336
|
+
// `frontmatterDest`, gated by `withFrontmatterDest` — the server has already decided which of
|
|
337
|
+
// the two the panel is allowed to see, including for an artifact whose link the PM retracted).
|
|
338
|
+
// This used to return `{role, path}` here and drop `a.dest` on the floor, even though
|
|
339
|
+
// `artifactRow` reads it as its fallback: the reader existed and the producer did not.
|
|
340
|
+
const d = dest.get(a.path) || a.dest || null;
|
|
336
341
|
if (!d) return { role: a.role, path: a.path };
|
|
337
342
|
return { role: a.role, path: a.path, dest: d, sync: sync.get(a.path + '|' + d.kind) || null };
|
|
338
343
|
});
|
|
@@ -679,6 +679,20 @@ function syncSummary(sy, dest) {
|
|
|
679
679
|
+ '. the task below is what performs it, and no session has reported back.';
|
|
680
680
|
}
|
|
681
681
|
|
|
682
|
+
/* ── the role chip's short form ───────────────────────────────────────────────────────────────
|
|
683
|
+
* The chip is a fixed 52px box — roughly eight characters of 10px mono with `.1em` tracking — and
|
|
684
|
+
* the design drew it that way. The index's canonical role for a user-story document is
|
|
685
|
+
* `user stories`, which is twelve, so it ran under the path beside it.
|
|
686
|
+
*
|
|
687
|
+
* The canonical role is NOT renamed. It is asserted as `user stories` in two suites, and it is the
|
|
688
|
+
* string the search results (`artifact · user stories`) and a document tab's label read — both
|
|
689
|
+
* places where the longer, clearer word is the right one. Only the CHIP is abbreviated, and only
|
|
690
|
+
* where a role is genuinely too long for the box the design specified. The prototype's own stories
|
|
691
|
+
* row says `STORIES`, so this is the design's word, shortened to the case the real index uses.
|
|
692
|
+
*/
|
|
693
|
+
const ROLE_CHIP = { 'user stories': 'stories' };
|
|
694
|
+
function roleChip(role) { return ROLE_CHIP[role] || role; }
|
|
695
|
+
|
|
682
696
|
function artifactRow(slug, a) {
|
|
683
697
|
const key = FX.artKey(slug, a.path);
|
|
684
698
|
// `hasOwnProperty`, not `||`. An unlinked artifact records `null` under its key, and a falsy check
|
|
@@ -693,7 +707,7 @@ function artifactRow(slug, a) {
|
|
|
693
707
|
style: 'display:flex;gap:8px;align-items:center;width:100%;padding:4px 6px;margin:0 -6px;min-width:0;min-height:30px',
|
|
694
708
|
hover: 'background:var(--accent)',
|
|
695
709
|
},
|
|
696
|
-
el('span', { style: 'flex:none;width:52px;font-family:var(--mono);font-size:10px;letter-spacing:.1em;color:var(--info)' }, iv(a.role)),
|
|
710
|
+
el('span', { style: 'flex:none;width:52px;font-family:var(--mono);font-size:10px;letter-spacing:.1em;color:var(--info)' }, iv(roleChip(a.role))),
|
|
697
711
|
el('span', { style: 'font-family:var(--mono);font-size:10px;color:var(--muted-foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(a.path)),
|
|
698
712
|
);
|
|
699
713
|
|
|
@@ -795,6 +809,16 @@ function artifactRow(slug, a) {
|
|
|
795
809
|
const pillRow = el('div', { style: 'display:flex;gap:7px;align-items:center;flex-wrap:wrap;padding:3px 0 0 0' },
|
|
796
810
|
el('span', { style: 'font-family:var(--mono);font-size:10px;padding:1px 6px;background:var(--brand-wash);color:var(--brand)' },
|
|
797
811
|
'⇄ ', iv(dest.kind), ' · ', iv(dest.target)),
|
|
812
|
+
/* WHERE THE CLAIM CAME FROM. A destination read out of the file's own frontmatter is exactly as
|
|
813
|
+
* unverified as one the PM typed — PMOS has not looked at either — but it arrived without
|
|
814
|
+
* anybody in this window doing anything, so the row has to say so or it reads as something the
|
|
815
|
+
* app established. This region already lost that argument once: its predecessor existed because
|
|
816
|
+
* a row said `connected` about a record nobody had checked. Real mode only; the prototype has
|
|
817
|
+
* no such row, and the parity comparator measures the fixture surface against it. */
|
|
818
|
+
(!FIXTURE && dest.origin === 'frontmatter')
|
|
819
|
+
? el('span', { style: 'font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' },
|
|
820
|
+
'declared in the file · nothing has been checked')
|
|
821
|
+
: null,
|
|
798
822
|
el('button', {
|
|
799
823
|
onClick: () => setState((s) => ({ artSyncOpen: { ...s.artSyncOpen, [key]: !s.artSyncOpen[key] } })),
|
|
800
824
|
style: 'font-family:var(--mono);font-size:10.5px;padding:3px 10px;min-height:26px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
|
|
@@ -361,6 +361,84 @@ const scratchTa = el('textarea', {
|
|
|
361
361
|
onInput: (e) => onScratchInput(e.target.value),
|
|
362
362
|
});
|
|
363
363
|
|
|
364
|
+
/* ── the drawer's height ────────────────────────────────────────────────────────────────────
|
|
365
|
+
* 96px — four lines of the 12px mono face — was a literal in the style string above, and it was the
|
|
366
|
+
* pad's only size. The pad's job (see the block comment above) is to stage the next thing to type
|
|
367
|
+
* into a particular agent, and a staged instruction is routinely longer than four lines, so the PM
|
|
368
|
+
* composed in a box that scrolled internally while a large empty pane sat above it.
|
|
369
|
+
*
|
|
370
|
+
* The handle is REAL MODE ONLY. The parity comparator renders the app at `?fixture=proto` against
|
|
371
|
+
* the PM's own prototype and diffs text nodes, computed styles, geometry and pixels; the prototype
|
|
372
|
+
* has no such control, so drawing one in fixture mode would be a difference the comparator is right
|
|
373
|
+
* to report. This module already draws that line for the placeholder string, and for the same
|
|
374
|
+
* reason.
|
|
375
|
+
*
|
|
376
|
+
* The clamps are the two ways this could damage the surface it sits in. `SCRATCH_MIN` keeps two
|
|
377
|
+
* lines and the button row reachable — a drawer that can be dragged shut is a drawer with two
|
|
378
|
+
* different closed states, one of which has no way back. The 70% ceiling keeps the pane above from
|
|
379
|
+
* being driven to nothing, which matters more than it sounds: that pane is frequently a live PTY.
|
|
380
|
+
*
|
|
381
|
+
* NOTHING HERE TOUCHES THE PAD. Resizing does not read or write `state.scratchText`, does not call
|
|
382
|
+
* `syncPad` or `flushPad`, and cannot change `state.scratchKey`. The height is a property of the
|
|
383
|
+
* window, the text is a property of the session, and the two stores stay separate.
|
|
384
|
+
*/
|
|
385
|
+
const SCRATCH_MIN = 56;
|
|
386
|
+
const SCRATCH_DEFAULT = 96;
|
|
387
|
+
const SCRATCH_MAX_FRACTION = 0.7;
|
|
388
|
+
const SCRATCH_STEP = 16;
|
|
389
|
+
|
|
390
|
+
/** The tallest the pad may be right now: 70% of panel 4, never below the minimum. */
|
|
391
|
+
function scratchMax() {
|
|
392
|
+
const host = scratchBlock && scratchBlock.parentElement;
|
|
393
|
+
const h = host && host.clientHeight ? host.clientHeight : 0;
|
|
394
|
+
// No host to measure (the drawer is not mounted yet) — fall back to the viewport, and to a plain
|
|
395
|
+
// number if there is no window either, so this is safe to call from a test harness.
|
|
396
|
+
const basis = h || (typeof window !== 'undefined' && window.innerHeight) || 600;
|
|
397
|
+
return Math.max(SCRATCH_MIN, Math.round(basis * SCRATCH_MAX_FRACTION));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function clampScratchH(v) {
|
|
401
|
+
const n = Math.round(Number(v));
|
|
402
|
+
if (!Number.isFinite(n)) return SCRATCH_DEFAULT;
|
|
403
|
+
return Math.min(scratchMax(), Math.max(SCRATCH_MIN, n));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function setScratchH(v) {
|
|
407
|
+
const h = clampScratchH(v);
|
|
408
|
+
if (h === state.scratchH) return;
|
|
409
|
+
setState({ scratchH: h });
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let dragFrom = null; // {y, h} while the handle is held; null otherwise
|
|
413
|
+
|
|
414
|
+
const scratchGrip = el('div', {
|
|
415
|
+
tabindex: '0',
|
|
416
|
+
role: 'separator',
|
|
417
|
+
'aria-orientation': 'horizontal',
|
|
418
|
+
'aria-label': 'resize the scratchpad — drag, or ↑ / ↓',
|
|
419
|
+
title: 'drag to resize · ↑ / ↓ to nudge · double-click to reset',
|
|
420
|
+
style: 'flex:none;height:7px;cursor:ns-resize;background:oklch(0.11 0.004 228.8);'
|
|
421
|
+
+ 'border-top:1px solid oklch(1 0 0 / 10%);touch-action:none',
|
|
422
|
+
hover: 'background:oklch(1 0 0 / 10%)',
|
|
423
|
+
onPointerdown: (e) => {
|
|
424
|
+
dragFrom = { y: e.clientY, h: state.scratchH || SCRATCH_DEFAULT };
|
|
425
|
+
try { e.target.setPointerCapture(e.pointerId); } catch { /* not captured — the move still works */ }
|
|
426
|
+
e.preventDefault();
|
|
427
|
+
},
|
|
428
|
+
// Up is taller: the drawer grows toward the top edge the PM is pulling, which is the direction
|
|
429
|
+
// the handle itself moves. `clientY` decreases as the pointer rises, hence `from.y - clientY`.
|
|
430
|
+
onPointermove: (e) => { if (dragFrom) setScratchH(dragFrom.h + (dragFrom.y - e.clientY)); },
|
|
431
|
+
onPointerup: () => { dragFrom = null; },
|
|
432
|
+
onPointercancel: () => { dragFrom = null; },
|
|
433
|
+
onDblclick: () => setScratchH(SCRATCH_DEFAULT),
|
|
434
|
+
onKeydown: (e) => {
|
|
435
|
+
const h = state.scratchH || SCRATCH_DEFAULT;
|
|
436
|
+
if (e.key === 'ArrowUp') { setScratchH(h + SCRATCH_STEP); e.preventDefault(); }
|
|
437
|
+
else if (e.key === 'ArrowDown') { setScratchH(h - SCRATCH_STEP); e.preventDefault(); }
|
|
438
|
+
else if (e.key === 'Home') { setScratchH(SCRATCH_DEFAULT); e.preventDefault(); }
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
|
|
364
442
|
/* ── pad identity, loading and autosave ─────────────────────────────────────────────────────── */
|
|
365
443
|
|
|
366
444
|
const padLoading = new Set(); // keys with a read in flight — one read per key, ever
|
|
@@ -467,6 +545,7 @@ const scratchSendBtn = el('button', {
|
|
|
467
545
|
const scratchBlock = el('div', {
|
|
468
546
|
style: 'display:flex;flex-direction:column;border-top:1px solid oklch(1 0 0 / 10%)',
|
|
469
547
|
},
|
|
548
|
+
FIXTURE ? null : scratchGrip,
|
|
470
549
|
scratchTa,
|
|
471
550
|
el('div', { style: 'display:flex;gap:6px;padding:5px 12px 7px;background:oklch(0.11 0.004 228.8)' },
|
|
472
551
|
scratchCopyBtn, scratchSendBtn),
|
|
@@ -814,6 +893,14 @@ export function renderTabs(host) {
|
|
|
814
893
|
const scratchText = state.scratchText || '';
|
|
815
894
|
if (scratchTa.defaultValue !== scratchText) scratchTa.defaultValue = scratchText;
|
|
816
895
|
if (scratchTa.value !== scratchText) scratchTa.value = scratchText;
|
|
896
|
+
/* The drawer's height, real mode only. The style ATTRIBUTE keeps the prototype's string; only the
|
|
897
|
+
* resolved `height` property is written, so the fixture render — which never reaches here — stays
|
|
898
|
+
* byte-identical to the design. Written on every paint rather than only on resize, because the
|
|
899
|
+
* restored value has to land on the first one. */
|
|
900
|
+
if (!FIXTURE) {
|
|
901
|
+
const h = clampScratchH(state.scratchH || SCRATCH_DEFAULT) + 'px';
|
|
902
|
+
if (scratchTa.style.height !== h) scratchTa.style.height = h;
|
|
903
|
+
}
|
|
817
904
|
scratchCopyLabel.textContent = state.scratchCopied ? 'copied ✓' : '⧉ copy';
|
|
818
905
|
const canSend = !!(act && act.kind === 'term');
|
|
819
906
|
scratchSendLabel.textContent = canSend ? '⇥ send to terminal' : 'open a terminal to send';
|
|
@@ -88,6 +88,10 @@ function initial() {
|
|
|
88
88
|
newSessIsolate: null, newSessError: null, newSessBusy: false,
|
|
89
89
|
newSessBranches: null, preflightProfile: null, profiles: null, branchInfo: null,
|
|
90
90
|
col0: true, col1: true, col2: true, scratchOpen: false, scratchText: '', copied: false,
|
|
91
|
+
/* The scratchpad drawer's height in px (panel-tabs.js). 96 is the prototype's literal and stays
|
|
92
|
+
* the default; the PM can drag it, and the drag is clamped there rather than here — this store
|
|
93
|
+
* only has to refuse a value that could not have come from the clamp. */
|
|
94
|
+
scratchH: 96,
|
|
91
95
|
flowsMap: null, schedList: null, agentList: null, memory: null,
|
|
92
96
|
savedDocs: {}, scratchCopied: false,
|
|
93
97
|
/* The scratchpad is ONE PAD PER SESSION, not one per page (see panel-tabs.js). `scratchPads`
|
|
@@ -169,6 +173,12 @@ const PERSIST = {
|
|
|
169
173
|
active: isSlug,
|
|
170
174
|
splitId: isSlug,
|
|
171
175
|
scratchOpen: isBool,
|
|
176
|
+
/* A restored height is re-validated on the way IN, not merely on the way out: a corrupted or
|
|
177
|
+
* hand-edited record must not be able to render a drawer with no textarea in it, or one taller
|
|
178
|
+
* than any window it could be restored into. The upper bound here is deliberately generous (the
|
|
179
|
+
* real ceiling is 70% of panel 4, which this module cannot measure) — panel-tabs.js clamps the
|
|
180
|
+
* value again against the live panel before it reaches the DOM. */
|
|
181
|
+
scratchH: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 56 && v <= 4000,
|
|
172
182
|
};
|
|
173
183
|
|
|
174
184
|
/** What is WRITTEN for a key, when it is not the live value verbatim. */
|
package/workspace/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|