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.
|
|
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",
|
package/kernel/lib/contract.mjs
CHANGED
|
@@ -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
|
-
//
|
|
196
|
-
// quoted
|
|
197
|
-
//
|
|
198
|
-
|
|
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
|
|
package/kernel/reduce/board.mjs
CHANGED
|
@@ -68,7 +68,11 @@ const listField = (fm, key) => {
|
|
|
68
68
|
*/
|
|
69
69
|
export function parseBoard(tasksDir) {
|
|
70
70
|
if (!existsSync(tasksDir)) return [];
|
|
71
|
-
|
|
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
|
|
160
|
+
if (better(c, best)) best = c;
|
|
137
161
|
}
|
|
138
162
|
return best;
|
|
139
163
|
}
|
package/kernel/reduce/graph.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
// `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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 (
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
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(
|
|
1345
|
-
|
|
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) {
|