shapeup-sdlc 3.7.7 → 3.7.8

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "shapeup-sdlc-plugin",
3
3
  "displayName": "ShapeUp SDLC Plugin",
4
- "version": "3.7.7",
4
+ "version": "3.7.8",
5
5
  "description": "Shape Up SDLC harness for Claude Code: shaping, intake, orient, scope-mapping, building (T0-verified, sandboxed, scope-contracted), evaluation and QA skills orchestrated by a tech-lead.",
6
6
  "author": {
7
7
  "name": "Liberty Nguyen",
@@ -26,19 +26,20 @@
26
26
  // pretty-printed envelope, colocated so audits can read it). Prints the path on stdout.
27
27
 
28
28
  import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from "node:fs";
29
+ import { discover, resolve as resolveGate, appendGateLedger, PRESETS } from "./gate.mjs";
29
30
  import { resolve, join, dirname, basename, relative, sep } from "node:path";
30
31
  import { fileURLToPath } from "node:url";
31
32
  import { validate } from "./verify/envelope.mjs";
32
33
  import { readTrials } from "./verify/t0.mjs";
33
34
  import { runArgs } from "./lib/argv.mjs";
34
- import { readRunId, dispatchReceipts, legLedger } from "./lib/paths.mjs";
35
+ import { readRunId, dispatchReceipts, legLedger, readReceipt, receipt } from "./lib/paths.mjs";
35
36
  // `specDir` is aliased: this module has a local `let specDir` holding the resolved, possibly
36
37
  // --spec-overridden directory, and the import is the convention-derived default.
37
38
  import {
38
39
  tasksDir, specDir as defaultSpecDir, roundLedger, trials, verdictsDir, ordersDir,
39
40
  relShared, relLocal, globLocal, globShared, relKnowledgeBase, resultsDir, scopesDir,
40
41
  } from "./lib/paths.mjs";
41
- import { readContract, readAllContracts, tasksForScope, SCOPE_CONTRACT } from "./lib/contract.mjs";
42
+ import { readContract, readAllContracts, tasksForScope, SCOPE_CONTRACT, reqId } from "./lib/contract.mjs";
42
43
  import { writeActiveOrder } from "./probe/resume.mjs";
43
44
  import { greenVerdict } from "./probe/t0.mjs";
44
45
  import { attemptEvidence, readReceipts } from "./probe/attempts.mjs";
@@ -100,14 +101,27 @@ export function parseTaskFile(path) {
100
101
  const body = readFileSync(path, "utf8");
101
102
  const fm = frontmatter(body);
102
103
  const acceptance_criteria = [];
103
- for (const line of body.split(/\r?\n/)) {
104
- const m = line.match(/^\s*- \[[ x]\]\s+(.*)$/);
104
+ // A `(covers: …)` clause is read across the WHOLE bullet — the checkbox line and the indented
105
+ // continuation lines under it — and each token folds through the one key helper. The clause was
106
+ // scanned on the checkbox line alone, so a board whose every AC carried one on its continuation
107
+ // line projected as a board carrying none: the requirements matrix printed "no evidence" for
108
+ // clauses that had a PASS criterion anchored to them. Measured live. `text` stays byte-identical
109
+ // to the checkbox line, because ingest ticks the box by matching it back.
110
+ const lines = body.split(/\r?\n/);
111
+ for (let i = 0; i < lines.length; i++) {
112
+ const m = lines[i].match(/^\s*- \[[ x]\]\s+(.*)$/);
105
113
  if (!m) continue;
106
- const text = m[1].trim(); // byte-identical to the checkbox — ingest ticks by matching it back
107
- // Additive covers-closure anchor (spine v1.3): a trailing `(covers: REQ-3, REQ-7)` clause on
108
- // the AC line yields {text, covers}; a plain line stays a string (non-regression on legacy boards).
109
- const cov = text.match(/\(covers:\s*([^)]*)\)/i);
110
- const covers = cov ? cov[1].split(",").map((s) => s.trim()).filter((s) => /^REQ-\d+$/.test(s)) : [];
114
+ const text = m[1].trim();
115
+ let block = text;
116
+ for (let j = i + 1; j < lines.length; j++) {
117
+ const l = lines[j];
118
+ if (!l.trim() || /^\s*[-*+]\s/.test(l) || /^#/.test(l) || !/^\s/.test(l)) break;
119
+ block += "\n" + l;
120
+ }
121
+ const covers = [];
122
+ for (const cov of block.matchAll(/\(covers:\s*([^)]*)\)/gi)) {
123
+ for (const raw of cov[1].split(",")) { const id = reqId(raw); if (/^REQ-\d+$/.test(id) && !covers.includes(id)) covers.push(id); }
124
+ }
111
125
  acceptance_criteria.push(covers.length ? { text, covers } : text);
112
126
  }
113
127
  return {
@@ -929,6 +943,28 @@ export async function cli(rawArgv) {
929
943
  || (operation ? OP_OWNER[operation] : null);
930
944
  if (!worker || !operation) { console.error("compile-order: could not resolve --worker/--operation"); process.exit(2); }
931
945
 
946
+ // GATE COACH-1 HAS A DETERMINISTIC CALL SITE: THE COACH DISPATCH. The gate asks whether a PO is
947
+ // present to categorize feedback; its answer used to be a prose instruction inside the coach
948
+ // skill, so an unattended lane could dispatch a coach nobody would answer and no row recorded the
949
+ // decision. Compiling the coach order resolves it with the run's own answer set and records the
950
+ // row; `skip` refuses the order, which is what "no live PO" means. `ask` compiles it — the
951
+ // categorization conversation the coach then holds IS the answer.
952
+ if (operation === "coach") {
953
+ const ga = readReceipt(receipt(cwd, slug))?.config?.gate_answers ?? null;
954
+ const presetName = ga && PRESETS[ga] ? ga : null;
955
+ let found = discover({ cwd, slug, preset: presetName, file: ga && !presetName ? ga : null });
956
+ if (found.error) found = { set: PRESETS.interactive, source: "preset:interactive (no answer set on disk)" };
957
+ const r = resolveGate(found.set, "COACH-1", found.source);
958
+ appendGateLedger(cwd, slug, {
959
+ at: new Date().toISOString(), run_id: readRunId(cwd, slug), gate: "COACH-1", status: r.status,
960
+ decision: r.decision ?? null, source: r.source ?? found.source, note: r.note ?? r.reason ?? null, round: null,
961
+ });
962
+ if (r.decision === "skip") {
963
+ console.error(`compile-order: GATE COACH-1 resolved "skip" (${r.source ?? found.source}) — no live PO to categorize feedback, so no coach order is compiled. The decision is on the gate ledger.`);
964
+ process.exit(3);
965
+ }
966
+ }
967
+
932
968
  // Task selection.
933
969
  let tasks;
934
970
  const board = readBoard(cwd, slug);
@@ -70,6 +70,7 @@
70
70
  // one moment it mattered named a mechanism that does not parse.
71
71
 
72
72
  import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, copyFileSync, rmSync, statSync } from "node:fs";
73
+ import { discover, resolve as resolveGate, appendGateLedger, PRESETS } from "../gate.mjs";
73
74
  import { join, dirname, resolve, relative, sep } from "node:path";
74
75
  import { createHash } from "node:crypto";
75
76
  import { decideLane, treeSize } from "./fit.mjs";
@@ -671,6 +672,29 @@ export function cli(rawArgv) {
671
672
  mkdirSync(dirname(pointer), { recursive: true });
672
673
  writeFileSync(pointer, JSON.stringify({ slug, started_at: startedAt }, null, 2) + "\n", "utf8");
673
674
 
675
+ // GATE L0 HAS A DETERMINISTIC CALL SITE: THE RUN'S OPENING. It used to be a line of prose the
676
+ // tech lead was asked to act on, and the same consumer ledgered L0 on one run and not the next.
677
+ // The intake conversation is the L0 decision; opening the run is the act that records it, with
678
+ // the answer set the run was configured with (a preset name or a file), else the interactive
679
+ // defaults. Best-effort: a row that cannot be written must not fail the opening.
680
+ try {
681
+ const ga = config.gate_answers ?? null;
682
+ const presetName = ga && PRESETS[ga] ? ga : null;
683
+ const found = discover({ cwd, slug, preset: presetName, file: ga && !presetName ? ga : null });
684
+ // A preset or file answers L0 for the run; a run with neither — the interactive lane, where the
685
+ // tech lead held the intake conversation before opening it — has that conversation as its L0
686
+ // decision, and the opening records it as such. An answer set that says `ask` is recorded as
687
+ // `ask`: the row states what the set said, and the intake note says what happened.
688
+ const r = found.error
689
+ ? { status: "ok", decision: "proceed", source: "intake (no answer set on disk)", note: "the intake conversation is the L0 decision" }
690
+ : resolveGate(found.set, "L0", found.source);
691
+ appendGateLedger(cwd, slug, {
692
+ at: startedAt, run_id: receipt.run_id, gate: "L0", status: r.status, decision: r.decision ?? null,
693
+ source: r.source ?? found.source, note: `${r.note ?? r.reason ?? ""} — run opened: intake recorded, receipt written`.replace(/^ — /, ""),
694
+ round: null,
695
+ });
696
+ } catch { /* the ledger row is a record of the opening, never a condition of it */ }
697
+
674
698
  console.log(JSON.stringify({
675
699
  ok: true,
676
700
  slug,
@@ -30,7 +30,7 @@
30
30
  import { existsSync, readdirSync, readFileSync } from "node:fs";
31
31
  import { join, resolve } from "node:path";
32
32
  import { runArgs } from "../lib/argv.mjs";
33
- import { dispatchReceipts, legLedger, resultsDir, readRunId } from "../lib/paths.mjs";
33
+ import { dispatchReceipts, legLedger, resultsDir, readRunId, ordersDir } from "../lib/paths.mjs";
34
34
  import { readLegs } from "./leg.mjs";
35
35
  import { greenVerdict } from "./t0.mjs";
36
36
 
@@ -84,7 +84,16 @@ export function attemptEvidence(cwd, slug, scopeId, round, attempt, receipts, le
84
84
  // so this stays a file check and is deliberately NOT sufficient on its own. It can only turn an
85
85
  // already run-scoped receipt into `spent`; a result left behind by an earlier run cannot attest
86
86
  // an attempt this run never dispatched.
87
- const hasResult = existsSync(join(resultsDir(cwd, slug), `${scopeId}-r${round}-a${attempt}.json`));
87
+ // A WorkResult carries no run key of its own; it answers THIS run's attempt only through the
88
+ // order of the same name, which this run's compile rewrote. A result left by a prior run over the
89
+ // same slug used to close an attempt this run had not even opened.
90
+ const stem = `${scopeId}-r${round}-a${attempt}.json`;
91
+ let orderIsMine = true;
92
+ if (runId != null) {
93
+ try { const o = JSON.parse(readFileSync(join(ordersDir(cwd, slug), stem), "utf8")); orderIsMine = !o?.run_id || o.run_id === runId; }
94
+ catch { orderIsMine = false; }
95
+ }
96
+ const hasResult = orderIsMine && existsSync(join(resultsDir(cwd, slug), stem));
88
97
  const state = !hasReceipt ? "unattested" : (hasResult || hasLeg) ? "spent" : "in-flight";
89
98
  return { orderId, hasReceipt, hasResult, hasLeg, state };
90
99
  }
@@ -16,7 +16,7 @@
16
16
  import { existsSync, readdirSync, readFileSync } from "node:fs";
17
17
  import { join, resolve } from "node:path";
18
18
  import { runArgs } from "../lib/argv.mjs";
19
- import { verdictsDir } from "../lib/paths.mjs";
19
+ import { verdictsDir, readRunId } from "../lib/paths.mjs";
20
20
 
21
21
  /**
22
22
  * Verdict filenames, newest first by their NUMERIC address.
@@ -55,10 +55,15 @@ export function greenVerdict(cwd, slug, scopeId, round) {
55
55
  if (!existsSync(dir)) return { green: false, path: null };
56
56
  // Newest first: an attempt retried after a red one writes a higher trial ordinal at the same
57
57
  // (round, attempt) address, and the LAST verdict is the one that stands.
58
+ // THIS RUN'S VERDICTS. Verdicts over one slug accumulate across runs and carry the run's key; a
59
+ // second run used to be told its scope was green on the first run's artifact, and the round loop
60
+ // skipped building it. A verdict with no key at all predates the key and is kept.
61
+ const runId = readRunId(cwd, slug);
58
62
  for (const f of newestFirst(readdirSync(dir).filter((x) => x.endsWith(".json")))) {
59
63
  const p = join(dir, f);
60
64
  try {
61
65
  const b = JSON.parse(readFileSync(p, "utf8"));
66
+ if (runId && b.run_id && b.run_id !== runId) continue;
62
67
  if (b.scope_id === scopeId && (round == null || b.round === round) && b.overall === "green") return { green: true, path: p };
63
68
  } catch { /* a torn artifact proves nothing; keep looking */ }
64
69
  }
@@ -33,7 +33,7 @@ import {
33
33
  localRoot, receipt as receiptPath, ordersDir, resultsDir, verdictsDir, trials as trialsPath, gates as gatesPath, scopesDir, usecasesDir, requirements as requirementsPath, wiringMap as wiringMapPath, legLedger,
34
34
  } from "../lib/paths.mjs";
35
35
  import { readAllContracts, readContract, ucId, reqId, SCOPE_CONTRACT, WIRING_MAP } from "../lib/contract.mjs";
36
- import { runIdFromReceipt } from "../lib/paths.mjs";
36
+ import { runIdFromReceipt, readRunId } from "../lib/paths.mjs";
37
37
 
38
38
  /** The graph's home — one file per feature, beside the run trace it projects. */
39
39
  export const graphPath = (cwd, slug) => join(localRoot(cwd, slug), "graph.jsonl");
@@ -384,7 +384,16 @@ export function appendGraph(cwd, slug) {
384
384
  export function runSubgraph(cwd, slug) {
385
385
  const { nodes, edges, lines } = readGraph(cwd, slug);
386
386
  const of = (t) => [...nodes.values()].filter((n) => n.t === t);
387
- const orders = of("Order"), results = of("Result"), verdicts = of("Verdict");
387
+ // ONE RUN'S SUBGRAPH. The graph is append-only over a slug and every run of it lands there; this
388
+ // query used to aggregate every run's verdicts into `green_scopes_by_round` and report the FIRST
389
+ // run ever recorded as `run`, so a relaunch skipped scopes a prior run had built. Work nodes carry
390
+ // the run key and are filtered on it; a node with no key predates the key and is kept; a Result
391
+ // has no key of its own and belongs to the run its Order does.
392
+ const runId = readRunId(cwd, slug);
393
+ const mine = (n) => !runId || !n.run_id || n.run_id === runId;
394
+ const orders = of("Order").filter(mine), verdicts = of("Verdict").filter(mine);
395
+ const orderIds = new Set(orders.map((o) => o.order_id));
396
+ const results = of("Result").filter((r) => !runId || orderIds.has(r.order_id));
388
397
  const resultIds = new Set(results.map((r) => r.order_id));
389
398
  const ingestedFrom = new Set([...edges.values()].filter((e) => e.t === "INGESTED").map((e) => e.from));
390
399
  const greenByRound = {};
@@ -394,7 +403,7 @@ export function runSubgraph(cwd, slug) {
394
403
  }
395
404
  return {
396
405
  graph_lines: lines,
397
- run: of("Run")[0]?.run_id ?? null,
406
+ run: runId ?? of("Run")[0]?.run_id ?? null,
398
407
  scopes: of("Scope").map((s) => s.scope_id).sort(),
399
408
  use_cases: of("UseCase").map((u) => u.use_case).sort(),
400
409
  requirements: of("Requirement").map((r) => r.req_id).sort(),
@@ -6,7 +6,7 @@
6
6
  import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from "node:fs";
7
7
  import { resolve, join } from "node:path";
8
8
  import { runArgs } from "../lib/argv.mjs";
9
- import { scopesDir, hillDir, verdictsDir, resultsDir, discoveryLedger, localRoot } from "../lib/paths.mjs";
9
+ import { scopesDir, hillDir, verdictsDir, resultsDir, discoveryLedger, receipt, readRunId } from "../lib/paths.mjs";
10
10
  import { readAllContracts, SCOPE_CONTRACT } from "../lib/contract.mjs";
11
11
  import { evalVerdict } from "../probe/eval.mjs";
12
12
  import { redBuildRounds } from "../verify/build.mjs";
@@ -190,9 +190,15 @@ export function deriveHill(cwd, slug) {
190
190
  // and reports what they currently support, in both directions. A guard phrased as "never lower a
191
191
  // phase" would quietly turn a derived value into a high-water mark, which is a different defect
192
192
  // wearing this one's clothes. The condition is the narrowest one that is positively provable:
193
- // the tier holding every input is not there.
193
+ // the RUN is not there — its receipt, the record every run's first act writes. The condition used
194
+ // to be the local root's existence, and any single file satisfies that: `reduce graph` creates
195
+ // `graph.jsonl` under it as a side effect, so a committed-only checkout that ran graph and then
196
+ // hill had its FINISHED shards flattened to UPHILL_UNKNOWN, exit 0, no warning. A backstop whose
197
+ // condition another command satisfies is a backstop only in the order nobody varied.
194
198
  // -------------------------------------------------------------------------------------------
195
- if (!existsSync(localRoot(cwd, slug))) {
199
+ // Evidence the derivation actually needs: the run's receipt, or the T0 verdicts it reads. A
200
+ // `graph.jsonl` alone is neither.
201
+ if (!existsSync(receipt(cwd, slug)) && !existsSync(verdictsDir(cwd, slug))) {
196
202
  return scopes.map((s) => ({
197
203
  scope_id: s.scope_id,
198
204
  phase: committedPhase(hDir, s.scope_id),
@@ -235,11 +241,14 @@ export function deriveHill(cwd, slug) {
235
241
  // verdict counting exactly as before.
236
242
  const redRounds = redBuildRounds(cwd, slug);
237
243
  const t0Facts = {};
244
+ // This run's verdicts only — a prior run's green over the same slug moved this run's dot.
245
+ const hillRunId = readRunId(cwd, slug);
238
246
  if (existsSync(vDir)) {
239
247
  for (const f of readdirSync(vDir)) {
240
248
  if (!f.endsWith(".json")) continue;
241
249
  try {
242
250
  const b = JSON.parse(readFileSync(join(vDir, f), "utf8"));
251
+ if (hillRunId && b.run_id && b.run_id !== hillRunId) continue;
243
252
  if (!t0Facts[b.scope_id]) t0Facts[b.scope_id] = { hasGreen: false, seesawGreen: false };
244
253
  if (b.overall === "green" && !redRounds.has(Number(b.round))) {
245
254
  t0Facts[b.scope_id].hasGreen = true;
@@ -42,7 +42,7 @@ import { join, resolve } from "node:path";
42
42
  import { spawnSync } from "node:child_process";
43
43
  import { createHash } from "node:crypto";
44
44
  import { runArgs, isMain } from "../lib/argv.mjs";
45
- import { harnessRun, projectProfile, roundBuildDir, localRoot, runIdFromRoot } from "../lib/paths.mjs";
45
+ import { harnessRun, projectProfile, roundBuildDir, localRoot, runIdFromRoot, readRunId } from "../lib/paths.mjs";
46
46
  import { readContract, readAllContracts, PROJECT_PROFILE, SCOPE_CONTRACT, splitFrontmatter } from "../lib/contract.mjs";
47
47
  import { scopesDir } from "../lib/paths.mjs";
48
48
  import { digest } from "../probe/digest.mjs";
@@ -231,9 +231,15 @@ export function redBuildRounds(cwd, slug) {
231
231
  const latest = new Map();
232
232
  let files;
233
233
  try { files = readdirSync(roundBuildDir(cwd, slug)); } catch { return new Set(); }
234
+ // This run's gates only: a prior run's red round over the same slug must not hold this run's dot.
235
+ const runId = readRunId(cwd, slug);
234
236
  for (const f of files) {
235
237
  const m = f.match(/^r(\d+)-t(\d+)\.json$/);
236
238
  if (!m) continue;
239
+ if (runId) {
240
+ try { const g = JSON.parse(readFileSync(join(roundBuildDir(cwd, slug), f), "utf8")); if (g?.run_id && g.run_id !== runId) continue; }
241
+ catch { continue; }
242
+ }
237
243
  const round = Number(m[1]), trial = Number(m[2]);
238
244
  if (!latest.has(round) || latest.get(round).trial < trial) latest.set(round, { trial, file: f });
239
245
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shapeup-sdlc",
3
- "version": "3.7.7",
3
+ "version": "3.7.8",
4
4
  "description": "Shape Up for coding agents \u2014 with gates the agent can't talk its way past. Harness for Claude Code.",
5
5
  "bin": {
6
6
  "shapeup-sdlc": "bin/init.mjs"
@@ -117,7 +117,7 @@ fields nobody used" → "Prefer the minimum DTO that satisfies the AC; don't add
117
117
  fields"). Keep the originating why — a rule without its reason gets ignored or misapplied.
118
118
 
119
119
  ### Step 2 — ⏸ GATE COACH-1: Categorize (ASK, never assume)
120
- This is the load-bearing gate. **Resolve it first** — `node
120
+ This is the load-bearing gate. Dispatched with an order, it is already resolved and on the gate ledger: compiling a coach order crosses COACH-1 with the run's answer set and refuses the order on `skip`, so an order in your hands means `ask`. Invoked standalone, **resolve it first** — `node
121
121
  "${CLAUDE_PLUGIN_ROOT}/kernel/harness.mjs" gate --resolve COACH-1 --slug <slug>
122
122
  [--file <path>|--preset <name>]` — so the ledger carries a row for the decision this gate makes,
123
123
  same as every other gate in the run. Exit 0 (`decision=skip`) — an unattended lane with no live PO;