@pmelab/gtd 27.1.0 → 27.3.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 +16 -9
- package/dist/chunks/registry-CP3YuV9j.mjs +101 -0
- package/dist/chunks/{reserved-BL9krPao.mjs → reserved-BPdVZkgv.mjs} +66 -73
- package/dist/chunks/tail-iLAMoigl.mjs +81 -0
- package/dist/gtd.bundle.mjs +5 -2
- package/dist/testkit.mjs +17 -2
- package/dist/workflows/cleanup.mjs +37 -0
- package/dist/workflows/feature.mjs +119 -5
- package/dist/workflows/fix.mjs +2 -1
- package/dist/workflows/review.mjs +2 -1
- package/dist/workflows/rollout.mjs +33 -0
- package/dist/workflows.mjs +362 -25
- package/package.json +11 -1
- package/src/flags/index.test.ts +28 -0
- package/src/flags/index.ts +9 -4
- package/src/testkit/EmittedScriptRecognizer.ts +21 -0
- package/src/testkit/EmittedScripts.test.ts +9 -0
- package/src/testkit/steps.ts +26 -0
- package/src/testkit/testkit.feature +8 -1
- package/src/workflows/cleanup/cleanup.feature +323 -0
- package/src/workflows/cleanup/cleanup.test.ts +272 -0
- package/src/workflows/cleanup/cleanup.ts +52 -0
- package/src/workflows/cleanup/index.ts +6 -0
- package/src/workflows/feature/index.ts +1 -0
- package/src/workflows/feature/refactor.test.ts +466 -0
- package/src/workflows/feature/refactor.ts +178 -0
- package/src/workflows/index.ts +4 -1
- package/src/workflows/rollout/index.ts +6 -0
- package/src/workflows/rollout/rollout.feature +214 -0
- package/src/workflows/rollout/rollout.test.ts +167 -0
- package/src/workflows/rollout/rollout.ts +44 -0
- package/src/workflows/shared/access.ts +2 -1
- package/src/workflows/shared/health.test.ts +103 -0
- package/src/workflows/shared/health.ts +32 -7
- package/src/workflows/shared/options.test.ts +30 -0
- package/src/workflows/shared/options.ts +20 -0
- package/src/workflows/shared/registry.test.ts +305 -0
- package/src/workflows/shared/registry.ts +158 -0
- package/src/workflows/shared/skills.test.ts +6 -0
- package/src/workflows/shared/skills.ts +5 -1
- package/src/workflows/shared/tail.test.ts +36 -2
- package/src/workflows/shared/tail.ts +30 -18
- package/src/workflows/shared/text.ts +35 -0
- package/src/workflows/shared/vars.ts +1 -0
package/dist/workflows.mjs
CHANGED
|
@@ -250,9 +250,9 @@ const check = async (name, command, options) => {
|
|
|
250
250
|
}), { label });
|
|
251
251
|
return !wrote(report);
|
|
252
252
|
};
|
|
253
|
-
const isCode$
|
|
253
|
+
const isCode$2 = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
|
|
254
254
|
/** The last step's changes outside `.gtd/`. */
|
|
255
|
-
const codeChanges = () => changes().filter((c) => isCode$
|
|
255
|
+
const codeChanges = () => changes().filter((c) => isCode$2(c.path));
|
|
256
256
|
/** Refuse a turn whose only change deletes `file` — unless it says it found nothing to do. */
|
|
257
257
|
const requireProgress = (file) => {
|
|
258
258
|
const change = changes().get(file);
|
|
@@ -17235,6 +17235,26 @@ ${fixFeedbackPrompt}
|
|
|
17235
17235
|
then legitimately report itself already satisfied later
|
|
17236
17236
|
- Leave everything uncommitted and finish your turn — do not commit
|
|
17237
17237
|
`;
|
|
17238
|
+
const refactorRules = (strays, pattern) => `- Behaviour-neutral: the suite's outcome must not change
|
|
17239
|
+
- Change only these paths, nothing else — a test file only when it is
|
|
17240
|
+
one of them:
|
|
17241
|
+
${strays.map((path) => ` - \`${path}\``).join("\n")}
|
|
17242
|
+
- Introduce no new flag read and add no second read of an existing one:
|
|
17243
|
+
every match of \`${pattern}\` may move, never increase in number`;
|
|
17244
|
+
const refactorBuildingPrompt = (pkg, strays, pattern, reference) => `${stateFileRules}
|
|
17245
|
+
- The package to implement is \`${pkg}\`; never delete it
|
|
17246
|
+
${refactorRules(strays, pattern)}
|
|
17247
|
+
${reference === void 0 ? "" : `- Reference only: \`git show ${reference} -- ${strays.map(quote).join(" ")}\` shows an
|
|
17248
|
+
earlier attempt at this change. Read it for ideas; never apply,
|
|
17249
|
+
cherry-pick or check it out — write a fresh refactor
|
|
17250
|
+
`}- Leave everything uncommitted and finish your turn — do not commit
|
|
17251
|
+
`;
|
|
17252
|
+
const refactorFixPrompt = (strays, pattern) => `${stateFileRules}
|
|
17253
|
+
${fixFeedbackPrompt}
|
|
17254
|
+
${refactorRules(strays, pattern)}
|
|
17255
|
+
- Never do another package's work, even when it would green the suite
|
|
17256
|
+
- Leave everything uncommitted and finish your turn — do not commit
|
|
17257
|
+
`;
|
|
17238
17258
|
const healthJudgeMessage = () => `The check is still red, and this isn't the first round —
|
|
17239
17259
|
\`.gtd/FEEDBACK.md\` holds this round's output, and the previous round's
|
|
17240
17260
|
is in history. Run \`gtd judge answer\` and pipe a
|
|
@@ -17805,12 +17825,22 @@ const escalationSteps = {
|
|
|
17805
17825
|
stop: escalate,
|
|
17806
17826
|
exhausted: escalationExhausted
|
|
17807
17827
|
};
|
|
17828
|
+
const guarded = (turn, guard) => async (...a) => {
|
|
17829
|
+
await turn(...a);
|
|
17830
|
+
guard?.();
|
|
17831
|
+
};
|
|
17832
|
+
const guardedSteps = (guard, describeGuard) => ({
|
|
17833
|
+
limit: escalationSteps.limit,
|
|
17834
|
+
describe: guarded(guarded(escalationSteps.describe, guard), describeGuard),
|
|
17835
|
+
stop: guarded(escalationSteps.stop, guard),
|
|
17836
|
+
exhausted: guarded(escalationSteps.exhausted, guard)
|
|
17837
|
+
});
|
|
17808
17838
|
/**
|
|
17809
17839
|
* Hand a red suite that repeated attempts could not fix to a person: an
|
|
17810
17840
|
* agent writes `.gtd/ESCALATION.md`, a human narrows it. From the third round
|
|
17811
17841
|
* on, the human is told the budget is spent instead.
|
|
17812
17842
|
*/
|
|
17813
|
-
const escalation = (count) => escalation$1(count,
|
|
17843
|
+
const escalation = (count, guard) => escalation$1(count, guardedSteps(guard));
|
|
17814
17844
|
/** "Is this red round the same failure as the last one?" — asked of two reports, or of nothing when one is empty. */
|
|
17815
17845
|
const retryQuestion = (comparable) => comparable ? {
|
|
17816
17846
|
id: "verdict",
|
|
@@ -17846,21 +17876,25 @@ const runSuite = (options) => check("health.check", env.testCommand ?? "", {
|
|
|
17846
17876
|
* first once `FIX_CAP` fixes are spent, or a judge calls the failure
|
|
17847
17877
|
* identical to the previous round's.
|
|
17848
17878
|
*/
|
|
17849
|
-
const healthy = (fix, options = {}) => healthy$1(fix, {
|
|
17879
|
+
const healthy = (fix, options = {}) => healthy$1(guarded(fix, options.guard), {
|
|
17850
17880
|
check: () => runSuite(options),
|
|
17851
17881
|
report: FEEDBACK,
|
|
17852
17882
|
fixCap: 3,
|
|
17853
17883
|
fixesSoFar: options.fixesSoFar ?? 0,
|
|
17854
17884
|
sameFailure,
|
|
17855
17885
|
escalations: options.escalations ?? { rounds: 0 },
|
|
17856
|
-
escalation:
|
|
17886
|
+
escalation: guardedSteps(options.guard, options.describeGuard)
|
|
17857
17887
|
});
|
|
17858
|
-
/**
|
|
17859
|
-
|
|
17888
|
+
/**
|
|
17889
|
+
* Hold the process at `<name>.blocked` until the suite is green. `guard` runs
|
|
17890
|
+
* after each `blocked` landing only — never after a check, whose refusal would
|
|
17891
|
+
* re-run the same script forever.
|
|
17892
|
+
*/
|
|
17893
|
+
const gate = (name, message, guard) => gate$1(name, () => baseline("check"), guarded(() => human("blocked", {
|
|
17860
17894
|
message,
|
|
17861
17895
|
label: "Baseline is red",
|
|
17862
17896
|
file: FEEDBACK
|
|
17863
|
-
}));
|
|
17897
|
+
}), guard));
|
|
17864
17898
|
//#endregion
|
|
17865
17899
|
//#region node_modules/effect/dist/esm/Function.js
|
|
17866
17900
|
/**
|
|
@@ -37184,6 +37218,7 @@ const defaults = {
|
|
|
37184
37218
|
judgeIdenticalMinP: "0.7",
|
|
37185
37219
|
reviewNoteActionable: "0.7",
|
|
37186
37220
|
reviewSeverityMinP: "0.7",
|
|
37221
|
+
refactorFlagPattern: "\\bGTD_FLAG_\\w+",
|
|
37187
37222
|
qualityReviews: "correctness, owasp-security, ponytail-review, test-audit, conventions, spec-challenge"
|
|
37188
37223
|
};
|
|
37189
37224
|
/** Environment settings. */
|
|
@@ -37201,6 +37236,7 @@ const ShippedOptions = Struct({
|
|
|
37201
37236
|
judgeIdenticalMinP: setting,
|
|
37202
37237
|
reviewNoteActionable: setting,
|
|
37203
37238
|
reviewSeverityMinP: setting,
|
|
37239
|
+
refactorFlagPattern: setting,
|
|
37204
37240
|
qualityReviews: setting,
|
|
37205
37241
|
testCommand: setting,
|
|
37206
37242
|
plannerModel: setting,
|
|
@@ -37221,6 +37257,27 @@ const shipped = (flow, doors, base) => defineWorkflow(standardSchemaV1(ShippedOp
|
|
|
37221
37257
|
doors,
|
|
37222
37258
|
base
|
|
37223
37259
|
}));
|
|
37260
|
+
const FlagOptions = Struct({
|
|
37261
|
+
...ShippedOptions.fields,
|
|
37262
|
+
flagRegistry: setting
|
|
37263
|
+
});
|
|
37264
|
+
const flagShipped = (flow) => defineWorkflow(standardSchemaV1(FlagOptions, {
|
|
37265
|
+
onExcessProperty: "error",
|
|
37266
|
+
errors: "all"
|
|
37267
|
+
}), (options) => {
|
|
37268
|
+
const flagDefaults = {
|
|
37269
|
+
...defaults,
|
|
37270
|
+
flag: "",
|
|
37271
|
+
flagRegistry: "src/flags/index.ts"
|
|
37272
|
+
};
|
|
37273
|
+
return {
|
|
37274
|
+
flow,
|
|
37275
|
+
defaults: over(flagDefaults, options),
|
|
37276
|
+
envDefaults: over(envDefaults, options),
|
|
37277
|
+
doors: {},
|
|
37278
|
+
base: void 0
|
|
37279
|
+
};
|
|
37280
|
+
});
|
|
37224
37281
|
//#endregion
|
|
37225
37282
|
//#region src/workflows/shared/tail.ts
|
|
37226
37283
|
/** The reviewer's own `.gtd/REVIEW.md`, kept beside the human's notes so `fix` can tell them apart. */
|
|
@@ -37230,13 +37287,13 @@ const REVIEW_ORIG = ".gtd/REVIEW.orig.md";
|
|
|
37230
37287
|
* finding to `.gtd/QUALITY.md`. Resolves `"findings"` when that file
|
|
37231
37288
|
* has any.
|
|
37232
37289
|
*/
|
|
37233
|
-
const qualityLap = () => qualityLap$1(lensesOf(vars.qualityReviews ?? ""), reviewQuality, QUALITY);
|
|
37290
|
+
const qualityLap = (guard) => qualityLap$1(lensesOf(vars.qualityReviews ?? ""), guarded(reviewQuality, guard), QUALITY);
|
|
37234
37291
|
/** Resolves `true` once `.gtd/QUALITY.md` is resolved, `false` when the fix cap escalated instead. */
|
|
37235
|
-
const fixQualityFindings = (escalations) => fixFindings({
|
|
37236
|
-
fix: fixQuality,
|
|
37292
|
+
const fixQualityFindings = (escalations, guard) => fixFindings({
|
|
37293
|
+
fix: guarded(fixQuality, guard),
|
|
37237
37294
|
findings: QUALITY,
|
|
37238
37295
|
cap: 3,
|
|
37239
|
-
escalate: () => escalation(escalations)
|
|
37296
|
+
escalate: () => escalation(escalations, guard)
|
|
37240
37297
|
});
|
|
37241
37298
|
/** `.gtd/REVIEW.md` removed at the end of a round at commit `round`. */
|
|
37242
37299
|
const closeReview = (round) => run("review.closing", removeScript([REVIEW, REVIEW_ORIG]), {
|
|
@@ -37252,19 +37309,26 @@ const refusePendingHandOff = () => {
|
|
|
37252
37309
|
* `finish` — the human review, as the calling workflow runs it. `green`: the
|
|
37253
37310
|
* caller just left the full suite green (the package loop), so no run opens it.
|
|
37254
37311
|
*/
|
|
37255
|
-
const tail = (fixFirst, finish, green = false) => scope("build", async () => {
|
|
37312
|
+
const tail = (fixFirst, finish, green = false, guard) => scope("build", async () => {
|
|
37256
37313
|
const escalations = { rounds: 0 };
|
|
37257
37314
|
const fix = fixCheck;
|
|
37258
37315
|
const repair = async () => {
|
|
37259
|
-
await fix();
|
|
37316
|
+
await guarded(fix, guard)();
|
|
37260
37317
|
await healthy(fix, {
|
|
37261
37318
|
fixesSoFar: 1,
|
|
37262
|
-
escalations
|
|
37319
|
+
escalations,
|
|
37320
|
+
guard
|
|
37263
37321
|
});
|
|
37264
37322
|
};
|
|
37265
37323
|
if (fixFirst) await repair();
|
|
37266
|
-
else if (!green) await healthy(fix, {
|
|
37267
|
-
|
|
37324
|
+
else if (!green) await healthy(fix, {
|
|
37325
|
+
escalations,
|
|
37326
|
+
guard
|
|
37327
|
+
});
|
|
37328
|
+
if (await qualityLap(guard) === "findings") if (await fixQualityFindings(escalations, guard)) await healthy(fix, {
|
|
37329
|
+
escalations,
|
|
37330
|
+
guard
|
|
37331
|
+
});
|
|
37268
37332
|
else await repair();
|
|
37269
37333
|
return finish(escalations);
|
|
37270
37334
|
});
|
|
@@ -37273,11 +37337,14 @@ const tail = (fixFirst, finish, green = false) => scope("build", async () => {
|
|
|
37273
37337
|
* `.gtd/REVIEW.md`; a note leaves it, with the reviewer's own version as
|
|
37274
37338
|
* `REVIEW_ORIG`, for the next `fix` to read — never a re-plan.
|
|
37275
37339
|
*/
|
|
37276
|
-
const handOffReview = (base, escalations) => reviewToHandOff(base, {
|
|
37340
|
+
const handOffReview = (base, escalations, guard) => reviewToHandOff(base, {
|
|
37277
37341
|
file: REVIEW,
|
|
37278
|
-
reviewing,
|
|
37279
|
-
fixRisks,
|
|
37280
|
-
keepGreen: () => healthy(fixCheck, {
|
|
37342
|
+
reviewing: guarded(reviewing, guard),
|
|
37343
|
+
fixRisks: guarded(fixRisks, guard),
|
|
37344
|
+
keepGreen: () => healthy(fixCheck, {
|
|
37345
|
+
escalations,
|
|
37346
|
+
guard
|
|
37347
|
+
}),
|
|
37281
37348
|
awaitReview: (b) => awaitReview(b, handOffAwaitReviewMessage(b)),
|
|
37282
37349
|
reviewMissing,
|
|
37283
37350
|
close: closeReview,
|
|
@@ -37287,7 +37354,7 @@ const handOffReview = (base, escalations) => reviewToHandOff(base, {
|
|
|
37287
37354
|
deleted: "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note to hand to fix."
|
|
37288
37355
|
});
|
|
37289
37356
|
/** The tail the review and fix workflows run: ends at the hand-off review since `base`. */
|
|
37290
|
-
const handOffTail = (fixFirst, base) => tail(fixFirst, (escalations) => handOffReview(base, escalations));
|
|
37357
|
+
const handOffTail = (fixFirst, base, guard) => tail(fixFirst, (escalations) => handOffReview(base, escalations, guard), false, guard);
|
|
37291
37358
|
//#endregion
|
|
37292
37359
|
//#region src/workflows/feature/steps.ts
|
|
37293
37360
|
/** Turn the sketch since `base` into `.gtd/REQUIREMENTS.md`. */
|
|
@@ -37569,7 +37636,7 @@ const triage$1 = (severities) => async (notes) => {
|
|
|
37569
37636
|
return [n.id, severity.tier === "could" ? "defer" : "edit"];
|
|
37570
37637
|
}));
|
|
37571
37638
|
};
|
|
37572
|
-
const isCode = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
|
|
37639
|
+
const isCode$1 = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
|
|
37573
37640
|
/**
|
|
37574
37641
|
* A reviewer writes `.gtd/REVIEW.md` over everything since `base`, a human
|
|
37575
37642
|
* reviews and signs off or comments, and each note is judged: edits go to
|
|
@@ -37582,7 +37649,7 @@ const review$1 = (base, escalations = { rounds: 0 }) => {
|
|
|
37582
37649
|
return review$2(base, {
|
|
37583
37650
|
file: REVIEW,
|
|
37584
37651
|
feedback: REQUIREMENTS,
|
|
37585
|
-
isCode,
|
|
37652
|
+
isCode: isCode$1,
|
|
37586
37653
|
reviewing: (b, carry) => {
|
|
37587
37654
|
deferred = [];
|
|
37588
37655
|
return reviewing(b, carry);
|
|
@@ -37711,6 +37778,9 @@ const skills = (vars) => ({
|
|
|
37711
37778
|
"build.review.fix.nits": ["incremental-implementation", "code-simplification"],
|
|
37712
37779
|
"build.review.fix.risks": ["debugging-and-error-recovery", "incremental-implementation"],
|
|
37713
37780
|
notes: ["incremental-implementation", "code-simplification"],
|
|
37781
|
+
refactor: ["ponytail"],
|
|
37782
|
+
"refactor.fix.suite": ["ponytail", "debugging-and-error-recovery"],
|
|
37783
|
+
"refactor.health": ["ponytail", "debugging-and-error-recovery"],
|
|
37714
37784
|
...lensScopes("build.quality", lensesOf(vars.qualityReviews ?? ""), (lens) => builtInLenses[lens]?.skills ?? [lens])
|
|
37715
37785
|
});
|
|
37716
37786
|
//#endregion
|
|
@@ -37734,6 +37804,119 @@ const steering = {
|
|
|
37734
37804
|
[REVIEW]: "review"
|
|
37735
37805
|
};
|
|
37736
37806
|
//#endregion
|
|
37807
|
+
//#region src/workflows/feature/refactor.ts
|
|
37808
|
+
const REFACTOR_PACKAGE = ".gtd/REFACTOR.md";
|
|
37809
|
+
const isCode = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
|
|
37810
|
+
const requireWithin = (allowed) => {
|
|
37811
|
+
const outside = changes().map((c) => c.path).filter((path) => isCode(path) && !allowed.includes(path));
|
|
37812
|
+
if (outside.length === 0) return;
|
|
37813
|
+
refuse(`gtd land: refactor-scope: the refactor may change only its stray paths — revert these:\n${outside.map((path) => ` - ${path}`).join("\n")}`);
|
|
37814
|
+
};
|
|
37815
|
+
const flagExpression = (pattern) => {
|
|
37816
|
+
if (pattern.trim() === "") throw new Error("gtd: refactorFlagPattern must not be blank");
|
|
37817
|
+
let expression;
|
|
37818
|
+
try {
|
|
37819
|
+
expression = new RegExp(pattern, "g");
|
|
37820
|
+
} catch (error) {
|
|
37821
|
+
throw new Error(`gtd: refactorFlagPattern is not a valid regular expression: ${String(error)}`);
|
|
37822
|
+
}
|
|
37823
|
+
if (expression.test("")) throw new Error("gtd: refactorFlagPattern must not match the empty string");
|
|
37824
|
+
return expression;
|
|
37825
|
+
};
|
|
37826
|
+
const countFlags = (text, expression, into) => {
|
|
37827
|
+
let zeroLength = false;
|
|
37828
|
+
for (const [name] of text.matchAll(expression)) if (name === "") zeroLength = true;
|
|
37829
|
+
else into.set(name, (into.get(name) ?? 0) + 1);
|
|
37830
|
+
return zeroLength;
|
|
37831
|
+
};
|
|
37832
|
+
const listPaths = (paths) => paths.map((path) => ` - ${path}`).join("\n");
|
|
37833
|
+
const requireNoNewFlags = (pattern, since) => {
|
|
37834
|
+
const expression = flagExpression(pattern);
|
|
37835
|
+
const before = /* @__PURE__ */ new Map();
|
|
37836
|
+
const after = /* @__PURE__ */ new Map();
|
|
37837
|
+
const unreadable = [];
|
|
37838
|
+
const zeroLength = [];
|
|
37839
|
+
for (const { path, status, before: was, after: now } of changesSince(since)) {
|
|
37840
|
+
if (!isCode(path)) continue;
|
|
37841
|
+
const oldSide = status === "added" ? "" : was;
|
|
37842
|
+
const newSide = status === "deleted" ? "" : now;
|
|
37843
|
+
if (oldSide === void 0 || newSide === void 0) {
|
|
37844
|
+
unreadable.push(path);
|
|
37845
|
+
continue;
|
|
37846
|
+
}
|
|
37847
|
+
countFlags(oldSide, expression, before);
|
|
37848
|
+
if (countFlags(newSide, expression, after)) zeroLength.push(path);
|
|
37849
|
+
}
|
|
37850
|
+
if (unreadable.length > 0) refuse(`gtd land: refactor-flags: cannot compare flag reads in:\n${listPaths(unreadable)}`);
|
|
37851
|
+
if (zeroLength.length > 0) refuse(`gtd land: refactor-flags: refactorFlagPattern (${pattern}) produced a zero-length match in:\n${listPaths(zeroLength)}`);
|
|
37852
|
+
const raised = [...after].filter(([name, count]) => count > (before.get(name) ?? 0));
|
|
37853
|
+
if (raised.length === 0) return;
|
|
37854
|
+
refuse(`gtd land: refactor-flags: the refactor may not introduce or add reads of a flag (${pattern}):\n${raised.map(([name, count]) => ` - ${name}: ${before.get(name) ?? 0} → ${count}`).join("\n")}`);
|
|
37855
|
+
};
|
|
37856
|
+
const packageLines = (strays, pattern, reference) => [
|
|
37857
|
+
"# Behaviour-neutral refactor",
|
|
37858
|
+
"",
|
|
37859
|
+
"## Scope (read-only)",
|
|
37860
|
+
"",
|
|
37861
|
+
...strays.map((path) => `- \`${path}\``),
|
|
37862
|
+
"",
|
|
37863
|
+
"This list is fixed when the package is derived: editing it here widens or narrows nothing.",
|
|
37864
|
+
"",
|
|
37865
|
+
"## Rules",
|
|
37866
|
+
"",
|
|
37867
|
+
"- The suite's outcome must not change",
|
|
37868
|
+
"- Change only the paths above; a test file only when it is one of them",
|
|
37869
|
+
`- Introduce no flag read and add no second read of an existing one (\`${pattern}\`)`,
|
|
37870
|
+
"",
|
|
37871
|
+
"## Reference",
|
|
37872
|
+
"",
|
|
37873
|
+
reference === void 0 ? "None." : `Reading material only, never applied: \`git show ${reference} -- ${strays.map(quote).join(" ")}\``,
|
|
37874
|
+
""
|
|
37875
|
+
];
|
|
37876
|
+
const deriveScript = (lines) => `mkdir -p .gtd
|
|
37877
|
+
printf '%s\\n' ${lines.map(quote).join(" ")} > ${quote(REFACTOR_PACKAGE)}
|
|
37878
|
+
`;
|
|
37879
|
+
const refactorFirst = async (shape, options = {}) => {
|
|
37880
|
+
if (shape.choice !== "refactor-first" || shape.unreadable.length > 0) return false;
|
|
37881
|
+
const pattern = vars.refactorFlagPattern ?? defaults.refactorFlagPattern;
|
|
37882
|
+
const expression = flagExpression(pattern);
|
|
37883
|
+
const { strays } = shape;
|
|
37884
|
+
const zeroLength = strays.filter((path) => countFlags(read(path) ?? "", expression, /* @__PURE__ */ new Map()));
|
|
37885
|
+
if (zeroLength.length > 0) throw new Error(`gtd: refactorFlagPattern (${pattern}) produces a zero-length match in:\n${listPaths(zeroLength)}`);
|
|
37886
|
+
const reference = options.reference?.trim() ? options.reference : void 0;
|
|
37887
|
+
return scope({
|
|
37888
|
+
name: "refactor",
|
|
37889
|
+
access: { write: [...strays, ".gtd/**"] }
|
|
37890
|
+
}, async () => {
|
|
37891
|
+
await run("deriving", deriveScript(packageLines(strays, pattern, reference)), {
|
|
37892
|
+
label: "Deriving the refactor package",
|
|
37893
|
+
file: REFACTOR_PACKAGE
|
|
37894
|
+
});
|
|
37895
|
+
const from = head$1();
|
|
37896
|
+
const guard = () => {
|
|
37897
|
+
requireWithin(strays);
|
|
37898
|
+
requireNoNewFlags(pattern, from);
|
|
37899
|
+
};
|
|
37900
|
+
await agentWithSkills("building", refactorBuildingPrompt(REFACTOR_PACKAGE, strays, pattern, reference), {
|
|
37901
|
+
label: "Refactoring",
|
|
37902
|
+
model: coder(),
|
|
37903
|
+
system: builderSystem()
|
|
37904
|
+
});
|
|
37905
|
+
guard();
|
|
37906
|
+
await healthy(async () => {
|
|
37907
|
+
await agentWithSkills("fix.suite.fixing", refactorFixPrompt(strays, pattern), {
|
|
37908
|
+
label: "Fixing the check",
|
|
37909
|
+
file: FEEDBACK,
|
|
37910
|
+
model: coder(),
|
|
37911
|
+
system: builderSystem()
|
|
37912
|
+
});
|
|
37913
|
+
guard();
|
|
37914
|
+
}, { describeGuard: guard });
|
|
37915
|
+
await run("closing", removeScript([REFACTOR_PACKAGE]), { label: "Closing out the refactor" });
|
|
37916
|
+
return true;
|
|
37917
|
+
});
|
|
37918
|
+
};
|
|
37919
|
+
//#endregion
|
|
37737
37920
|
//#region src/workflows/review/review.ts
|
|
37738
37921
|
/**
|
|
37739
37922
|
* Pure review of everything since `reviewBase`: a green gate, the quality
|
|
@@ -37789,6 +37972,160 @@ const fixFlow = async () => {
|
|
|
37789
37972
|
};
|
|
37790
37973
|
const fix = shipped(fixFlow, { fix: {} });
|
|
37791
37974
|
//#endregion
|
|
37975
|
+
//#region src/workflows/shared/registry.ts
|
|
37976
|
+
const FILL = "";
|
|
37977
|
+
const commentLength = (rest) => {
|
|
37978
|
+
const end = rest.startsWith("//") ? rest.indexOf("\n") : rest.indexOf("*/") + 2;
|
|
37979
|
+
return end <= 0 ? rest.length : end;
|
|
37980
|
+
};
|
|
37981
|
+
const stringEnd = (text, i, quote) => {
|
|
37982
|
+
let j = i + 1;
|
|
37983
|
+
while (j < text.length && text[j] !== quote) j += text[j] === "\\" ? 2 : 1;
|
|
37984
|
+
return Math.min(j, text.length);
|
|
37985
|
+
};
|
|
37986
|
+
const mask = (text) => {
|
|
37987
|
+
let out = "";
|
|
37988
|
+
let i = 0;
|
|
37989
|
+
while (i < text.length) {
|
|
37990
|
+
const c = text[i];
|
|
37991
|
+
const rest = text.slice(i);
|
|
37992
|
+
if (rest.startsWith("//") || rest.startsWith("/*")) {
|
|
37993
|
+
const len = commentLength(rest);
|
|
37994
|
+
out += FILL.repeat(len);
|
|
37995
|
+
i += len;
|
|
37996
|
+
} else if (c === "\"" || c === "'" || c === "`") {
|
|
37997
|
+
const j = stringEnd(text, i, c);
|
|
37998
|
+
out += c + FILL.repeat(j - i - 1) + (j < text.length ? c : "");
|
|
37999
|
+
i = j + 1;
|
|
38000
|
+
} else {
|
|
38001
|
+
out += c;
|
|
38002
|
+
i += 1;
|
|
38003
|
+
}
|
|
38004
|
+
}
|
|
38005
|
+
return out;
|
|
38006
|
+
};
|
|
38007
|
+
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
38008
|
+
const entrySpan = (text, name) => {
|
|
38009
|
+
const quoted = escapeRegExp(name);
|
|
38010
|
+
const re = new RegExp(`\\bname\\s*:\\s*(["'\`])${quoted}\\1`, "g");
|
|
38011
|
+
const masked = mask(text);
|
|
38012
|
+
for (const hit of text.matchAll(re)) {
|
|
38013
|
+
if (masked[hit.index] === FILL) continue;
|
|
38014
|
+
const start = masked.lastIndexOf("{", hit.index);
|
|
38015
|
+
const close = masked.indexOf("}", hit.index);
|
|
38016
|
+
return start < 0 || close < 0 ? void 0 : {
|
|
38017
|
+
start,
|
|
38018
|
+
end: close + 1
|
|
38019
|
+
};
|
|
38020
|
+
}
|
|
38021
|
+
};
|
|
38022
|
+
const DEFAULT_ON = /\bdefaultOn\s*:\s*(true|false)\s*,?/;
|
|
38023
|
+
const defaultOnHit = (body) => DEFAULT_ON.exec(mask(body));
|
|
38024
|
+
const defaultOnValue = (body) => defaultOnHit(body)?.[1];
|
|
38025
|
+
const KIND = /\bkind\s*:\s*["'`]/;
|
|
38026
|
+
const registryEntry = (text, name) => {
|
|
38027
|
+
const span = entrySpan(text, name);
|
|
38028
|
+
if (span === void 0) return void 0;
|
|
38029
|
+
const body = text.slice(span.start, span.end);
|
|
38030
|
+
const hit = KIND.exec(mask(body));
|
|
38031
|
+
return {
|
|
38032
|
+
kind: (hit === null ? null : /^(\w+)["'`]/.exec(body.slice(hit.index + hit[0].length)))?.[1] ?? "",
|
|
38033
|
+
defaultOn: defaultOnValue(body) === "true"
|
|
38034
|
+
};
|
|
38035
|
+
};
|
|
38036
|
+
const canonical = (body) => {
|
|
38037
|
+
const hit = defaultOnHit(body);
|
|
38038
|
+
const kept = hit === null ? body : body.slice(0, hit.index) + body.slice(hit.index + hit[0].length);
|
|
38039
|
+
const masked = mask(kept);
|
|
38040
|
+
return kept.split("").filter((c, i) => masked[i] === FILL || !(/\s/.test(c) || c === "," && /^\s*}/.test(masked.slice(i + 1)))).join("");
|
|
38041
|
+
};
|
|
38042
|
+
/**
|
|
38043
|
+
* [] when `after` differs from `before` only by the named entry's `defaultOn`
|
|
38044
|
+
* going absent/false → true (whitespace and trailing commas inside that entry
|
|
38045
|
+
* ignored, so oxfmt re-wrapping it passes); else one line per fault.
|
|
38046
|
+
*/
|
|
38047
|
+
const flipProblems = (before, after, name) => {
|
|
38048
|
+
const was = entrySpan(before, name);
|
|
38049
|
+
const now = entrySpan(after, name);
|
|
38050
|
+
if (was === void 0) return [`no entry "${name}" before the turn`];
|
|
38051
|
+
if (now === void 0) return [`entry "${name}" is gone`];
|
|
38052
|
+
const faults = [];
|
|
38053
|
+
if (before.slice(0, was.start) !== after.slice(0, now.start)) faults.push("text before the entry changed");
|
|
38054
|
+
if (before.slice(was.end) !== after.slice(now.end)) faults.push("text after the entry changed");
|
|
38055
|
+
const oldBody = before.slice(was.start, was.end);
|
|
38056
|
+
const newBody = after.slice(now.start, now.end);
|
|
38057
|
+
if (canonical(oldBody) !== canonical(newBody)) faults.push(`entry "${name}" changed beyond defaultOn`);
|
|
38058
|
+
if (defaultOnValue(newBody) !== "true") faults.push(`"${name}" is not set to defaultOn: true`);
|
|
38059
|
+
return faults;
|
|
38060
|
+
};
|
|
38061
|
+
/** `path:line` for every `GTD_FLAG_<NAME>` in shipped sources; re-states scripts/lint-flags.ts' rule by hand. */
|
|
38062
|
+
const flagReferences = (files, name) => {
|
|
38063
|
+
const pattern = new RegExp(`\\bGTD_FLAG_${escapeRegExp(name.toUpperCase())}\\b`);
|
|
38064
|
+
return files.filter(({ path }) => !/\.(test|fixture|stories)\.tsx?$/.test(path)).flatMap(({ path, text }) => text.split("\n").flatMap((line, i) => pattern.test(line) ? [`${path}:${i + 1}`] : []));
|
|
38065
|
+
};
|
|
38066
|
+
const requireFlag = (workflow, registryPath, name, defaultOn) => {
|
|
38067
|
+
if (name === "") return refuse(`gtd: ${workflow} needs --var flag=<name>`);
|
|
38068
|
+
const entry = registryEntry(read(registryPath) ?? "", name);
|
|
38069
|
+
if (entry === void 0) return refuse(`gtd: ${workflow}: no flag "${name}" in ${registryPath}`);
|
|
38070
|
+
if (entry.kind !== "release") return refuse(`gtd: ${workflow}: flag "${name}" is ${entry.kind}, not release`);
|
|
38071
|
+
if (entry.defaultOn !== defaultOn) return refuse(defaultOn ? `gtd: ${workflow}: flag "${name}" is not default-on — roll it out first` : `gtd: ${workflow}: flag "${name}" is already default-on`);
|
|
38072
|
+
};
|
|
38073
|
+
//#endregion
|
|
38074
|
+
//#region src/workflows/rollout/rollout.ts
|
|
38075
|
+
const flippingPrompt = (name, registry) => `Roll out the release flag "${name}": in ${registry}, set \`defaultOn: true\` on its entry. Change nothing else — no other entry, no other field, no other file, no comments.`;
|
|
38076
|
+
const suiteBlockedMessage = () => "The suite is red after the rollout. Fix it by hand — a rollout may only touch the flag's registry entry — then land.";
|
|
38077
|
+
const rolloutFlow = async () => {
|
|
38078
|
+
const registry = vars.flagRegistry ?? "";
|
|
38079
|
+
const name = vars.flag ?? "";
|
|
38080
|
+
requireFlag("rollout", registry, name, false);
|
|
38081
|
+
const base = head$1();
|
|
38082
|
+
const before = read(registry) ?? "";
|
|
38083
|
+
const guard = () => {
|
|
38084
|
+
const strays = changesSince(base).paths.filter((path) => path !== registry && path !== ".gtd/FEEDBACK.md");
|
|
38085
|
+
if (strays.length > 0) refuse(`gtd land: rollout-only: ${strays.join(", ")} changed — rollout may only set defaultOn: true on "${name}" in ${registry}`);
|
|
38086
|
+
const problems = flipProblems(before, read(registry) ?? "", name);
|
|
38087
|
+
if (problems.length > 0) refuse(`gtd land: rollout-only: other edits in ${registry}: ${problems.join("; ")}`);
|
|
38088
|
+
};
|
|
38089
|
+
await scope("rollout", async () => {
|
|
38090
|
+
await agent("flipping", flippingPrompt(name, registry), {
|
|
38091
|
+
label: `Rolling out ${name}`,
|
|
38092
|
+
model: coder()
|
|
38093
|
+
});
|
|
38094
|
+
guard();
|
|
38095
|
+
await gate("suite", suiteBlockedMessage(), guard);
|
|
38096
|
+
});
|
|
38097
|
+
};
|
|
38098
|
+
const rollout = flagShipped(rolloutFlow);
|
|
38099
|
+
//#endregion
|
|
38100
|
+
//#region src/workflows/cleanup/cleanup.ts
|
|
38101
|
+
const removingPrompt = (name, registry) => `Clean up the release flag "${name}", which is default-on: delete its entry in ${registry}, and remove every \`GTD_FLAG_${name.toUpperCase()}\` check from the shipped sources. Keep the flag-on path; delete code only the off path reached, including tests that pin the off path.`;
|
|
38102
|
+
const cleanupFlow = async () => {
|
|
38103
|
+
const registry = vars.flagRegistry ?? "";
|
|
38104
|
+
const name = vars.flag ?? "";
|
|
38105
|
+
refusePendingHandOff();
|
|
38106
|
+
requireFlag("cleanup", registry, name, true);
|
|
38107
|
+
let scanned = false;
|
|
38108
|
+
const guard = () => {
|
|
38109
|
+
const turn = scanned ? changes() : void 0;
|
|
38110
|
+
if ((turn === void 0 || turn.some((c) => c.path === registry)) && registryEntry(read(registry) ?? "", name) !== void 0) refuse(`gtd land: cleanup: ${registry} still declares flag "${name}"`);
|
|
38111
|
+
const hits = flagReferences((turn === void 0 ? [...glob("src/**/*.ts"), ...glob("src/**/*.tsx")] : turn.filter((c) => c.status !== "deleted").map((c) => c.path).filter((path) => path.startsWith("src/") && /\.tsx?$/.test(path))).map((path) => ({
|
|
38112
|
+
path,
|
|
38113
|
+
text: read(path) ?? ""
|
|
38114
|
+
})), name);
|
|
38115
|
+
if (hits.length > 0) refuse(`gtd land: cleanup: ${hits.join(", ")} still names GTD_FLAG_${name.toUpperCase()}`);
|
|
38116
|
+
};
|
|
38117
|
+
await scope("cleanup", async () => {
|
|
38118
|
+
await agent("removing", removingPrompt(name, registry), {
|
|
38119
|
+
label: `Removing ${name}`,
|
|
38120
|
+
model: coder()
|
|
38121
|
+
});
|
|
38122
|
+
guard();
|
|
38123
|
+
scanned = true;
|
|
38124
|
+
});
|
|
38125
|
+
await handOffTail(false, start(), guard);
|
|
38126
|
+
};
|
|
38127
|
+
const cleanup = flagShipped(cleanupFlow);
|
|
38128
|
+
//#endregion
|
|
37792
38129
|
//#region src/workflows/index.ts
|
|
37793
38130
|
var workflows_default = feature;
|
|
37794
38131
|
/**
|
|
@@ -37797,4 +38134,4 @@ var workflows_default = feature;
|
|
|
37797
38134
|
*/
|
|
37798
38135
|
const doors = {};
|
|
37799
38136
|
//#endregion
|
|
37800
|
-
export { ARCHITECTURE, ESCALATION, FEEDBACK, FIX_CAP, QUALITY, REQUIREMENTS, REVIEW, REVIEW_ORIG, ShippedOptions, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, awaitReview, base, baseline, build, buildTail, builtInLenses, closeReview, coder, collecting, declaredTests, decompose, workflows_default as default, defaults, deferNotes, describeEscalation, design, doors, envDefaults, escalate, escalation, escalationExhausted, feature, fix, fixCheck, fixFlow, fixNits, fixQuality, fixQualityFindings, fixReviewNotes, fixRisks, fixSuite, gate, handOffReview, handOffTail, healthy, lensesOf, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, planner, qualityLap, reUnwind, requireDeclaredTests, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewing, skills, steering, summary, tail, threads, triage, unwind };
|
|
38137
|
+
export { ARCHITECTURE, ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, QUALITY, REFACTOR_PACKAGE, REQUIREMENTS, REVIEW, REVIEW_ORIG, ShippedOptions, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, awaitReview, base, baseline, build, buildTail, builtInLenses, cleanup, cleanupFlow, closeReview, coder, collecting, declaredTests, decompose, workflows_default as default, defaults, deferNotes, describeEscalation, design, doors, envDefaults, escalate, escalation, escalationExhausted, feature, fix, fixCheck, fixFlow, fixNits, fixQuality, fixQualityFindings, fixReviewNotes, fixRisks, fixSuite, gate, guarded, handOffReview, handOffTail, healthy, lensesOf, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, planner, qualityLap, reUnwind, refactorFirst, requireDeclaredTests, requireNoNewFlags, requireWithin, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewing, rollout, rolloutFlow, skills, steering, summary, tail, threads, triage, unwind };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmelab/gtd",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
|
|
6
6
|
"bin": {
|
|
@@ -52,6 +52,16 @@
|
|
|
52
52
|
"source": "./src/workflows/index.ts",
|
|
53
53
|
"default": "./dist/workflows.mjs"
|
|
54
54
|
},
|
|
55
|
+
"./workflows/rollout": {
|
|
56
|
+
"types": "./src/workflows/rollout/index.ts",
|
|
57
|
+
"source": "./src/workflows/rollout/index.ts",
|
|
58
|
+
"default": "./dist/workflows/rollout.mjs"
|
|
59
|
+
},
|
|
60
|
+
"./workflows/cleanup": {
|
|
61
|
+
"types": "./src/workflows/cleanup/index.ts",
|
|
62
|
+
"source": "./src/workflows/cleanup/index.ts",
|
|
63
|
+
"default": "./dist/workflows/cleanup.mjs"
|
|
64
|
+
},
|
|
55
65
|
"./testkit": {
|
|
56
66
|
"types": "./src/testkit/index.ts",
|
|
57
67
|
"source": "./src/testkit/index.ts",
|
package/src/flags/index.test.ts
CHANGED
|
@@ -55,6 +55,34 @@ describe("resolveFlags", () => {
|
|
|
55
55
|
})
|
|
56
56
|
})
|
|
57
57
|
|
|
58
|
+
describe("resolveFlags defaultOn", () => {
|
|
59
|
+
const on = [
|
|
60
|
+
{ name: "beta", owner: "pmelab", expiry: "2099-01-01", kind: "release", defaultOn: true },
|
|
61
|
+
] as const satisfies readonly FlagDeclaration[]
|
|
62
|
+
|
|
63
|
+
it.each([undefined, ""])("reads unset or empty (%j) as on", (value) => {
|
|
64
|
+
expect(resolveFlags({ GTD_FLAG_BETA: value }, on).beta).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it.each(["0", "false", "FALSE"])("%s still forces a default-on flag off", (value) => {
|
|
68
|
+
expect(resolveFlags({ GTD_FLAG_BETA: value }, on).beta).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it.each(["1", "true"])("%s forces a default-on flag on", (value) => {
|
|
72
|
+
expect(resolveFlags({ GTD_FLAG_BETA: value }, on).beta).toBe(true)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("still throws for a garbled value", () => {
|
|
76
|
+
expect(() => resolveFlags({ GTD_FLAG_BETA: "ture" }, on)).toThrow(
|
|
77
|
+
/^GTD_FLAG_BETA must be 1, 0, true or false$/,
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("reads an entry without defaultOn as off when unset", () => {
|
|
82
|
+
expect(resolveFlags({}, [...on, ...registry]).alpha).toBe(false)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
58
86
|
describe("FLAGS", () => {
|
|
59
87
|
it("declares settable, unique variable names", () => {
|
|
60
88
|
// Vacuous while FLAGS is empty; guards the first declared flag.
|
package/src/flags/index.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface FlagDeclaration {
|
|
|
9
9
|
/** ISO `YYYY-MM-DD`; never enforced at runtime. */
|
|
10
10
|
readonly expiry: string
|
|
11
11
|
readonly kind: FlagKind
|
|
12
|
+
/** Unset or empty resolves on; `0`/`false` is still the kill switch. */
|
|
13
|
+
readonly defaultOn?: boolean
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
export const FLAGS = [] as const satisfies readonly FlagDeclaration[]
|
|
@@ -18,17 +20,20 @@ export type Flags<R extends readonly FlagDeclaration[] = typeof FLAGS> = {
|
|
|
18
20
|
readonly [N in R[number]["name"]]: boolean
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
const BOOL: Record<string, boolean> = { "
|
|
23
|
+
const BOOL: Record<string, boolean> = { "0": false, false: false, "1": true, true: true }
|
|
22
24
|
|
|
23
25
|
export const resolveFlags = <const R extends readonly FlagDeclaration[] = typeof FLAGS>(
|
|
24
26
|
env: Readonly<Record<string, string | undefined>>,
|
|
25
27
|
registry: R = FLAGS as unknown as R,
|
|
26
28
|
): Flags<R> => {
|
|
27
29
|
const out: Record<string, boolean> = {}
|
|
28
|
-
for (const { name } of registry) {
|
|
30
|
+
for (const { name, defaultOn } of registry) {
|
|
29
31
|
const variable = `GTD_FLAG_${name.toUpperCase()}`
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
+
const key = (env[variable] ?? "").toLowerCase()
|
|
33
|
+
if (key === "") {
|
|
34
|
+
out[name] = defaultOn ?? false
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
32
37
|
if (!Object.hasOwn(BOOL, key)) {
|
|
33
38
|
throw new Error(`${variable} must be 1, 0, true or false`)
|
|
34
39
|
}
|
|
@@ -667,6 +667,26 @@ const recognizeDeferNotes = (repo: InMemRepo, block: string): BlockOutcome | und
|
|
|
667
667
|
return { kind: "applied" }
|
|
668
668
|
}
|
|
669
669
|
|
|
670
|
+
/** `mkdir -p <dir>` then `printf '%s\n' <quoted lines> > <path>`: a flow's one-file write. Re-rendered from what was extracted, so any other shape stays unrecognized. */
|
|
671
|
+
const recognizeWriteLines = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
|
|
672
|
+
const [mkdir, printf, ...rest] = block.split("\n")
|
|
673
|
+
if (
|
|
674
|
+
rest.length > 0 ||
|
|
675
|
+
!mkdir?.startsWith("mkdir -p ") ||
|
|
676
|
+
!printf?.startsWith("printf '%s\\n' ")
|
|
677
|
+
) {
|
|
678
|
+
return undefined
|
|
679
|
+
}
|
|
680
|
+
const tokens = extractQuotedTokens(printf.slice("printf '%s\\n' ".length))
|
|
681
|
+
const path = tokens.pop()
|
|
682
|
+
if (path === undefined || !path.includes("/")) return undefined
|
|
683
|
+
const dir = path.slice(0, path.lastIndexOf("/"))
|
|
684
|
+
const rendered = `mkdir -p ${dir}\nprintf '%s\\n' ${tokens.map(shellQuote).join(" ")} > ${shellQuote(path)}`
|
|
685
|
+
if (rendered !== block) return undefined
|
|
686
|
+
repo.writeFile(path, `${tokens.join("\n")}\n`)
|
|
687
|
+
return { kind: "applied" }
|
|
688
|
+
}
|
|
689
|
+
|
|
670
690
|
/** Every recognizer `applyEmittedScript` tries, first match wins — `recognizeScriptedCommand` stays last since it's the exact-hit fallback for anything the rest don't claim. */
|
|
671
691
|
const recognizersFor = (
|
|
672
692
|
repo: InMemRepo,
|
|
@@ -679,6 +699,7 @@ const recognizersFor = (
|
|
|
679
699
|
(block) => recognizeBinaryGuard(commands, block),
|
|
680
700
|
(block) => recognizeGitBuilders(repo, block),
|
|
681
701
|
(block) => recognizeDeferNotes(repo, block),
|
|
702
|
+
(block) => recognizeWriteLines(repo, block),
|
|
682
703
|
(block) => recognizeFailurePromptWrapper(repo, commands, block),
|
|
683
704
|
(block) => recognizePresentationSubshell(repo, commands, block),
|
|
684
705
|
(block) => recognizeModeContradictionSkipNotice(block),
|