@pmelab/gtd 39.0.0 → 41.0.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/.claude-plugin/plugin.json +1 -1
- package/README.md +57 -55
- package/dist/chunks/{reserved-B6tTYjeb.mjs → reserved-Ch4LLrEj.mjs} +15 -9
- package/dist/chunks/{tail-_CZTP7Hu.mjs → tail-lY1UmJrU.mjs} +1 -1
- package/dist/workflow-kit.mjs +29 -11
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +98 -37
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +140 -55
- package/package.json +1 -1
- package/skills/authoring/SKILL.md +3 -2
- package/src/workflow-kit/health.ts +42 -13
- package/src/workflows/feature/coverage.feature +5 -5
- package/src/workflows/feature/criteria.test.ts +20 -1
- package/src/workflows/feature/criteria.ts +24 -0
- package/src/workflows/feature/fast-path.feature +4 -4
- package/src/workflows/feature/feature.ts +82 -22
- package/src/workflows/feature/flag-mode.feature +2 -2
- package/src/workflows/feature/one-step-land.feature +4 -4
- package/src/workflows/feature/planning.ts +3 -2
- package/src/workflows/feature/quality-unresolved.feature +6 -6
- package/src/workflows/feature/refactor-absorbs.feature +3 -3
- package/src/workflows/feature/refactor-first.feature +5 -5
- package/src/workflows/feature/refactor-rebuild.feature +2 -2
- package/src/workflows/feature/refactor-review.feature +8 -8
- package/src/workflows/feature/review-gate.feature +271 -0
- package/src/workflows/feature/seam-rejection.feature +4 -4
- package/src/workflows/feature/seam.feature +2 -2
- package/src/workflows/feature/seam.ts +17 -7
- package/src/workflows/feature/slicing.feature +63 -4
- package/src/workflows/shared/health.test.ts +30 -0
- package/src/workflows/shared/health.ts +12 -9
- package/src/workflows/shared/progress.feature +124 -0
- package/src/workflows/shared/text.ts +7 -5
- package/src/workflows/review/review-door.feature +0 -121
package/dist/workflows.mjs
CHANGED
|
@@ -456,13 +456,33 @@ const recheckReport = async (health) => {
|
|
|
456
456
|
if (health.recheck === void 0) return void 0;
|
|
457
457
|
return await health.recheck() || stripStamp(read(health.report) ?? "");
|
|
458
458
|
};
|
|
459
|
+
/** Escalate unless the re-check passes: `true` then, else the report the next round compares against. */
|
|
460
|
+
const escalated = async (health, current) => {
|
|
461
|
+
const rechecked = await recheckReport(health);
|
|
462
|
+
if (rechecked === true) {
|
|
463
|
+
health.escalations.rounds = 0;
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
await escalation$1(health.escalations, health.escalation);
|
|
467
|
+
return rechecked ?? current;
|
|
468
|
+
};
|
|
469
|
+
const nextMove = async (health, fixes, cap, previous, current) => {
|
|
470
|
+
const capped = fixes >= cap;
|
|
471
|
+
if (capped && cap > health.fixCap) return "escalate";
|
|
472
|
+
const verdict = previous === void 0 ? void 0 : await health.compare(previous, current);
|
|
473
|
+
if (capped && verdict === "progress") return "extend";
|
|
474
|
+
return verdict === "identical" || capped ? "escalate" : void 0;
|
|
475
|
+
};
|
|
459
476
|
/**
|
|
460
477
|
* Loop until the suite is green: check, and on red run `fix` — escalating
|
|
461
|
-
* first once `fixCap` fixes are spent, or `
|
|
462
|
-
* identical to the previous round's, unless `recheck` passes.
|
|
478
|
+
* first once `fixCap` fixes are spent, or `compare` calls the failure
|
|
479
|
+
* identical to the previous round's, unless `recheck` passes. A round that
|
|
480
|
+
* reaches the cap while `compare` calls it progress raises the cap by one fix,
|
|
481
|
+
* once per run of fixes between escalations.
|
|
463
482
|
*/
|
|
464
483
|
const healthy$1 = async (fix, health) => {
|
|
465
484
|
let fixes = health.fixesSoFar ?? 0;
|
|
485
|
+
let cap = health.fixCap;
|
|
466
486
|
let previous;
|
|
467
487
|
for (;;) {
|
|
468
488
|
if (await health.check()) {
|
|
@@ -470,17 +490,15 @@ const healthy$1 = async (fix, health) => {
|
|
|
470
490
|
return;
|
|
471
491
|
}
|
|
472
492
|
const current = stripStamp(read(health.report) ?? "");
|
|
473
|
-
const
|
|
493
|
+
const move = await nextMove(health, fixes, cap, previous, current);
|
|
474
494
|
previous = current;
|
|
475
|
-
if (
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
}
|
|
481
|
-
previous = rechecked ?? previous;
|
|
482
|
-
await escalation$1(health.escalations, health.escalation);
|
|
495
|
+
if (move === "extend") cap++;
|
|
496
|
+
else if (move === "escalate") {
|
|
497
|
+
const after = await escalated(health, current);
|
|
498
|
+
if (after === true) return;
|
|
499
|
+
previous = after;
|
|
483
500
|
fixes = 0;
|
|
501
|
+
cap = health.fixCap;
|
|
484
502
|
}
|
|
485
503
|
fixes++;
|
|
486
504
|
await fix();
|
|
@@ -17233,14 +17251,14 @@ What each change does next (then run \`gtd land\`):
|
|
|
17233
17251
|
- **Continue** — having undone the sketch by hand, check the test baseline is green and start triage (**start-gate.check**).
|
|
17234
17252
|
`;
|
|
17235
17253
|
const qualityUnresolvedMessage = () => `The quality lap's fix turns hit their cap and \`.gtd/QUALITY.md\` still
|
|
17236
|
-
holds findings. Nothing
|
|
17237
|
-
|
|
17254
|
+
holds findings. Nothing after this fixes them, so the process will not
|
|
17255
|
+
go on with them open.
|
|
17238
17256
|
|
|
17239
17257
|
Fix what matters by hand, then delete or empty \`.gtd/QUALITY.md\` —
|
|
17240
17258
|
dropping a finding is your call to make.
|
|
17241
17259
|
|
|
17242
17260
|
What each change does next (then run \`gtd land\`):
|
|
17243
|
-
- **Delete or empty \`.gtd/QUALITY.md\`** — the suite runs on your edits, then the
|
|
17261
|
+
- **Delete or empty \`.gtd/QUALITY.md\`** — the suite runs on your edits, then the next slice is built, or after the last one you review the change (**build.review.reviewing**).
|
|
17244
17262
|
- **Anything else** — rests here again.
|
|
17245
17263
|
`;
|
|
17246
17264
|
const startGateBlockedMessage = () => `The test baseline is red — gtd will not start new work on a broken suite.
|
|
@@ -17278,8 +17296,10 @@ reword a criterion in place, append a new one with the next unused
|
|
|
17278
17296
|
number, delete a dropped one — never renumber, never reuse an ID. When
|
|
17279
17297
|
the file holds no list (a review loop-back rewrites it), recover the
|
|
17280
17298
|
last settled one from history (\`git log -p -- .gtd/REQUIREMENTS.md\`)
|
|
17281
|
-
and carry its IDs over
|
|
17282
|
-
|
|
17299
|
+
and carry its IDs over: after a review a built criterion keeps its ID,
|
|
17300
|
+
reworded in place when the review changes its behaviour, and new
|
|
17301
|
+
behaviour gets a new ID. Landing is refused while a criterion has no ID
|
|
17302
|
+
or two share one.
|
|
17283
17303
|
`;
|
|
17284
17304
|
const riskTierBlock = `
|
|
17285
17305
|
## Risk tier and production signals
|
|
@@ -18446,7 +18466,7 @@ const retryQuestion = (comparable) => comparable ? {
|
|
|
18446
18466
|
instructions: "state carries tailsNotComparable: one of the two check reports came back empty. There is nothing to compare here; answer from criteria alone. `identical` is FORBIDDEN here.",
|
|
18447
18467
|
criteria: "new-failure: a materially different failure than before. progress: still red, but closer to green — the default if genuinely unsure."
|
|
18448
18468
|
};
|
|
18449
|
-
const
|
|
18469
|
+
const compare$1 = async (previous, current) => {
|
|
18450
18470
|
const comparable = current.trim() !== "" && previous.trim() !== "";
|
|
18451
18471
|
const { answers, truncated } = await judge("health.judge", {
|
|
18452
18472
|
questions: [retryQuestion(comparable)],
|
|
@@ -18457,7 +18477,10 @@ const sameFailure = async (previous, current) => {
|
|
|
18457
18477
|
message: healthJudgeMessage(),
|
|
18458
18478
|
label: "Judging the retry"
|
|
18459
18479
|
});
|
|
18460
|
-
|
|
18480
|
+
if (!comparable || truncated.length > 0) return void 0;
|
|
18481
|
+
const minP = numeric(vars.judgeIdenticalMinP, Infinity);
|
|
18482
|
+
if (answered(answers.verdict, "identical", minP)) return "identical";
|
|
18483
|
+
return answered(answers.verdict, "progress", minP) ? "progress" : void 0;
|
|
18461
18484
|
};
|
|
18462
18485
|
const healthSweep = (options) => [ESCALATION, ...options.sweepOnGreen ?? []];
|
|
18463
18486
|
const runSuite = (options) => check("health.check", env.testCommand ?? "", {
|
|
@@ -18468,7 +18491,8 @@ const runSuite = (options) => check("health.check", env.testCommand ?? "", {
|
|
|
18468
18491
|
/**
|
|
18469
18492
|
* Loop until the suite is green: check, and on red run `fix` — escalating
|
|
18470
18493
|
* first once `FIX_CAP` fixes are spent, or a judge calls the failure
|
|
18471
|
-
* identical to the previous round's.
|
|
18494
|
+
* identical to the previous round's. A judge calling it progress at the cap
|
|
18495
|
+
* buys one extra fix per run of fixes. An escalation re-runs the suite once
|
|
18472
18496
|
* first; a green re-run ends the loop.
|
|
18473
18497
|
*/
|
|
18474
18498
|
const healthy = (fix, options = {}) => healthy$1(guarded(fix, options.guard), {
|
|
@@ -18476,7 +18500,7 @@ const healthy = (fix, options = {}) => healthy$1(guarded(fix, options.guard), {
|
|
|
18476
18500
|
report: FEEDBACK,
|
|
18477
18501
|
fixCap: 3,
|
|
18478
18502
|
fixesSoFar: options.fixesSoFar ?? 0,
|
|
18479
|
-
|
|
18503
|
+
compare: compare$1,
|
|
18480
18504
|
escalations: options.escalations ?? { rounds: 0 },
|
|
18481
18505
|
recheck: () => recheck("health.recheck", "health.check", { sweepOnGreen: healthSweep(options) }),
|
|
18482
18506
|
escalation: guardedSteps(options.guard, options.describeGuard, options.guardRests, options.rules)
|
|
@@ -38095,6 +38119,19 @@ const requireCriteria = (file) => {
|
|
|
38095
38119
|
if (criteria.length > 0 && unnamed.length === 0 && twice.length === 0) return;
|
|
38096
38120
|
refuse(`gtd land: acceptance-criteria: ${file} has ${criteria.length === 0 ? `no "## Acceptance Criteria" list` : unnamed.length > 0 ? `${unnamed.length} criterion without an ID:\n${unnamed.map((c) => ` - ${c.text}`).join("\n")}` : `duplicate IDs: ${[...new Set(twice)].join(", ")}`} — list every criterion under "## Acceptance Criteria" as "- AC-<n>: <checkable criterion>", each ID unique and never reused`);
|
|
38097
38121
|
};
|
|
38122
|
+
const idNumber = (id) => Number(id.slice(3));
|
|
38123
|
+
/** Why `listed` renumbers or reuses an earlier ID; `undefined` when it does neither. */
|
|
38124
|
+
const unstableIds = (listed, ids) => {
|
|
38125
|
+
const top = Math.max(0, ...ids.used.map(idNumber));
|
|
38126
|
+
const reused = listed.filter((id) => !ids.closed.includes(id) && idNumber(id) <= top);
|
|
38127
|
+
if (reused.length === 0) return void 0;
|
|
38128
|
+
return `numbers ${reused.join(", ")} at or below AC-${top}, an ID already handed out — keep a built criterion's ID (reword it in place when the review changes it), and number new ones from AC-${top + 1}`;
|
|
38129
|
+
};
|
|
38130
|
+
/** Refuse criteria in `file` that renumber or reuse an earlier ID. */
|
|
38131
|
+
const requireStableIds = (file, ids) => {
|
|
38132
|
+
const why = unstableIds(criterionIds(read(file) ?? ""), ids);
|
|
38133
|
+
if (why !== void 0) refuse(`gtd land: criteria-ids: ${file} ${why}`);
|
|
38134
|
+
};
|
|
38098
38135
|
/** Refuse a turn that changed the acceptance criteria in `file`: after the design gate only another design lap may. */
|
|
38099
38136
|
const requireCriteriaFrozen = (file) => {
|
|
38100
38137
|
const change = changes().get(file);
|
|
@@ -38250,13 +38287,14 @@ const noteGate = async (file, answer) => {
|
|
|
38250
38287
|
if (!hasThreadFor("agent", file)) requireAnswers(file);
|
|
38251
38288
|
return true;
|
|
38252
38289
|
};
|
|
38253
|
-
/** Triage the sketch since `base` into requirements, until no product question is open. */
|
|
38254
|
-
const design = (base) => scope("design", async () => {
|
|
38290
|
+
/** Triage the sketch since `base` into requirements, until no product question is open; `guard` holds every round's criteria. */
|
|
38291
|
+
const design = (base, guard) => scope("design", async () => {
|
|
38255
38292
|
do {
|
|
38256
38293
|
await triage(base);
|
|
38257
38294
|
requireProgress(REQUIREMENTS);
|
|
38258
38295
|
requireReplies(REQUIREMENTS);
|
|
38259
38296
|
requireCriteria(REQUIREMENTS);
|
|
38297
|
+
guard?.();
|
|
38260
38298
|
} while (await noteGate(REQUIREMENTS, answerProductQuestions));
|
|
38261
38299
|
});
|
|
38262
38300
|
//#endregion
|
|
@@ -39068,27 +39106,31 @@ const build = (options) => continuing(phase("build", requireCriteriaIfEdited), a
|
|
|
39068
39106
|
}
|
|
39069
39107
|
};
|
|
39070
39108
|
});
|
|
39071
|
-
function implement(first, tier, amend) {
|
|
39072
|
-
|
|
39109
|
+
async function implement(first, tier, amend) {
|
|
39110
|
+
await implementing(typeof first === "object" && first !== null ? first : {
|
|
39073
39111
|
reference: first,
|
|
39074
39112
|
tier,
|
|
39075
39113
|
amend
|
|
39076
|
-
};
|
|
39077
|
-
return scope("implement", async () => {
|
|
39078
|
-
const since = head$1();
|
|
39079
|
-
const acceptance = options.rebuild?.acceptance;
|
|
39080
|
-
const built = options.naive === void 0 || !await applied(options.naive);
|
|
39081
|
-
if (built) await build(options);
|
|
39082
|
-
else requireCriteriaFrozen(REQUIREMENTS);
|
|
39083
|
-
if (built && acceptance !== void 0) requireAcceptance({
|
|
39084
|
-
...acceptance,
|
|
39085
|
-
ids: settled(acceptance.ids, options.slice)
|
|
39086
|
-
}, since);
|
|
39087
|
-
await green(built && acceptance !== void 0 ? () => requireAcceptanceKept(acceptance) : void 0);
|
|
39088
|
-
await requireCoverage(options.base, options.slice);
|
|
39089
|
-
await closing(options.more === true);
|
|
39090
39114
|
});
|
|
39091
39115
|
}
|
|
39116
|
+
/** `implement`, resolving with the criterion IDs the requirements listed when it closed. */
|
|
39117
|
+
const implementing = (options) => scope("implement", async () => {
|
|
39118
|
+
const since = head$1();
|
|
39119
|
+
const acceptance = options.rebuild?.acceptance;
|
|
39120
|
+
const built = options.naive === void 0 || !await applied(options.naive);
|
|
39121
|
+
if (built) await build(options);
|
|
39122
|
+
else requireCriteriaFrozen(REQUIREMENTS);
|
|
39123
|
+
if (built && acceptance !== void 0) requireAcceptance({
|
|
39124
|
+
...acceptance,
|
|
39125
|
+
ids: settled(acceptance.ids, options.slice)
|
|
39126
|
+
}, since);
|
|
39127
|
+
await green(built && acceptance !== void 0 ? () => requireAcceptanceKept(acceptance) : void 0);
|
|
39128
|
+
await requireCoverage(options.base, options.slice);
|
|
39129
|
+
const ids = criterionIds(read(".gtd/REQUIREMENTS.md") ?? "");
|
|
39130
|
+
await closing(moreToBuild(options.more));
|
|
39131
|
+
return ids;
|
|
39132
|
+
});
|
|
39133
|
+
const moreToBuild = (more) => typeof more === "function" ? more() : more === true;
|
|
39092
39134
|
/** The last slice's closing, for a last slice a steer left with no criterion to build. */
|
|
39093
39135
|
const closeOut = () => scope("implement", () => closing(false));
|
|
39094
39136
|
/** A slice with `more` after it keeps the criteria and the slicing for the next one. */
|
|
@@ -39323,32 +39365,37 @@ const unwind = () => {
|
|
|
39323
39365
|
const buildFeature = async (firstBase) => {
|
|
39324
39366
|
const tier = riskTierOn() ? recordRisk() : void 0;
|
|
39325
39367
|
const slices = await slicing();
|
|
39368
|
+
const handedOut = new Set(listedIds());
|
|
39326
39369
|
const one = slices.length === 1;
|
|
39370
|
+
const built = (i) => sliceIds(sliceCriteria(slices, i))?.length !== 0;
|
|
39327
39371
|
for (const i of slices.keys()) {
|
|
39328
|
-
|
|
39329
|
-
const criteria = sliceCriteria(slices, i);
|
|
39330
|
-
if (sliceIds(criteria)?.length === 0) {
|
|
39331
|
-
if (!more) await closeOut();
|
|
39332
|
-
continue;
|
|
39333
|
-
}
|
|
39372
|
+
if (!built(i)) continue;
|
|
39334
39373
|
const n = one ? "" : `-${i + 1}`;
|
|
39335
39374
|
await buildSlice(i === 0 ? firstBase : head$1(), {
|
|
39336
39375
|
tier,
|
|
39337
|
-
criteria,
|
|
39376
|
+
criteria: sliceCriteria(slices, i),
|
|
39338
39377
|
refactor: `refactor${n}`,
|
|
39339
39378
|
feature: `feature${n}`,
|
|
39340
|
-
more
|
|
39379
|
+
more: () => slices.some((_, j) => j > i && built(j)),
|
|
39380
|
+
reviewBase: one ? void 0 : firstBase,
|
|
39381
|
+
handedOut
|
|
39341
39382
|
});
|
|
39342
39383
|
}
|
|
39343
39384
|
};
|
|
39344
|
-
|
|
39385
|
+
const listedIds = () => criterionIds(read(".gtd/REQUIREMENTS.md") ?? "");
|
|
39386
|
+
const gather = (handedOut) => {
|
|
39387
|
+
for (const id of listedIds()) handedOut.add(id);
|
|
39388
|
+
return [...handedOut];
|
|
39389
|
+
};
|
|
39390
|
+
/** A naive pass (judged only with `flagMode` on), then implement and the quality lap; after the last slice, the review. */
|
|
39345
39391
|
const buildSlice = async (sliceBase, build) => {
|
|
39346
39392
|
const seam = await seamBuild(sliceBase, {
|
|
39347
39393
|
criteria: build.criteria,
|
|
39348
39394
|
refactor: build.refactor
|
|
39349
39395
|
});
|
|
39396
|
+
const used = gather(build.handedOut);
|
|
39350
39397
|
await slice(build.feature, async () => {
|
|
39351
|
-
await
|
|
39398
|
+
const closed = await implementing({
|
|
39352
39399
|
reference: seam.reference,
|
|
39353
39400
|
tier: build.tier,
|
|
39354
39401
|
afterRefactor: seam.refactored,
|
|
@@ -39358,14 +39405,23 @@ const buildSlice = async (sliceBase, build) => {
|
|
|
39358
39405
|
base: build.criteria === void 0 ? void 0 : sliceBase,
|
|
39359
39406
|
more: build.more
|
|
39360
39407
|
});
|
|
39408
|
+
const more = build.more();
|
|
39361
39409
|
const carried = seam.rebuild?.acceptance?.tests ?? [];
|
|
39362
39410
|
const base = seam.refactored === true && !changesSince(seam.base).some((c) => isCode$1(c.path) && !carried.includes(c.path)) ? sliceBase : seam.base;
|
|
39363
|
-
|
|
39364
|
-
|
|
39365
|
-
|
|
39366
|
-
|
|
39367
|
-
|
|
39368
|
-
|
|
39411
|
+
if (more) {
|
|
39412
|
+
const guard = () => requireCriteriaFrozen(REQUIREMENTS);
|
|
39413
|
+
await tail(settleQuality, {
|
|
39414
|
+
green: true,
|
|
39415
|
+
lensBase: base,
|
|
39416
|
+
guard,
|
|
39417
|
+
guardRests: false
|
|
39418
|
+
});
|
|
39419
|
+
return;
|
|
39420
|
+
}
|
|
39421
|
+
const reviewBase = build.reviewBase ?? base;
|
|
39422
|
+
await featureFeedback(await reviewedTail(reviewBase, base), base, {
|
|
39423
|
+
closed,
|
|
39424
|
+
used
|
|
39369
39425
|
});
|
|
39370
39426
|
});
|
|
39371
39427
|
};
|
|
@@ -39381,6 +39437,35 @@ const settleQuality = async () => {
|
|
|
39381
39437
|
}
|
|
39382
39438
|
if (rested) await healthy(() => fixCheck());
|
|
39383
39439
|
};
|
|
39440
|
+
/** The quality lap, then the human review since `base`. */
|
|
39441
|
+
const reviewedTail = (base, lensBase, lap) => tail(async (escalations) => {
|
|
39442
|
+
await settleQuality();
|
|
39443
|
+
return review$1(base, escalations, { lap });
|
|
39444
|
+
}, {
|
|
39445
|
+
green: true,
|
|
39446
|
+
lensBase
|
|
39447
|
+
});
|
|
39448
|
+
const featureFeedback = async (first, lensBase, ids) => {
|
|
39449
|
+
let outcome = first;
|
|
39450
|
+
let closed = ids.closed;
|
|
39451
|
+
const handedOut = /* @__PURE__ */ new Set([...ids.used, ...ids.closed]);
|
|
39452
|
+
while (outcome.verdict === "feedback") {
|
|
39453
|
+
await reUnwind(outcome);
|
|
39454
|
+
const lap = {
|
|
39455
|
+
closed,
|
|
39456
|
+
used: [...handedOut]
|
|
39457
|
+
};
|
|
39458
|
+
await design(outcome.base, () => {
|
|
39459
|
+
requireStableIds(REQUIREMENTS, lap);
|
|
39460
|
+
gather(handedOut);
|
|
39461
|
+
});
|
|
39462
|
+
closed = await implementing({
|
|
39463
|
+
tier: riskTierOn() ? recordRisk() : void 0,
|
|
39464
|
+
amend: true
|
|
39465
|
+
});
|
|
39466
|
+
outcome = await reviewedTail(outcome.base, lensBase, "feedback");
|
|
39467
|
+
}
|
|
39468
|
+
};
|
|
39384
39469
|
/** Design — skipped when the sketch's own criteria are judged clear — then `buildFeature`. */
|
|
39385
39470
|
const planAndBuild = async (firstBase, stated) => {
|
|
39386
39471
|
if (!await skipDesign(riskTierOn() ? void 0 : stated)) await design(firstBase);
|
|
@@ -39606,4 +39691,4 @@ var workflows_default = feature;
|
|
|
39606
39691
|
*/
|
|
39607
39692
|
const doors = {};
|
|
39608
39693
|
//#endregion
|
|
39609
|
-
export { COMMENT, ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, HANDOFF, QUALITY, REFACTOR, REFACTOR_NOTE, REFACTOR_PACKAGE, REQUIREMENTS, REVIEW, REVIEW_ORIG, SLICES, ShippedOptions, acceptanceFault, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, awaitReview, base, baseline, buildFeature, buildTail, builtInLenses, cleanup, cleanupFlow, closeOut, closeReview, coder, collecting, criteriaOf, criterionIds, workflows_default as default, defaults, deferNotes, describeEscalation, design, doors, envDefaults, escalate, escalation, escalationExhausted, feature, fix, fixCheck, fixFlow, fixNits, fixQuality, fixQualityFindings, fixReviewNotes, fixRisks, gate, guarded, handOffReview, handOffTail, healthy, holdsCallSites, implement, isTestFile, judgeSeam, lensEntriesOf, lensOf, lensesOf, naiveAcceptance, naivePass, ordinaryStart, planAndBuild, planner, qualityLap, reUnwind, recheck, refactorCollecting, refactorFirst, refactorNote, refactorStrays, requireAcceptance, requireAcceptanceKept, requireCriteria, requireCriteriaFrozen, requireCriteriaIfEdited, requireNoNewFlags, requireNoTestFiles, requireSlices, requireTestCallSitesOnly, requireWithin, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewedRefactor, reviewing, rollout, rolloutFlow, seamBuild, skills, skipDesign, sliceCap, sliceCriteria, sliceIds, slicesOf, slicing, statedCriteria, steering, summary, tail, threads, triage, unwind };
|
|
39694
|
+
export { COMMENT, ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, HANDOFF, QUALITY, REFACTOR, REFACTOR_NOTE, REFACTOR_PACKAGE, REQUIREMENTS, REVIEW, REVIEW_ORIG, SLICES, ShippedOptions, acceptanceFault, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, awaitReview, base, baseline, buildFeature, buildTail, builtInLenses, cleanup, cleanupFlow, closeOut, closeReview, coder, collecting, criteriaOf, criterionIds, workflows_default as default, defaults, deferNotes, describeEscalation, design, doors, envDefaults, escalate, escalation, escalationExhausted, feature, fix, fixCheck, fixFlow, fixNits, fixQuality, fixQualityFindings, fixReviewNotes, fixRisks, gate, guarded, handOffReview, handOffTail, healthy, holdsCallSites, implement, implementing, isTestFile, judgeSeam, lensEntriesOf, lensOf, lensesOf, naiveAcceptance, naivePass, ordinaryStart, planAndBuild, planner, qualityLap, reUnwind, recheck, refactorCollecting, refactorFirst, refactorNote, refactorStrays, requireAcceptance, requireAcceptanceKept, requireCriteria, requireCriteriaFrozen, requireCriteriaIfEdited, requireNoNewFlags, requireNoTestFiles, requireSlices, requireStableIds, requireTestCallSitesOnly, requireWithin, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewedRefactor, reviewing, rollout, rolloutFlow, seamBuild, skills, skipDesign, sliceCap, sliceCriteria, sliceIds, slicesOf, slicing, statedCriteria, steering, summary, tail, threads, triage, unstableIds, unwind };
|
package/package.json
CHANGED
|
@@ -224,8 +224,9 @@ workflow.
|
|
|
224
224
|
|
|
225
225
|
In the bundled default, `planAndBuild` runs the design phase, then
|
|
226
226
|
`buildFeature`: the naive pass and its seam judge, then the implementation (the
|
|
227
|
-
pass itself on `land`)
|
|
228
|
-
|
|
227
|
+
pass itself on `land`), the quality lap and the human review (a review feedback
|
|
228
|
+
lap re-runs design inside `buildFeature` and does not pass it again). Add a
|
|
229
|
+
human sign-off before the first build:
|
|
229
230
|
|
|
230
231
|
```ts
|
|
231
232
|
const planAndBuild = async (firstBase: string): Promise<void> => {
|
|
@@ -32,6 +32,8 @@ export const untilSettled = async (
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
export type RetryVerdict = "identical" | "progress"
|
|
36
|
+
|
|
35
37
|
export interface Health {
|
|
36
38
|
/** One counted run of the suite; resolves `true` when green. */
|
|
37
39
|
readonly check: () => Promise<boolean>
|
|
@@ -39,8 +41,8 @@ export interface Health {
|
|
|
39
41
|
readonly report: string
|
|
40
42
|
readonly fixCap: number
|
|
41
43
|
readonly fixesSoFar?: number
|
|
42
|
-
/**
|
|
43
|
-
readonly
|
|
44
|
+
/** How a red report compares to the previous round's: `identical` escalates at once, `progress` at the cap earns one extra fix. */
|
|
45
|
+
readonly compare: (previous: string, current: string) => Promise<RetryVerdict | undefined>
|
|
44
46
|
/** Shared with other callers in the same run of red checks. */
|
|
45
47
|
readonly escalations: EscalationCount
|
|
46
48
|
readonly escalation: EscalationSteps
|
|
@@ -58,13 +60,42 @@ const recheckReport = async (health: Health): Promise<true | string | undefined>
|
|
|
58
60
|
return (await health.recheck()) || stripStamp(read(health.report) ?? "")
|
|
59
61
|
}
|
|
60
62
|
|
|
63
|
+
/** Escalate unless the re-check passes: `true` then, else the report the next round compares against. */
|
|
64
|
+
const escalated = async (health: Health, current: string): Promise<true | string> => {
|
|
65
|
+
const rechecked = await recheckReport(health)
|
|
66
|
+
if (rechecked === true) {
|
|
67
|
+
health.escalations.rounds = 0
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
await escalation(health.escalations, health.escalation)
|
|
71
|
+
return rechecked ?? current
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const nextMove = async (
|
|
75
|
+
health: Health,
|
|
76
|
+
fixes: number,
|
|
77
|
+
cap: number,
|
|
78
|
+
previous: string | undefined,
|
|
79
|
+
current: string,
|
|
80
|
+
): Promise<"extend" | "escalate" | undefined> => {
|
|
81
|
+
const capped = fixes >= cap
|
|
82
|
+
// Past a raised cap the loop escalates whatever the judge would answer.
|
|
83
|
+
if (capped && cap > health.fixCap) return "escalate"
|
|
84
|
+
const verdict = previous === undefined ? undefined : await health.compare(previous, current)
|
|
85
|
+
if (capped && verdict === "progress") return "extend"
|
|
86
|
+
return verdict === "identical" || capped ? "escalate" : undefined
|
|
87
|
+
}
|
|
88
|
+
|
|
61
89
|
/**
|
|
62
90
|
* Loop until the suite is green: check, and on red run `fix` — escalating
|
|
63
|
-
* first once `fixCap` fixes are spent, or `
|
|
64
|
-
* identical to the previous round's, unless `recheck` passes.
|
|
91
|
+
* first once `fixCap` fixes are spent, or `compare` calls the failure
|
|
92
|
+
* identical to the previous round's, unless `recheck` passes. A round that
|
|
93
|
+
* reaches the cap while `compare` calls it progress raises the cap by one fix,
|
|
94
|
+
* once per run of fixes between escalations.
|
|
65
95
|
*/
|
|
66
96
|
export const healthy = async (fix: () => Promise<void>, health: Health): Promise<void> => {
|
|
67
97
|
let fixes = health.fixesSoFar ?? 0
|
|
98
|
+
let cap = health.fixCap
|
|
68
99
|
let previous: string | undefined
|
|
69
100
|
for (;;) {
|
|
70
101
|
if (await health.check()) {
|
|
@@ -72,17 +103,15 @@ export const healthy = async (fix: () => Promise<void>, health: Health): Promise
|
|
|
72
103
|
return
|
|
73
104
|
}
|
|
74
105
|
const current = stripStamp(read(health.report) ?? "")
|
|
75
|
-
const
|
|
106
|
+
const move = await nextMove(health, fixes, cap, previous, current)
|
|
76
107
|
previous = current
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
previous = rechecked ?? previous
|
|
84
|
-
await escalation(health.escalations, health.escalation)
|
|
108
|
+
if (move === "extend") cap++
|
|
109
|
+
else if (move === "escalate") {
|
|
110
|
+
const after = await escalated(health, current)
|
|
111
|
+
if (after === true) return
|
|
112
|
+
previous = after
|
|
85
113
|
fixes = 0
|
|
114
|
+
cap = health.fixCap
|
|
86
115
|
}
|
|
87
116
|
fixes++
|
|
88
117
|
await fix()
|
|
@@ -55,19 +55,19 @@ Feature: a feature is done only when every acceptance criterion has a passing te
|
|
|
55
55
|
And the output of gtd "judge" contains "add is unchanged."
|
|
56
56
|
And the output of gtd "judge" contains "[AC-1] subtracts"
|
|
57
57
|
|
|
58
|
-
Scenario: every ID covered
|
|
58
|
+
Scenario: every ID covered moves on to the review and marks the IDs it covers
|
|
59
59
|
Given judge step "implement.coverage" answers:
|
|
60
60
|
"""
|
|
61
61
|
[{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"yes","p":0.9}]
|
|
62
62
|
"""
|
|
63
63
|
When the process is driven
|
|
64
|
-
Then the process rests at "
|
|
64
|
+
Then the process rests at "build.review.reviewing"
|
|
65
65
|
And the commit subjects end with:
|
|
66
66
|
"""
|
|
67
67
|
gtd(check): implement.applying → implement.health.check
|
|
68
68
|
gtd(check): implement.health.check → implement.coverage
|
|
69
69
|
gtd(judge): implement.coverage → implement.closing
|
|
70
|
-
gtd(check): implement.closing →
|
|
70
|
+
gtd(check): implement.closing → build.review.reviewing
|
|
71
71
|
"""
|
|
72
72
|
And the commit "gtd(judge): implement.coverage → implement.closing" has a body containing "Gtd-Mark: covers=AC-1"
|
|
73
73
|
And the commit "gtd(judge): implement.coverage → implement.closing" has a body containing "Gtd-Mark: covers=AC-2"
|
|
@@ -132,7 +132,7 @@ Feature: a feature is done only when every acceptance criterion has a passing te
|
|
|
132
132
|
"""
|
|
133
133
|
Then driving the process is refused with "criteria-frozen"
|
|
134
134
|
|
|
135
|
-
Scenario: overruling at the gate
|
|
135
|
+
Scenario: overruling at the gate moves on to the review, recording the uncovered IDs
|
|
136
136
|
Given judge step "implement.coverage" answers:
|
|
137
137
|
"""
|
|
138
138
|
[{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"no","p":0.9}]
|
|
@@ -144,7 +144,7 @@ Feature: a feature is done only when every acceptance criterion has a passing te
|
|
|
144
144
|
"""
|
|
145
145
|
And step "implement.uncovered" changes nothing
|
|
146
146
|
When the process is driven
|
|
147
|
-
Then the process rests at "
|
|
147
|
+
Then the process rests at "build.review.reviewing"
|
|
148
148
|
And the commit "gtd(human): implement.uncovered → implement.closing" has a body containing "Gtd-Mark: covers=AC-1"
|
|
149
149
|
And the commit "gtd(human): implement.uncovered → implement.closing" has a body containing "Gtd-Mark: uncovered=AC-2"
|
|
150
150
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
2
|
import type { Change } from "../../flows/index.js"
|
|
3
|
-
import { criteriaOf, criterionIds, statedCriteria } from "./criteria.js"
|
|
3
|
+
import { criteriaOf, criterionIds, statedCriteria, unstableIds } from "./criteria.js"
|
|
4
4
|
|
|
5
5
|
const added = (path: string, after: string): Change => ({
|
|
6
6
|
path,
|
|
@@ -120,3 +120,22 @@ describe("criterionIds", () => {
|
|
|
120
120
|
expect(criterionIds(requirements, { except: ["AC-1", "AC-3"] })).toEqual(["AC-2", "AC-4"])
|
|
121
121
|
})
|
|
122
122
|
})
|
|
123
|
+
|
|
124
|
+
describe("unstableIds", () => {
|
|
125
|
+
const ids = { closed: ["AC-1", "AC-3"], used: ["AC-1", "AC-2", "AC-3"] }
|
|
126
|
+
|
|
127
|
+
it("accepts built IDs kept or dropped and new ones numbered above all handed out", () => {
|
|
128
|
+
expect(unstableIds(["AC-3", "AC-4", "AC-7"], ids)).toBeUndefined()
|
|
129
|
+
expect(unstableIds([], ids)).toBeUndefined()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it("names a new ID that reuses a number handed out before, even a narrowed-away one", () => {
|
|
133
|
+
expect(unstableIds(["AC-1", "AC-2", "AC-3"], ids)).toMatch(
|
|
134
|
+
/^numbers AC-2 at or below AC-3, .* from AC-4$/,
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("names every reused ID", () => {
|
|
139
|
+
expect(unstableIds(["AC-0", "AC-2"], ids)).toMatch(/^numbers AC-0, AC-2 at or below AC-3/)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -151,6 +151,30 @@ export const requireCriteria = (file: string): void => {
|
|
|
151
151
|
)
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/** The IDs a review's feedback lap must respect. */
|
|
155
|
+
export interface StableIds {
|
|
156
|
+
/** Listed when the build closed: each may stay, reworded or not, or go. */
|
|
157
|
+
readonly closed: readonly string[]
|
|
158
|
+
/** Every ID handed out so far: any other is numbered above all of them. */
|
|
159
|
+
readonly used: readonly string[]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const idNumber = (id: string): number => Number(id.slice("AC-".length))
|
|
163
|
+
|
|
164
|
+
/** Why `listed` renumbers or reuses an earlier ID; `undefined` when it does neither. */
|
|
165
|
+
export const unstableIds = (listed: readonly string[], ids: StableIds): string | undefined => {
|
|
166
|
+
const top = Math.max(0, ...ids.used.map(idNumber))
|
|
167
|
+
const reused = listed.filter((id) => !ids.closed.includes(id) && idNumber(id) <= top)
|
|
168
|
+
if (reused.length === 0) return undefined
|
|
169
|
+
return `numbers ${reused.join(", ")} at or below AC-${top}, an ID already handed out — keep a built criterion's ID (reword it in place when the review changes it), and number new ones from AC-${top + 1}`
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Refuse criteria in `file` that renumber or reuse an earlier ID. */
|
|
173
|
+
export const requireStableIds = (file: string, ids: StableIds): void => {
|
|
174
|
+
const why = unstableIds(criterionIds(read(file) ?? ""), ids)
|
|
175
|
+
if (why !== undefined) refuse(`gtd land: criteria-ids: ${file} ${why}`)
|
|
176
|
+
}
|
|
177
|
+
|
|
154
178
|
/** Refuse a turn that changed the acceptance criteria in `file`: after the design gate only another design lap may. */
|
|
155
179
|
export const requireCriteriaFrozen = (file: string): void => {
|
|
156
180
|
const change = changes().get(file)
|
|
@@ -3,7 +3,7 @@ Feature: a trivial change takes the same few beats at either risk tier
|
|
|
3
3
|
|
|
4
4
|
With GTD_FLAG_RISK_TIER on, a one-line copy fix goes
|
|
5
5
|
from idle through the naive pass, the seam judge's land, the applied pass and
|
|
6
|
-
the quality lap
|
|
6
|
+
the quality lap to the review gate. Low tier walks exactly the steps high does; only
|
|
7
7
|
the summary differs.
|
|
8
8
|
|
|
9
9
|
Background:
|
|
@@ -32,7 +32,7 @@ Feature: a trivial change takes the same few beats at either risk tier
|
|
|
32
32
|
"""
|
|
33
33
|
And step "build.quality.correctness.reviewing" changes nothing
|
|
34
34
|
|
|
35
|
-
Scenario Outline: a one-line fix runs idle to
|
|
35
|
+
Scenario Outline: a one-line fix runs from idle to the review gate with the same beats at either tier
|
|
36
36
|
Given step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
|
|
37
37
|
"""
|
|
38
38
|
# Requirements
|
|
@@ -64,7 +64,7 @@ Feature: a trivial change takes the same few beats at either risk tier
|
|
|
64
64
|
"""
|
|
65
65
|
And step "implement.applying" runs its script
|
|
66
66
|
When the process is driven
|
|
67
|
-
Then the process rests at "
|
|
67
|
+
Then the process rests at "build.review.reviewing"
|
|
68
68
|
And "src/greeting.ts" contains "Hello, world"
|
|
69
69
|
And the commit subjects end with:
|
|
70
70
|
"""
|
|
@@ -80,7 +80,7 @@ Feature: a trivial change takes the same few beats at either risk tier
|
|
|
80
80
|
gtd(check): implement.health.check → implement.coverage
|
|
81
81
|
gtd(judge): implement.coverage → implement.closing
|
|
82
82
|
gtd(check): implement.closing → build.quality.correctness.reviewing
|
|
83
|
-
gtd(agent): build.quality.correctness.reviewing →
|
|
83
|
+
gtd(agent): build.quality.correctness.reviewing → build.review.reviewing
|
|
84
84
|
"""
|
|
85
85
|
And the output of gtd "summary" <summary> "needs a human reviewer"
|
|
86
86
|
|