@pmelab/gtd 32.0.0 → 33.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 +28 -22
- package/dist/chunks/{reserved-CHTJTzpA.mjs → reserved-BU5Zi-nX.mjs} +74 -13
- package/dist/chunks/{tail-CCsDBbZe.mjs → tail-DuXanjIY.mjs} +6 -3
- package/dist/testkit.mjs +2 -2
- package/dist/workflow-kit.mjs +15 -3
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +158 -34
- 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 +246 -46
- package/package.json +1 -1
- package/src/workflow-kit/quality.test.ts +25 -1
- package/src/workflow-kit/quality.ts +22 -4
- package/src/workflows/feature/acceptance-criteria.feature +8 -0
- package/src/workflows/feature/fast-path.feature +8 -2
- package/src/workflows/feature/feature.test.ts +19 -9
- package/src/workflows/feature/lens-models.feature +60 -0
- package/src/workflows/feature/naive-acceptance.feature +140 -0
- package/src/workflows/feature/one-step-land.feature +8 -2
- package/src/workflows/feature/refactor-absorbs.feature +6 -1
- package/src/workflows/feature/refactor-first.feature +9 -3
- package/src/workflows/feature/refactor-lens-models.feature +67 -0
- package/src/workflows/feature/refactor-review.feature +50 -5
- package/src/workflows/feature/refactor.ts +6 -5
- package/src/workflows/feature/risk-tier.feature +10 -0
- package/src/workflows/feature/seam-rejection.feature +19 -6
- package/src/workflows/feature/seam.feature +70 -16
- package/src/workflows/feature/seam.test.ts +189 -3
- package/src/workflows/feature/seam.ts +195 -30
- package/src/workflows/feature/steps.test.ts +13 -0
- package/src/workflows/index.ts +1 -1
- package/src/workflows/shared/skills.test.ts +7 -0
- package/src/workflows/shared/steps.test.ts +45 -0
- package/src/workflows/shared/steps.ts +4 -2
- package/src/workflows/shared/tail.test.ts +31 -1
- package/src/workflows/shared/tail.ts +6 -3
- package/src/workflows/shared/text.test.ts +40 -0
- package/src/workflows/shared/text.ts +97 -16
package/dist/workflows.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
1
2
|
//#region \0rolldown/runtime.js
|
|
2
3
|
var __defProp = Object.defineProperty;
|
|
3
4
|
var __exportAll = (all, no_symbols) => {
|
|
@@ -476,8 +477,20 @@ const gate$1 = (name, passes, blocked) => scope(name, async () => {
|
|
|
476
477
|
});
|
|
477
478
|
//#endregion
|
|
478
479
|
//#region src/workflow-kit/quality.ts
|
|
479
|
-
|
|
480
|
-
const
|
|
480
|
+
const lensOf = (entry) => {
|
|
481
|
+
const at = entry.indexOf("=");
|
|
482
|
+
return at < 0 ? {
|
|
483
|
+
lens: entry.trim(),
|
|
484
|
+
model: ""
|
|
485
|
+
} : {
|
|
486
|
+
lens: entry.slice(0, at).trim(),
|
|
487
|
+
model: entry.slice(at + 1).trim()
|
|
488
|
+
};
|
|
489
|
+
};
|
|
490
|
+
/** Split a comma-separated lens list into whole entries, `<lens>` or `<lens>=<model>`: trimmed, blanks dropped. */
|
|
491
|
+
const lensEntriesOf = (value) => value.split(",").map((entry) => entry.trim()).filter((entry) => lensOf(entry).lens.length > 0);
|
|
492
|
+
/** The same list's lens names, model hints cut off. */
|
|
493
|
+
const lensesOf = (value) => lensEntriesOf(value).map((entry) => lensOf(entry).lens);
|
|
481
494
|
/** One `<scope>.<lens>` entry per lens — the per-lens part of a skills or access export. */
|
|
482
495
|
const lensScopes = (scope, lenses, entry) => Object.fromEntries(lenses.map((lens) => [`${scope}.${lens}`, entry(lens)]));
|
|
483
496
|
/** One `review` turn per lens, each appending its findings to `findings`; resolves `"findings"` when that file has any. */
|
|
@@ -17987,24 +18000,47 @@ Print the closing message and stop — this writes nothing itself.
|
|
|
17987
18000
|
`;
|
|
17988
18001
|
};
|
|
17989
18002
|
const listed = (paths, unreadable = []) => paths.map((path) => ` - \`${displayPath(path)}\`${unreadable.includes(path) ? " (unreadable)" : ""}`).join("\n");
|
|
18003
|
+
const isRed = (retry) => "red" in retry;
|
|
18004
|
+
const isFault = (retry) => "fault" in retry;
|
|
18005
|
+
const pickUp = (hash) => `Pick it up — \`git diff --binary ${hash}^ ${hash} | git apply\``;
|
|
17990
18006
|
const criteriaBinding = (refused) => `- Its \`## Acceptance Criteria\` (\`AC-<n>\`) bind this turn: re-read them from
|
|
17991
18007
|
the file, never from memory, and stop only when each one holds. They are
|
|
17992
18008
|
settled — ${refused ? "a turn that changes them is refused" : "leave them as they are"}`;
|
|
17993
|
-
const
|
|
18009
|
+
const naiveRetry = (retry) => isFault(retry) ? `
|
|
18010
|
+
Your previous attempt's (\`${retry.hash}\`) tests fall short:
|
|
18011
|
+
${retry.fault}
|
|
18012
|
+
${pickUp(retry.hash)} — and fix its tests; do not start over.
|
|
18013
|
+
` : isRed(retry) ? `
|
|
18014
|
+
Your previous attempt (\`${retry.red}\`) left the suite red; \`.gtd/FEEDBACK.md\`
|
|
18015
|
+
holds the output. ${pickUp(retry.red)}
|
|
18016
|
+
— and fix it until the suite is green; do not start over.
|
|
18017
|
+
` : `
|
|
18018
|
+
Your previous attempt was rejected: the judge answered \`reject\` (p=${retry.p}),
|
|
18019
|
+
meaning the naive pass solved the task the wrong way — discard it and
|
|
18020
|
+
re-implement. It changed these files outside the expected set:
|
|
18021
|
+
${listed(retry.strays)}
|
|
18022
|
+
Start over and choose a different approach; do not rebuild the same diff.
|
|
18023
|
+
`;
|
|
18024
|
+
const naivePassPrompt = (retry) => `${stateFileRules}
|
|
17994
18025
|
- Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
|
|
17995
18026
|
${criteriaBinding(false)}
|
|
17996
18027
|
- Implement them the quickest way that works — the naive version. Do not
|
|
17997
18028
|
restructure, abstract or refactor around the change; a later step judges
|
|
17998
18029
|
the shape of your diff and a reviewer makes it fit
|
|
17999
|
-
-
|
|
18030
|
+
- Work breadth-first: rough out every part of the change before finishing any
|
|
18031
|
+
one, so a run cut off midway still shows the whole shape
|
|
18032
|
+
- Write behaviour-level acceptance tests, at least one per criterion, each
|
|
18033
|
+
naming its ID in the test or scenario name (\`[AC-3] …\`). Test through code
|
|
18034
|
+
that existed before this pass — a public entry point, the CLI, an existing
|
|
18035
|
+
module's exports — never a file this pass adds: that is naive structure a
|
|
18036
|
+
later reset and refactor replaces. A pass that leaves a criterion's ID out
|
|
18037
|
+
of its tests, or whose test imports a file it added, is sent back
|
|
18038
|
+
- Run \`${env.testCommand}\` and finish only once it is green with every
|
|
18039
|
+
criterion's test passing: the next step runs it on your diff and sends a red
|
|
18040
|
+
pass back
|
|
18041
|
+
- Leave \`.gtd/\` untouched
|
|
18000
18042
|
- Leave everything uncommitted and finish your turn — do not commit
|
|
18001
|
-
${
|
|
18002
|
-
Your previous attempt was rejected: the judge answered \`reject\` (p=${rejection.p}),
|
|
18003
|
-
meaning the naive pass solved the task the wrong way — discard it and
|
|
18004
|
-
re-implement. It changed these files outside the expected set:
|
|
18005
|
-
${listed(rejection.strays)}
|
|
18006
|
-
Start over and choose a different approach; do not rebuild the same diff.
|
|
18007
|
-
`}`;
|
|
18043
|
+
${retry === void 0 ? "" : naiveRetry(retry)}`;
|
|
18008
18044
|
const implementBuildingPrompt = (reference, tier, amend, afterRefactor, flagMode = true) => `${stateFileRules}
|
|
18009
18045
|
- Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
|
|
18010
18046
|
${criteriaBinding(true)}
|
|
@@ -18029,8 +18065,41 @@ const implementFixPrompt = () => `${stateFileRules}
|
|
|
18029
18065
|
${fixFeedbackPrompt}
|
|
18030
18066
|
- Leave everything uncommitted and finish your turn — do not commit
|
|
18031
18067
|
`;
|
|
18032
|
-
const
|
|
18033
|
-
|
|
18068
|
+
const steerChoices = (overrule) => `Two ways forward (then run \`gtd land\`):
|
|
18069
|
+
- **Steer** — edit \`.gtd/REQUIREMENTS.md\` to redirect the change; the naive
|
|
18070
|
+
pass runs again with a fresh retry budget
|
|
18071
|
+
- **Overrule** — ${overrule}
|
|
18072
|
+
|
|
18073
|
+
An edit together with an approval counts as steer. Only
|
|
18074
|
+
\`.gtd/REQUIREMENTS.md\` is meant to change here: any other edit lands on the
|
|
18075
|
+
branch unjudged.
|
|
18076
|
+
`;
|
|
18077
|
+
const seamSteerMessage = (retry) => isFault(retry) ? `The naive pass failed three times and the retries are spent. The last
|
|
18078
|
+
attempt's tests (\`git show ${retry.hash}\`) fall short:
|
|
18079
|
+
${retry.fault}
|
|
18080
|
+
|
|
18081
|
+
${steerChoices(`land a clean tree to go on with the last attempt as it is:
|
|
18082
|
+
\`seam.check\` runs its suite and the seam judge rules on its shape. Use it
|
|
18083
|
+
when the rule cannot fit this change (tests the globs do not see, or no
|
|
18084
|
+
entry point that existed before). Risk: a criterion may have no test that
|
|
18085
|
+
survives a later refactor — the choice is recorded in history.`)}` : isRed(retry) ? `The naive pass failed three times and the retries are spent. The last
|
|
18086
|
+
attempt left the suite red: \`.gtd/FEEDBACK.md\` holds the last run's output; \`git show ${retry.red}\` shows
|
|
18087
|
+
the last attempt.
|
|
18088
|
+
|
|
18089
|
+
Two ways forward (then run \`gtd land\`):
|
|
18090
|
+
- **Steer** — edit \`.gtd/REQUIREMENTS.md\` to redirect the change; the naive
|
|
18091
|
+
pass runs again with a fresh retry budget
|
|
18092
|
+
- **Overrule** — land a clean tree to go on with the last attempt as it is:
|
|
18093
|
+
the seam judge rules on its shape, and the implementation's health loop
|
|
18094
|
+
must turn the suite green. Risk: the criteria's tests never passed on the
|
|
18095
|
+
naive pass — the choice is recorded in history.
|
|
18096
|
+
|
|
18097
|
+
An edit together with an approval counts as steer. Only
|
|
18098
|
+
\`.gtd/REQUIREMENTS.md\` is meant to change here: any other edit lands on the
|
|
18099
|
+
branch unjudged.
|
|
18100
|
+
` : seamRejectedMessage(retry);
|
|
18101
|
+
const seamRejectedMessage = (rejection) => `The naive pass failed three times and the retries are spent. The judge
|
|
18102
|
+
rejected the last attempt: it changed these files outside the expected set:
|
|
18034
18103
|
${listed(rejection.strays)}
|
|
18035
18104
|
|
|
18036
18105
|
Two ways forward (then run \`gtd land\`):
|
|
@@ -18106,11 +18175,16 @@ const builtInLenses = {
|
|
|
18106
18175
|
brief: specChallengeBrief
|
|
18107
18176
|
}
|
|
18108
18177
|
};
|
|
18178
|
+
const coderLenses = /* @__PURE__ */ new Set([
|
|
18179
|
+
"conventions",
|
|
18180
|
+
"ponytail-review",
|
|
18181
|
+
"test-audit"
|
|
18182
|
+
]);
|
|
18109
18183
|
/** One quality review through `lens`; each lens is its own scope, so its own conversation and skills. */
|
|
18110
|
-
const reviewQuality = (lens, base) => agentWithSkills(`quality.${lens}.reviewing`, buildQualityReviewingPrompt(lens, builtInLenses[lens]?.brief, base), {
|
|
18184
|
+
const reviewQuality = (lens, base, model) => agentWithSkills(`quality.${lens}.reviewing`, buildQualityReviewingPrompt(lens, builtInLenses[lens]?.brief, base), {
|
|
18111
18185
|
label: "Reviewing (one quality lens)",
|
|
18112
18186
|
file: QUALITY,
|
|
18113
|
-
model: planner(),
|
|
18187
|
+
model: model || (coderLenses.has(lens) ? coder() : planner()),
|
|
18114
18188
|
system: reviewerSystem(),
|
|
18115
18189
|
allowEmpty: true
|
|
18116
18190
|
});
|
|
@@ -37631,7 +37705,10 @@ const REVIEW_ORIG = ".gtd/REVIEW.orig.md";
|
|
|
37631
37705
|
* finding to `.gtd/QUALITY.md`. Resolves `"findings"` when that file
|
|
37632
37706
|
* has any.
|
|
37633
37707
|
*/
|
|
37634
|
-
const qualityLap = (guard, lenses, base) => qualityLap$1(lenses ??
|
|
37708
|
+
const qualityLap = (guard, lenses, base) => qualityLap$1(lenses ?? lensEntriesOf(vars.qualityReviews ?? ""), guarded((entry) => {
|
|
37709
|
+
const { lens, model } = lensOf(entry);
|
|
37710
|
+
return reviewQuality(lens, base, model);
|
|
37711
|
+
}, guard), QUALITY);
|
|
37635
37712
|
/** Resolves `true` once `.gtd/QUALITY.md` is resolved, `false` when the fix cap escalated instead. */
|
|
37636
37713
|
const fixQualityFindings = (escalations, guard, guardRests = true, rules) => fixFindings({
|
|
37637
37714
|
fix: guarded(() => fixQuality(rules), guard),
|
|
@@ -38437,7 +38514,7 @@ const reviewedRefactor = async (shape, options = {}) => {
|
|
|
38437
38514
|
green: true,
|
|
38438
38515
|
guard,
|
|
38439
38516
|
guardRests: false,
|
|
38440
|
-
lenses:
|
|
38517
|
+
lenses: lensEntriesOf(vars.refactorQualityReviews ?? ""),
|
|
38441
38518
|
feedback,
|
|
38442
38519
|
fixAccess: strayAccess(p.strays),
|
|
38443
38520
|
lensBase: entry,
|
|
@@ -38453,7 +38530,7 @@ const reviewedRefactor = async (shape, options = {}) => {
|
|
|
38453
38530
|
};
|
|
38454
38531
|
//#endregion
|
|
38455
38532
|
//#region src/workflows/feature/seam.ts
|
|
38456
|
-
/** Naive-pass retries after a `reject
|
|
38533
|
+
/** Naive-pass retries after a `reject` or a red suite; the next one rests at `seam.steer`. */
|
|
38457
38534
|
const NAIVE_RETRIES = 2;
|
|
38458
38535
|
/** A change to an existing file outside the allowlist is a stray even when the task needs it: the judge decides. */
|
|
38459
38536
|
const judgeSeam = (tip) => judgeDiffShape("judge", {
|
|
@@ -38461,12 +38538,95 @@ const judgeSeam = (tip) => judgeDiffShape("judge", {
|
|
|
38461
38538
|
allow: (vars.seamAllow ?? defaults.seamAllow).split(",").map((glob) => glob.trim()).filter((glob) => glob !== ""),
|
|
38462
38539
|
exempt: tip.changes.filter((c) => c.status === "added").map((c) => c.path)
|
|
38463
38540
|
});
|
|
38464
|
-
const naivePass = (
|
|
38465
|
-
label:
|
|
38541
|
+
const naivePass = (retry) => agentWithSkills("naive", naivePassPrompt(retry), {
|
|
38542
|
+
label: retry === void 0 ? "Writing the naive pass" : "Rewriting the naive pass",
|
|
38466
38543
|
keep: true,
|
|
38467
38544
|
model: coder(),
|
|
38468
38545
|
system: builderSystem()
|
|
38469
38546
|
});
|
|
38547
|
+
const IMPORTED = /(?:\bfrom|\bimport|\brequire\s*\(|\bmock\s*\()\s*\(?\s*(["'`])([^"'`\n]+)\1/g;
|
|
38548
|
+
const EXTENSION = /\.[\w-]+$/;
|
|
38549
|
+
const ALIAS = /^(?:[@~]\/|#)/;
|
|
38550
|
+
/** The specifiers an added `path` answers to: itself, without its extension, and its directory for an `index`. */
|
|
38551
|
+
const namesOf = (path) => {
|
|
38552
|
+
const bare = path.replace(EXTENSION, "");
|
|
38553
|
+
return posix.basename(bare) === "index" ? [
|
|
38554
|
+
path,
|
|
38555
|
+
bare,
|
|
38556
|
+
posix.dirname(bare)
|
|
38557
|
+
] : [path, bare];
|
|
38558
|
+
};
|
|
38559
|
+
/**
|
|
38560
|
+
* The repository paths `test` imports, each with and without an extension: a
|
|
38561
|
+
* relative specifier resolved from its directory, an aliased one (`@/`, `~/`,
|
|
38562
|
+
* `#`) as a path tail, anything else as written. Comments are skipped; a
|
|
38563
|
+
* computed import goes unseen.
|
|
38564
|
+
*/
|
|
38565
|
+
const importsOf = (test) => [...(test.after ?? "").replace(COMMENT, "").matchAll(IMPORTED)].flatMap(([, , spec]) => {
|
|
38566
|
+
const relative = /^\.\.?(\/|$)/.test(spec);
|
|
38567
|
+
const tail = ALIAS.test(spec);
|
|
38568
|
+
const path = relative ? posix.join(posix.dirname(test.path), spec) : spec.replace(ALIAS, "");
|
|
38569
|
+
return [path, path.replace(EXTENSION, "")].map((p) => ({
|
|
38570
|
+
path: p,
|
|
38571
|
+
tail
|
|
38572
|
+
}));
|
|
38573
|
+
});
|
|
38574
|
+
const reaches = (imports, added) => namesOf(added).some((name) => imports.some(({ path, tail }) => name === path || tail && name.endsWith(`/${path}`)));
|
|
38575
|
+
const named = (id, text) => new RegExp(`(?<![\\w-])${id}(?!\\d)`).test(text);
|
|
38576
|
+
/** The lines `change` added: an ID an earlier change left in the file does not count. */
|
|
38577
|
+
const addedLines = (change) => {
|
|
38578
|
+
const before = new Set((change.before ?? "").split("\n"));
|
|
38579
|
+
return change.after.split("\n").filter((line) => !before.has(line)).join("\n");
|
|
38580
|
+
};
|
|
38581
|
+
/**
|
|
38582
|
+
* Why `tip`'s tests fall short, or `undefined`: a criterion ID of `file` no
|
|
38583
|
+
* test line of the pass names, or a test importing a file the pass added —
|
|
38584
|
+
* naive structure a reset and refactor replaces, which the tests must survive.
|
|
38585
|
+
*/
|
|
38586
|
+
const acceptanceFault = (file, tip) => {
|
|
38587
|
+
const ids = (criteriaOf(read(file) ?? "") ?? []).map((c) => c.id).filter((id) => id !== "");
|
|
38588
|
+
const tests = tip.changes.filter((c) => c.after !== void 0 && isCode$1(c.path) && isTestFile(c.path));
|
|
38589
|
+
const lines = tests.map(addedLines);
|
|
38590
|
+
const untested = ids.filter((id) => !lines.some((text) => named(id, text)));
|
|
38591
|
+
if (untested.length > 0) return `acceptance-tests: no test of the naive pass names ${untested.join(", ")} — write a behaviour-level test for each criterion with its ID in the test or scenario name, e.g. "[${untested[0]}] …"`;
|
|
38592
|
+
const added = tip.changes.filter((c) => c.status === "added" && isCode$1(c.path) && !isTestFile(c.path)).map((c) => c.path);
|
|
38593
|
+
const reaching = tests.filter((test) => holdsCallSites(test.path)).flatMap((test) => {
|
|
38594
|
+
const imports = importsOf(test);
|
|
38595
|
+
return added.filter((path) => reaches(imports, path)).map((path) => ` - ${test.path} → ${path}`);
|
|
38596
|
+
});
|
|
38597
|
+
if (reaching.length > 0) return `acceptance-internals: these tests import files the naive pass added — test through code that existed before it (a public entry point, an existing module's exports):\n${reaching.join("\n")}`;
|
|
38598
|
+
};
|
|
38599
|
+
const restore = (path) => `if git cat-file -e ${quote(`HEAD:${path}`)} 2>/dev/null; then git checkout -q HEAD -- ${quote(path)}; else rm -f -- ${quote(path)}; fi`;
|
|
38600
|
+
/**
|
|
38601
|
+
* Run the suite with `tip`'s diff applied, then take it out again; resolves
|
|
38602
|
+
* `true` when green. The pass's paths go back to HEAD before (an interrupted
|
|
38603
|
+
* run left them applied) and after; afterwards so does anything else the run
|
|
38604
|
+
* changed, judged against what was already dirty, which is left alone.
|
|
38605
|
+
*/
|
|
38606
|
+
const naiveGreen = (tip) => {
|
|
38607
|
+
const paths = tip.changes.map((c) => restore(c.path));
|
|
38608
|
+
return check("check", [
|
|
38609
|
+
...paths,
|
|
38610
|
+
"dirty=$(git -c core.quotePath=false ls-files -m -d -o --exclude-standard)",
|
|
38611
|
+
`if ! git diff --binary ${quote(`${tip.hash}^`)} ${quote(tip.hash)} -- | git apply; then`,
|
|
38612
|
+
" echo 'gtd: the kept naive pass no longer applies to HEAD; no test ran'",
|
|
38613
|
+
" exit 1",
|
|
38614
|
+
"fi",
|
|
38615
|
+
"(",
|
|
38616
|
+
env.testCommand ?? "",
|
|
38617
|
+
")",
|
|
38618
|
+
"code=$?",
|
|
38619
|
+
...paths,
|
|
38620
|
+
"git -c core.quotePath=false ls-files -m -d -o --exclude-standard | while IFS= read -r path; do",
|
|
38621
|
+
" printf '%s\\n' \"$dirty\" | grep -qxF -- \"$path\" && continue",
|
|
38622
|
+
" if git cat-file -e \"HEAD:$path\" 2>/dev/null; then git checkout -q HEAD -- \"$path\"; else rm -f -- \"$path\"; fi",
|
|
38623
|
+
"done",
|
|
38624
|
+
"exit \"$code\""
|
|
38625
|
+
].join("\n"), {
|
|
38626
|
+
report: FEEDBACK,
|
|
38627
|
+
label: "Running the naive pass's tests"
|
|
38628
|
+
});
|
|
38629
|
+
};
|
|
38470
38630
|
function implement(first, tier, amend) {
|
|
38471
38631
|
const options = typeof first === "object" && first !== null ? first : {
|
|
38472
38632
|
reference: first,
|
|
@@ -38493,36 +38653,76 @@ function implement(first, tier, amend) {
|
|
|
38493
38653
|
await run("closing", removeScript([REQUIREMENTS]), { label: "Closing out the implementation" });
|
|
38494
38654
|
});
|
|
38495
38655
|
}
|
|
38496
|
-
|
|
38497
|
-
|
|
38656
|
+
/** Resolves `true` when the human steered (edited), `false` on a clean overrule. */
|
|
38657
|
+
const steer = async (retry) => {
|
|
38658
|
+
await human("steer", {
|
|
38659
|
+
message: seamSteerMessage(retry),
|
|
38660
|
+
label: "Steer or overrule the naive pass",
|
|
38661
|
+
file: REQUIREMENTS,
|
|
38662
|
+
acceptClean: true
|
|
38663
|
+
});
|
|
38664
|
+
if (changes().length === 0) return false;
|
|
38665
|
+
requireCriteria(REQUIREMENTS);
|
|
38666
|
+
return true;
|
|
38667
|
+
};
|
|
38668
|
+
/** Rejects, test faults and red checks share one budget; a steer or an overrule refills it. */
|
|
38669
|
+
const budget = () => {
|
|
38498
38670
|
let retries = 0;
|
|
38499
|
-
|
|
38500
|
-
await naivePass(rejection);
|
|
38501
|
-
const tip = kept("naive");
|
|
38502
|
-
const shape = await judgeSeam(tip);
|
|
38503
|
-
if (shape.choice === "land") return { reference: tip.hash };
|
|
38504
|
-
if (shape.choice === "refactor-first") return {
|
|
38505
|
-
reference: tip.hash,
|
|
38506
|
-
shape
|
|
38507
|
-
};
|
|
38508
|
-
rejection = {
|
|
38509
|
-
strays: shape.strays,
|
|
38510
|
-
p: shape.judgment?.answers.shape?.p ?? 0
|
|
38511
|
-
};
|
|
38671
|
+
return async (next) => {
|
|
38512
38672
|
if (retries < NAIVE_RETRIES) {
|
|
38513
38673
|
retries++;
|
|
38514
|
-
|
|
38674
|
+
return {
|
|
38675
|
+
kind: "retry",
|
|
38676
|
+
retry: next
|
|
38677
|
+
};
|
|
38515
38678
|
}
|
|
38516
|
-
await
|
|
38517
|
-
message: seamSteerMessage(rejection),
|
|
38518
|
-
label: "Steer or overrule the rejected naive pass",
|
|
38519
|
-
file: REQUIREMENTS,
|
|
38520
|
-
acceptClean: true
|
|
38521
|
-
});
|
|
38522
|
-
if (changes().length === 0) return { reference: tip.hash };
|
|
38523
|
-
requireCriteria(REQUIREMENTS);
|
|
38524
|
-
rejection = void 0;
|
|
38679
|
+
const steered = await steer(next);
|
|
38525
38680
|
retries = 0;
|
|
38681
|
+
if (read(".gtd/FEEDBACK.md") !== void 0) await run("clear", removeScript([FEEDBACK]), { label: "Clearing the red check" });
|
|
38682
|
+
return { kind: steered ? "steered" : "overruled" };
|
|
38683
|
+
};
|
|
38684
|
+
};
|
|
38685
|
+
/** Sends a pass whose tests fall short or run red back (`undefined`: it goes on, an overruled one as it is). */
|
|
38686
|
+
const vetted = async (tip, vetting) => {
|
|
38687
|
+
const fault = vetting.waived ? void 0 : acceptanceFault(REQUIREMENTS, tip);
|
|
38688
|
+
if (fault !== void 0) {
|
|
38689
|
+
const verdict = await vetting.again({
|
|
38690
|
+
fault,
|
|
38691
|
+
hash: tip.hash
|
|
38692
|
+
});
|
|
38693
|
+
if (verdict.kind !== "overruled") return verdict;
|
|
38694
|
+
vetting.waived = true;
|
|
38695
|
+
}
|
|
38696
|
+
if (await naiveGreen(tip)) return void 0;
|
|
38697
|
+
const verdict = await vetting.again({ red: tip.hash });
|
|
38698
|
+
return verdict.kind === "overruled" ? void 0 : verdict;
|
|
38699
|
+
};
|
|
38700
|
+
/** The judge's ruling on a vetted pass: the decision, or the next attempt's verdict. */
|
|
38701
|
+
const ruled = async (tip, again) => {
|
|
38702
|
+
const shape = await judgeSeam(tip);
|
|
38703
|
+
if (shape.choice === "land") return { reference: tip.hash };
|
|
38704
|
+
if (shape.choice === "refactor-first") return {
|
|
38705
|
+
reference: tip.hash,
|
|
38706
|
+
shape
|
|
38707
|
+
};
|
|
38708
|
+
const next = await again({
|
|
38709
|
+
strays: shape.strays,
|
|
38710
|
+
p: shape.judgment?.answers.shape?.p ?? 0
|
|
38711
|
+
});
|
|
38712
|
+
return next.kind === "overruled" ? { reference: tip.hash } : next;
|
|
38713
|
+
};
|
|
38714
|
+
const decide = () => scope("seam", async () => {
|
|
38715
|
+
const vetting = {
|
|
38716
|
+
again: budget(),
|
|
38717
|
+
waived: false
|
|
38718
|
+
};
|
|
38719
|
+
let retry;
|
|
38720
|
+
for (;;) {
|
|
38721
|
+
await naivePass(retry);
|
|
38722
|
+
const tip = kept("naive");
|
|
38723
|
+
const outcome = await vetted(tip, vetting) ?? await ruled(tip, vetting.again);
|
|
38724
|
+
if (!("kind" in outcome)) return outcome;
|
|
38725
|
+
retry = outcome.kind === "retry" ? outcome.retry : void 0;
|
|
38526
38726
|
}
|
|
38527
38727
|
});
|
|
38528
38728
|
const seamBuild = async (base) => {
|
|
@@ -38803,4 +39003,4 @@ var workflows_default = feature;
|
|
|
38803
39003
|
*/
|
|
38804
39004
|
const doors = {};
|
|
38805
39005
|
//#endregion
|
|
38806
|
-
export { ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, QUALITY, REFACTOR, REFACTOR_PACKAGE, REQUIREMENTS, REVIEW, REVIEW_ORIG, ShippedOptions, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, awaitReview, base, baseline, buildFeature, buildTail, builtInLenses, cleanup, cleanupFlow, closeReview, coder, collecting, criteriaOf, 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, implement, judgeSeam, lensesOf, naivePass, ordinaryStart, planAndBuild, planner, qualityLap, reUnwind, refactorCollecting, refactorFirst, refactorStrays, requireCriteria, requireCriteriaFrozen, requireNoNewFlags, requireNoTestFiles, requireTestCallSitesOnly, requireWithin, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewedRefactor, reviewing, rollout, rolloutFlow, seamBuild, skills, skipDesign, statedCriteria, steering, summary, tail, threads, triage, unwind };
|
|
39006
|
+
export { COMMENT, ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, QUALITY, REFACTOR, REFACTOR_PACKAGE, REQUIREMENTS, REVIEW, REVIEW_ORIG, ShippedOptions, acceptanceFault, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, awaitReview, base, baseline, buildFeature, buildTail, builtInLenses, cleanup, cleanupFlow, closeReview, coder, collecting, criteriaOf, 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, naivePass, ordinaryStart, planAndBuild, planner, qualityLap, reUnwind, refactorCollecting, refactorFirst, refactorStrays, requireCriteria, requireCriteriaFrozen, requireNoNewFlags, requireNoTestFiles, requireTestCallSitesOnly, requireWithin, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewedRefactor, reviewing, rollout, rolloutFlow, seamBuild, skills, skipDesign, statedCriteria, steering, summary, tail, threads, triage, unwind };
|
package/package.json
CHANGED
|
@@ -1,10 +1,34 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import { lensesOf, lensScopes } from "./quality.js"
|
|
2
|
+
import { lensEntriesOf, lensesOf, lensOf, lensScopes } from "./quality.js"
|
|
3
3
|
|
|
4
4
|
describe("lensesOf", () => {
|
|
5
5
|
it("splits on commas, trims, drops blanks", () => {
|
|
6
6
|
expect(lensesOf(" a ,, b,")).toEqual(["a", "b"])
|
|
7
7
|
})
|
|
8
|
+
|
|
9
|
+
it("cuts each entry's model hint off at its first =", () => {
|
|
10
|
+
expect(lensesOf("a = smart, plugin:b=vendor=x, c=")).toEqual(["a", "plugin:b", "c"])
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("drops an entry with a hint but no lens", () => {
|
|
14
|
+
expect(lensesOf("=smart, a")).toEqual(["a"])
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe("lensEntriesOf", () => {
|
|
19
|
+
it("keeps each entry whole, hint included", () => {
|
|
20
|
+
expect(lensEntriesOf(" a = smart ,, b, =x")).toEqual(["a = smart", "b"])
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe("lensOf", () => {
|
|
25
|
+
it("splits an entry at its first =, both sides trimmed", () => {
|
|
26
|
+
expect(lensOf(" plugin:b = vendor=x ")).toEqual({ lens: "plugin:b", model: "vendor=x" })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("an entry without = has a blank hint", () => {
|
|
30
|
+
expect(lensOf("a")).toEqual({ lens: "a", model: "" })
|
|
31
|
+
})
|
|
8
32
|
})
|
|
9
33
|
|
|
10
34
|
describe("lensScopes", () => {
|
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
import { read } from "../flows/index.js"
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
export interface LensEntry {
|
|
4
|
+
readonly lens: string
|
|
5
|
+
/** Blank when the entry carries none. */
|
|
6
|
+
readonly model: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Split at the first `=`: skill names may hold `:` (`plugin:skill`), never `=`.
|
|
10
|
+
export const lensOf = (entry: string): LensEntry => {
|
|
11
|
+
const at = entry.indexOf("=")
|
|
12
|
+
return at < 0
|
|
13
|
+
? { lens: entry.trim(), model: "" }
|
|
14
|
+
: { lens: entry.slice(0, at).trim(), model: entry.slice(at + 1).trim() }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Split a comma-separated lens list into whole entries, `<lens>` or `<lens>=<model>`: trimmed, blanks dropped. */
|
|
18
|
+
export const lensEntriesOf = (value: string): readonly string[] =>
|
|
5
19
|
value
|
|
6
20
|
.split(",")
|
|
7
|
-
.map((
|
|
8
|
-
.filter((
|
|
21
|
+
.map((entry) => entry.trim())
|
|
22
|
+
.filter((entry) => lensOf(entry).lens.length > 0)
|
|
23
|
+
|
|
24
|
+
/** The same list's lens names, model hints cut off. */
|
|
25
|
+
export const lensesOf = (value: string): readonly string[] =>
|
|
26
|
+
lensEntriesOf(value).map((entry) => lensOf(entry).lens)
|
|
9
27
|
|
|
10
28
|
/** One `<scope>.<lens>` entry per lens — the per-lens part of a skills or access export. */
|
|
11
29
|
export const lensScopes = <T>(
|
|
@@ -86,6 +86,14 @@ Feature: acceptance criteria carry stable IDs, and a sketch that states clear on
|
|
|
86
86
|
- AC-1: `greeting` reads "Hello, world".
|
|
87
87
|
"""
|
|
88
88
|
And step "design.gate.answer" changes nothing
|
|
89
|
+
And step "seam.check" changes nothing
|
|
90
|
+
And step "seam.naive" writes "src/acceptance.test.ts" with:
|
|
91
|
+
"""
|
|
92
|
+
it("[AC-1] holds", () => {})
|
|
93
|
+
it("[AC-2] holds", () => {})
|
|
94
|
+
it("[AC-3] holds", () => {})
|
|
95
|
+
it("[AC-7] holds", () => {})
|
|
96
|
+
"""
|
|
89
97
|
And step "seam.naive" writes "src/greeting.ts" with:
|
|
90
98
|
"""
|
|
91
99
|
export const greeting = "Hello, world"
|
|
@@ -57,7 +57,12 @@ Feature: a trivial change takes the same few beats at either risk tier, flag-fre
|
|
|
57
57
|
Then the process rests at "seam.naive"
|
|
58
58
|
And the last commit subject is "gtd(human): design.gate.answer → seam.naive"
|
|
59
59
|
|
|
60
|
-
Given step "seam.
|
|
60
|
+
Given step "seam.check" changes nothing
|
|
61
|
+
And step "seam.naive" writes "src/acceptance.test.ts" with:
|
|
62
|
+
"""
|
|
63
|
+
it("[AC-1] holds", () => {})
|
|
64
|
+
"""
|
|
65
|
+
And step "seam.naive" writes "src/greeting.ts" with:
|
|
61
66
|
"""
|
|
62
67
|
export const greeting = "Hello, world"
|
|
63
68
|
"""
|
|
@@ -99,7 +104,8 @@ Feature: a trivial change takes the same few beats at either risk tier, flag-fre
|
|
|
99
104
|
gtd(check): start-gate.check → design.triage
|
|
100
105
|
gtd(agent): design.triage → design.gate.answer
|
|
101
106
|
gtd(human): design.gate.answer → seam.naive
|
|
102
|
-
gtd(agent): seam.naive → seam.
|
|
107
|
+
gtd(agent): seam.naive → seam.check
|
|
108
|
+
gtd(check): seam.check → seam.judge
|
|
103
109
|
gtd(judge): seam.judge → implement.building
|
|
104
110
|
gtd(agent): implement.building → implement.health.check
|
|
105
111
|
gtd(check): implement.health.check → implement.closing
|
|
@@ -47,6 +47,8 @@ const review = (text?: string): string =>
|
|
|
47
47
|
|
|
48
48
|
const CALC = "export const add = (a: number, b: number) => a + b\n"
|
|
49
49
|
|
|
50
|
+
const ACCEPTANCE = 'it("[AC-1] done", () => {})\n'
|
|
51
|
+
|
|
50
52
|
const naivePlanned = () => {
|
|
51
53
|
const repo = testRepo({
|
|
52
54
|
workflow: CONFIG_SCAFFOLD,
|
|
@@ -77,7 +79,11 @@ describe("planAndBuild", () => {
|
|
|
77
79
|
"# Requirements\n\n## Concerns\n\n### S\n\n- `s`\n\n## Acceptance Criteria\n\n- AC-1: done\n",
|
|
78
80
|
),
|
|
79
81
|
"design.gate.answer": () => undefined,
|
|
80
|
-
"seam.naive": (_, r) =>
|
|
82
|
+
"seam.naive": (_, r) => {
|
|
83
|
+
r.write("src/calc.ts", SUBTRACT)
|
|
84
|
+
r.write("src/calc.test.ts", ACCEPTANCE)
|
|
85
|
+
},
|
|
86
|
+
"seam.check": () => undefined,
|
|
81
87
|
"seam.judge": () => '[{"id":"shape","answer":"refactor-first","p":0.9}]',
|
|
82
88
|
"refactor.deriving": runsItsScript,
|
|
83
89
|
"refactor.building": (_, r) => r.write("src/calc.ts", SUBTRACT),
|
|
@@ -112,7 +118,11 @@ describe("planAndBuild", () => {
|
|
|
112
118
|
"start-gate.check": () => undefined,
|
|
113
119
|
"design.triage": requirements,
|
|
114
120
|
"design.gate.answer": () => undefined,
|
|
115
|
-
"seam.naive": (_: unknown, r: typeof repo) =>
|
|
121
|
+
"seam.naive": (_: unknown, r: typeof repo) => {
|
|
122
|
+
r.write("src/calc.ts", `${CALC}// naive\n`)
|
|
123
|
+
r.write("src/calc.test.ts", ACCEPTANCE)
|
|
124
|
+
},
|
|
125
|
+
"seam.check": () => undefined,
|
|
116
126
|
"seam.judge": () => '[{"id":"shape","answer":"land","p":0.9}]',
|
|
117
127
|
"implement.building": (_: unknown, r: typeof repo) =>
|
|
118
128
|
r.write("src/calc.ts", `${CALC}// built\n`),
|
|
@@ -341,7 +351,10 @@ describe("an in-flight 28.0.0 naive-pass process", () => {
|
|
|
341
351
|
"# Requirements\n\n## Concerns\n\n### S\n\n- `s`\n\n## Acceptance Criteria\n\n- AC-1: done\n",
|
|
342
352
|
),
|
|
343
353
|
"design.gate.answer": () => undefined,
|
|
344
|
-
"seam.naive": (_, r) =>
|
|
354
|
+
"seam.naive": (_, r) => {
|
|
355
|
+
r.write("src/calc.ts", `${CALC}// naive\n`)
|
|
356
|
+
r.write("src/calc.test.ts", ACCEPTANCE)
|
|
357
|
+
},
|
|
345
358
|
})
|
|
346
359
|
const kept = /Gtd-Kept: (\w+)/.exec(repo.messages().at(-1)!)![1]!
|
|
347
360
|
const seen: Record<string, number> = {
|
|
@@ -366,16 +379,13 @@ describe("an in-flight 28.0.0 naive-pass process", () => {
|
|
|
366
379
|
return repo
|
|
367
380
|
}
|
|
368
381
|
|
|
382
|
+
// seam.check now follows every naive pass, so no rest past the first naive pass replays.
|
|
369
383
|
for (const [name, trail] of Object.entries(trails)) {
|
|
370
|
-
|
|
371
|
-
it(`${name}: ${replays ? "every rest replays to itself" : "every rest from the verdict on fails replay"}`, async () => {
|
|
384
|
+
it(`${name}: every rest past the naive pass fails replay`, async () => {
|
|
372
385
|
for (let upTo = 1; upTo <= trail.length; upTo++) {
|
|
373
386
|
const rest = trail[upTo - 1]![2]
|
|
374
387
|
const repo = await forged(trail, upTo)
|
|
375
|
-
|
|
376
|
-
else {
|
|
377
|
-
await expect(repo.next(), rest).rejects.toThrow("the workflow changed under this process")
|
|
378
|
-
}
|
|
388
|
+
await expect(repo.next(), rest).rejects.toThrow("the workflow changed under this process")
|
|
379
389
|
}
|
|
380
390
|
})
|
|
381
391
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
@inmem
|
|
2
|
+
Feature: each quality lens runs on its own model hint
|
|
3
|
+
|
|
4
|
+
A `qualityReviews` entry may carry a model hint, `<lens>=<model>`. A lens
|
|
5
|
+
without one runs on the planner model (`smart`), except `conventions`,
|
|
6
|
+
`ponytail-review` and `test-audit`, which run on the coder model (`base`).
|
|
7
|
+
|
|
8
|
+
Background:
|
|
9
|
+
Given a gtd config with:
|
|
10
|
+
"""
|
|
11
|
+
import { feature } from "@pmelab/gtd/workflows/feature"
|
|
12
|
+
export { summary, steering, skills, access } from "@pmelab/gtd/workflows/feature"
|
|
13
|
+
export default feature({
|
|
14
|
+
flagMode: false,
|
|
15
|
+
qualityReviews: "correctness, conventions, test-audit=opus",
|
|
16
|
+
})
|
|
17
|
+
"""
|
|
18
|
+
And a file "src/calc.ts" with:
|
|
19
|
+
"""
|
|
20
|
+
export const add = (a: number, b: number) => a + b
|
|
21
|
+
"""
|
|
22
|
+
And the working tree is committed as "feat: add calculator"
|
|
23
|
+
And a file "TODO.md" with:
|
|
24
|
+
"""
|
|
25
|
+
# Add subtract
|
|
26
|
+
|
|
27
|
+
subtract(a, b) returns the difference.
|
|
28
|
+
"""
|
|
29
|
+
And step "unwind" changes nothing
|
|
30
|
+
And step "start-gate.check" changes nothing
|
|
31
|
+
And step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
|
|
32
|
+
"""
|
|
33
|
+
# Requirements
|
|
34
|
+
|
|
35
|
+
## Acceptance Criteria
|
|
36
|
+
|
|
37
|
+
- AC-1: `subtract(a, b)` returns `a - b`.
|
|
38
|
+
"""
|
|
39
|
+
And step "design.gate.answer" changes nothing
|
|
40
|
+
And step "implement.building" writes "src/calc.ts" with:
|
|
41
|
+
"""
|
|
42
|
+
export const add = (a: number, b: number) => a + b
|
|
43
|
+
export const subtract = (a: number, b: number) => a - b
|
|
44
|
+
"""
|
|
45
|
+
And step "implement.health.check" changes nothing
|
|
46
|
+
And step "implement.closing" runs its script
|
|
47
|
+
|
|
48
|
+
Scenario: a hinted lens runs on its hint, every other lens on its default
|
|
49
|
+
When the process is driven
|
|
50
|
+
Then the process rests at "build.quality.correctness.reviewing"
|
|
51
|
+
And the output of gtd "next --json" contains "\"model\":\"smart\""
|
|
52
|
+
Given step "build.quality.correctness.reviewing" changes nothing
|
|
53
|
+
When the process is driven
|
|
54
|
+
Then the process rests at "build.quality.conventions.reviewing"
|
|
55
|
+
And the output of gtd "next --json" contains "\"model\":\"base\""
|
|
56
|
+
Given step "build.quality.conventions.reviewing" changes nothing
|
|
57
|
+
When the process is driven
|
|
58
|
+
Then the process rests at "build.quality.test-audit.reviewing"
|
|
59
|
+
And the output of gtd "next --json" contains "\"model\":\"opus\""
|
|
60
|
+
And the resting step's content contains "test-audit"
|