shapeup-sdlc 3.3.0 → 3.4.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "shapeup-sdlc-plugin",
3
3
  "displayName": "ShapeUp SDLC Plugin",
4
- "version": "3.3.0",
4
+ "version": "3.4.0",
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",
@@ -172,7 +172,16 @@ export function coerce(raw) {
172
172
  // A LIST IS TESTED BEFORE THE QUOTES ARE STRIPPED. `"[a, b]"` is a quoted STRING; stripping first
173
173
  // would turn it into a list and change its type on a round-trip.
174
174
  if (/^\[.*\]$/.test(trimmed)) return splitList(trimmed.slice(1, -1));
175
+ // A QUOTED SCALAR IS A STRING, VERBATIM — the same rule the list test above already applies, one
176
+ // step further in. Unquoting FIRST and then testing the bareword literals meant the quotes bought
177
+ // nothing: `"false"` unwrapped to `false` and then matched the boolean test, so a cell whose value
178
+ // is genuinely the word "false" re-read as the boolean. Same for `"true"`, `"123"`, `"~"` and `""`
179
+ // — a string changed TYPE on a round trip, silently, the first time its contract was rewritten.
180
+ // Quoting is how an author says "this is text"; honouring that is what makes the round trip total.
181
+ const quoted = trimmed.length >= 2 && (trimmed[0] === '"' || trimmed[0] === "'")
182
+ && trimmed[trimmed.length - 1] === trimmed[0];
175
183
  const v = unquote(trimmed);
184
+ if (quoted) return v;
176
185
  if (v === "true") return true;
177
186
  if (v === "false") return false;
178
187
  if (v === "~" || v === "null" || v === "") return null;
@@ -192,10 +201,17 @@ export function uncoerce(v) {
192
201
  // side instead of the reader's.
193
202
  if (Array.isArray(v)) return `[${v.map((x) => (/[,"]/.test(String(x)) ? JSON.stringify(String(x)) : String(x))).join(", ")}]`;
194
203
  const s = String(v);
195
- // A scalar that itself begins AND ends with a quote is indistinguishable, once written, from a
196
- // quoted scalar — so emit it in the JSON form the reader unwraps exactly. Without this the round
197
- // trip loses the value's own outer quotes, which is the same shredding as the reader's half.
198
- if (s.length >= 2 && (s[0] === '"' || s[0] === "'") && s[s.length - 1] === s[0]) return JSON.stringify(s);
204
+ // THE READER IS THE ORACLE. Anything whose plain text would come back as a DIFFERENT VALUE goes
205
+ // out quoted — and the only honest test of that is to ask `coerce` itself.
206
+ //
207
+ // This subsumes the older rule (a scalar that begins AND ends with a quote) and closes the family
208
+ // it missed: a string whose text is a bareword literal changed TYPE on a round trip. The string
209
+ // "false" re-read as the boolean false, "123" as the number 123, "[a, b]" as a two-member list,
210
+ // "~" as null. Each is a value an author can legitimately write in a cell, and each silently
211
+ // became something else the first time the contract was rewritten. `coerce(s) !== v` catches all
212
+ // of them at once and leaves every value that already round-trips untouched — a path, a sentence,
213
+ // a real boolean, a real number.
214
+ if (coerce(s) !== v) return JSON.stringify(s);
199
215
  return s;
200
216
  }
201
217
 
@@ -68,7 +68,11 @@ const listField = (fm, key) => {
68
68
  */
69
69
  export function parseBoard(tasksDir) {
70
70
  if (!existsSync(tasksDir)) return [];
71
- return readdirSync(tasksDir)
71
+ // SORTED, because `criticalPath` breaks ties on strict `>` and therefore keeps the FIRST chain it
72
+ // meets among equal-hours chains. Directory order is a filesystem detail (APFS happens to return
73
+ // sorted; ext4's hash order does not, and neither does a rename), so an unsorted read made a
74
+ // derived value depend on which machine ran it. Every sibling reader in the kernel already sorts.
75
+ return readdirSync(tasksDir).sort()
72
76
  .filter((f) => /^TASK-[\w.-]+\.md$/i.test(f))
73
77
  .map((f) => {
74
78
  const body = readFileSync(join(tasksDir, f), "utf8");
@@ -130,10 +134,30 @@ export function criticalPath(tasks) {
130
134
  }
131
135
  return (memo[id] = { hours: best.hours + t.hours, chain: [...best.chain, id] });
132
136
  };
137
+ // TIES BREAK ON CONTENT, NOT ON ARRIVAL. `>` alone keeps whichever equal-hours chain is MET
138
+ // FIRST, which is input order — so this function returned a different critical path for the same
139
+ // board depending only on how its task list happened to be ordered. Measured: two disjoint
140
+ // 5-hour chains, five permutations of one list, two different answers.
141
+ //
142
+ // Sorting the reader that feeds it (`parseBoard`) makes the input stable and therefore hides this
143
+ // on any one machine, but it leaves the ORDER-DEPENDENCE in place one call up — a caller with its
144
+ // own ordering, or a future second reader, re-opens it. A derived value has to be a function of
145
+ // the board, not of the walk that produced it, so the tie is resolved here: same hours, then the
146
+ // lexicographically smaller chain. Deterministic on every machine and for every caller.
147
+ /**
148
+ * Is chain `c` a better critical path than the incumbent `b`?
149
+ * @param {{hours:number, chain:string[]}} c - The candidate chain.
150
+ * @param {{hours:number, chain:string[]}} b - The incumbent best.
151
+ * @returns {boolean} True when `c` has more hours, or ties on hours and sorts first by chain
152
+ * content — so the answer is a function of the board rather than of the iteration order.
153
+ */
154
+ const better = (c, b) => c.hours > b.hours
155
+ || (c.hours === b.hours && c.chain.length > 0
156
+ && (b.chain.length === 0 || c.chain.join("\u0000") < b.chain.join("\u0000")));
133
157
  let best = { hours: 0, chain: [] };
134
158
  for (const t of tasks) {
135
159
  const c = longest(t.id);
136
- if (c.hours > best.hours) best = c;
160
+ if (better(c, best)) best = c;
137
161
  }
138
162
  return best;
139
163
  }
@@ -43,7 +43,7 @@ export const WORK_NODES = ["Run", "Order", "Result", "Verdict", "Trial", "GateDe
43
43
  export const DOMAIN_NODES = ["Scope", "UseCase", "Requirement", "Seam"];
44
44
 
45
45
  /** Edge types. Each names a direction that is meaningful to read backwards. */
46
- export const EDGES = ["PRODUCED", "EVALUATES", "SUPERSEDES", "COVERS", "DEPENDS_ON", "DERIVED_FROM"];
46
+ export const EDGES = ["PRODUCED", "EVALUATES", "SUPERSEDES", "COVERS", "DEPENDS_ON", "DERIVED_FROM", "IMPLEMENTS"];
47
47
 
48
48
  /**
49
49
  * Read the graph as a log and fold it into nodes and edges.
@@ -63,7 +63,10 @@ export function readGraph(cwd, slug) {
63
63
  let row;
64
64
  try { row = JSON.parse(line); } catch { continue; } // a torn line proves nothing; skip it
65
65
  lines++;
66
- if (row.k === "node" && row.id) nodes.set(row.id, { ...(nodes.get(row.id) || {}), ...row });
66
+ // LAST LINE WINS, as the banner says — a REPLACE, not a merge. Merging kept attributes from
67
+ // superseded lines alive forever, so an attribute removed from an artifact survived in the
68
+ // projection and a rebuilt graph stopped matching the maintained one.
69
+ if (row.k === "node" && row.id) nodes.set(row.id, row);
67
70
  else if (row.k === "edge" && row.from && row.to && row.t) edges.set(`${row.from}|${row.t}|${row.to}`, row);
68
71
  }
69
72
  return { nodes, edges, lines };
@@ -194,16 +197,19 @@ export function project(cwd, slug) {
194
197
  source: g.source ?? null, note: g.note ?? null, round: g.round ?? null,
195
198
  run_id: g.run_id ?? runId ?? null,
196
199
  });
197
- // A round-scoped gate's decision depends on that round's T0 verdict(s) — the most honest
198
- // shape, since that is the evidence the decision was made against. Every other gate (and a
199
- // round-scoped one with no verdict yet on disk) depends on the Run instead, so no
200
- // `GateDecision` node is ever orphaned.
200
+ // EVERY gate depends on the Run, UNCONDITIONALLY — and a round-scoped one ALSO depends on that
201
+ // round's T0 verdict(s), the evidence it was decided against.
202
+ //
203
+ // The Run edge used to be an `else` FALLBACK, and a conditional edge TARGET cannot survive an
204
+ // append-only log. Gates are crossed BEFORE the round's verdict artifact lands, so the first
205
+ // projection minted `DEPENDS_ON run` and a later one added the verdict edges beside it —
206
+ // and `appendGraph` has no tombstone, so the fallback became permanent. The incremental graph
207
+ // then carried an edge a rebuild does not imply, breaking the one property this file exists to
208
+ // promise: that it can be deleted and rebuilt identically. Unconditional is also truer — a gate
209
+ // does depend on its run. The fix is not a guard; it is removing the condition.
210
+ if (runNode) edge(id, "DEPENDS_ON", runNode);
201
211
  const roundVerdicts = (g.gate === "L2" || g.gate === "L3") ? verdictIdsByRound.get(g.round) : null;
202
- if (roundVerdicts?.length) {
203
- for (const vid of roundVerdicts) edge(id, "DEPENDS_ON", vid);
204
- } else if (runNode) {
205
- edge(id, "DEPENDS_ON", runNode);
206
- }
212
+ for (const vid of roundVerdicts || []) edge(id, "DEPENDS_ON", vid);
207
213
  }
208
214
  }
209
215
 
@@ -221,7 +227,15 @@ export function project(cwd, slug) {
221
227
  // projected to two nodes. `--trace` is supposed to reach "the execution record"; it reached
222
228
  // whichever scope happened to be written last. `baseline_trial` is chosen from the same
223
229
  // scope's prior rows, so the SUPERSEDES edge resolves inside the same partition.
224
- const trialKey = (n) => `trial:${slug}:${t.scope_id ? `${t.scope_id}:` : ""}${n}`;
230
+ //
231
+ // AND THE RUN IS PART OF THE KEY TOO, for the same reason one step out. A trial ordinal
232
+ // restarts at 1 in the next run while `trials.jsonl` is APPEND-ONLY, so both runs' rows live
233
+ // on disk together and `trial:<slug>:<scope>:1` named two of them — the second run silently
234
+ // overwrote the first run's execution record, which is the identical defect the paragraph
235
+ // above records fixing once already, one key component short. `baseline_trial` is chosen from
236
+ // the same run's rows, so SUPERSEDES still resolves inside the partition.
237
+ const trialRun = t.run_id ?? runId ?? "norun";
238
+ const trialKey = (n) => `trial:${slug}:${trialRun}:${t.scope_id ? `${t.scope_id}:` : ""}${n}`;
225
239
  const id = trialKey(t.trial);
226
240
  node(id, "Trial", {
227
241
  trial: t.trial, round: t.round ?? null, attempt: t.attempt ?? null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shapeup-sdlc",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
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"
@@ -1193,6 +1193,17 @@ await advisory(`reduce hill --slug ${slug}`, "MapScopes", "hill-derive");
1193
1193
  const lastEval = rs.eval_rounds_done?.length ? Math.max(...rs.eval_rounds_done) : 0;
1194
1194
  let round = lastEval + 1;
1195
1195
  let verdict = null;
1196
+ // DERIVED FROM DISK AT EVERY LAUNCH, never accumulated only in memory. These two lists ARE GATE H's
1197
+ // census: `scope-hammer` is dispatched with `hammer_proposals`, and AGENTS.md makes a scope that
1198
+ // exhausted its attempt budget a queued GATE H proposal. As bare `const []` they were emptied by the
1199
+ // one event that most needs them intact — a gate PAUSE is a `return`, so the PO's answer is followed
1200
+ // by a FRESH LAUNCH whose accumulators start empty and whose round loop is then fast-forwarded past
1201
+ // the rounds that filled them. The PO was handed an empty cut list for a run that had genuinely
1202
+ // exhausted scopes. Unattended (`ci`) runs never pause, which is why no archived trace shows it.
1203
+ //
1204
+ // This file has now paid for the same class three times — `findings` in the temporal dead zone and
1205
+ // `payload.bugs` "lived in a variable, which a relaunch between two rounds resets to empty" are the
1206
+ // other two. The cure is the same each time and it is not a bigger variable: re-derive the fact.
1196
1207
  const allGreen = [];
1197
1208
  const allHammer = [];
1198
1209
  // OUTSIDE the loop, because its whole purpose is to cross a round boundary: round r's verdict is
@@ -1248,6 +1259,16 @@ while (verdict !== "pass" && round <= maxRounds) {
1248
1259
  const alreadyGreen = new Set(g?.green_scopes_by_round?.[String(round)] || []);
1249
1260
  if (alreadyGreen.size) log(`BUILD r${round} — ${alreadyGreen.size} scope(s) already green in the graph, skipping them`);
1250
1261
 
1262
+ // REBUILD THE CENSUS FROM THE GRAPH THIS QUERY JUST RETURNED. Everything a prior launch learned is
1263
+ // already on disk: a scope green in ANY round is green work, and a scope the run has touched but
1264
+ // never got green is what GATE H has to be shown. One query, already made, re-read — so a relaunch
1265
+ // after a paused gate carries the same census the launch that paused it would have.
1266
+ const greenEver = new Set(Object.values(g?.green_scopes_by_round || {}).flat());
1267
+ for (const sid of greenEver) if (!allGreen.includes(sid)) allGreen.push(sid);
1268
+ for (const sid of (g?.scopes || [])) {
1269
+ if (!greenEver.has(sid) && !allHammer.includes(sid) && scopes.some((x) => x.scope_id === sid)) allHammer.push(sid);
1270
+ }
1271
+
1251
1272
  // SCOPES FAN OUT. A scope contract is the definition of an independent subtask — disjoint
1252
1273
  // substrate, own fixtures, own ratchet — so the loop that ran them one at a time was leaving the
1253
1274
  // whole point of the contract on the floor. `pipeline()` has NO barrier between its stages: a
@@ -1281,13 +1302,25 @@ while (verdict !== "pass" && round <= maxRounds) {
1281
1302
  async (pre, s) => (pre?.pending ? buildScope(s, round) : pre),
1282
1303
  async (res, s) => {
1283
1304
  if (!res || res.__failed) return res;
1284
- if (res.resumed || !res.green) return res;
1285
- const confirmed = await query(`probe t0 --slug ${slug} --scope ${s.scope_id} --round ${round}`,
1286
- T0CHECK, "Build", `t0confirm:${s.scope_id}-r${round}`);
1287
- if (!confirmed?.green) {
1288
- log(`BUILD r${round} — ${s.scope_id} reported green but no T0 verdict is on disk for this ` +
1289
- `round; treating it as not green (the evaluator cites that artifact, and it is not there).`);
1290
- return { ...res, green: false, reason: "reported green with no T0 verdict artifact on disk" };
1305
+ if (!res.green) return res;
1306
+ // THE T0 RE-READ IS SKIPPED FOR A RESUMED SCOPE; THE LEG CHECK BELOW IS NOT.
1307
+ //
1308
+ // `resumed` means the graph already reported this scope green for this round, so re-reading
1309
+ // its T0 artifact would only confirm what the resume derivation just read. But these two
1310
+ // stages answer DIFFERENT questions, and the second one is precisely the question a resumed
1311
+ // scope is most likely to fail: a relaunch happens because the previous launch DIED, and a
1312
+ // leg that died between writing its result and running `reduce ingest` leaves exactly this
1313
+ // state — green T0 on disk, result never applied, board still `pending`. The short-circuit
1314
+ // used to cover both stages, so the one scope class known to be at risk was the one class
1315
+ // nobody asked, and the late-ingest repair nine lines below could never fire for it.
1316
+ if (!res.resumed) {
1317
+ const confirmed = await query(`probe t0 --slug ${slug} --scope ${s.scope_id} --round ${round}`,
1318
+ T0CHECK, "Build", `t0confirm:${s.scope_id}-r${round}`);
1319
+ if (!confirmed?.green) {
1320
+ log(`BUILD r${round} — ${s.scope_id} reported green but no T0 verdict is on disk for this ` +
1321
+ `round; treating it as not green (the evaluator cites that artifact, and it is not there).`);
1322
+ return { ...res, green: false, reason: "reported green with no T0 verdict artifact on disk" };
1323
+ }
1291
1324
  }
1292
1325
  // AND ITS RESULT HAS TO HAVE REACHED THE BOARD. A green T0 says the worker's fixtures ran and
1293
1326
  // passed; it says nothing about whether the WorkResult was applied, and this stage used to ask
@@ -1341,8 +1374,10 @@ while (verdict !== "pass" && round <= maxRounds) {
1341
1374
  }
1342
1375
  }
1343
1376
 
1344
- allGreen.push(...roundGreen);
1345
- allHammer.push(...roundHammer);
1377
+ for (const sid of roundGreen) if (!allGreen.includes(sid)) allGreen.push(sid);
1378
+ // A scope that went green this round is no longer a cut candidate, however it was queued earlier.
1379
+ for (const sid of roundGreen) { const i = allHammer.indexOf(sid); if (i !== -1) allHammer.splice(i, 1); }
1380
+ for (const sid of roundHammer) if (!allHammer.includes(sid) && !allGreen.includes(sid)) allHammer.push(sid);
1346
1381
 
1347
1382
  // INNER breaker: nothing green and something queued → GATE H. The census is scope-hammer's job.
1348
1383
  if (roundGreen.length === 0 && roundHammer.length > 0) {