create-pmos 0.30.1 → 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 CHANGED
@@ -333,6 +333,24 @@ 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
+
336
354
  ## What's new in 0.30.1
337
355
 
338
356
  **Open a markdown file in the workspace and you get the document, not its source. And scrolling
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.30.1",
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 — it generates no application
14
- code by itself; you build the product, PMOS disciplines the loop.
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
 
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@f5b480a
1
+ kernel-source: pmos@fd42042
2
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" is the load-bearing identity
12
- claim of the whole product, and it is easy to forget mid-build. This concept fixes what that claim
13
- means so design decisions stay on the right side of the line. It informs every artifact: if a thing
14
- generates production app code, it is not PMOS's job.
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';
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.30.1",
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",
@@ -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 deliberately NOT derived here: κ labels
4648
- // are the PM's own word (D40 — never manufacture calibration data from a UI click).
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
- return json(res, { ok: true, recorded: rec, sink, fallback });
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