@pmelab/gtd 27.2.0 → 27.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +14 -1
- package/dist/chunks/{reserved-B2QaImnN.mjs → reserved-BPdVZkgv.mjs} +29 -4
- package/dist/chunks/{tail-BnhIxCYv.mjs → tail-iLAMoigl.mjs} +1 -1
- package/dist/chunks/{wire-BY1S-obW.mjs → wire-C12dahrH.mjs} +6 -4
- package/dist/flows.mjs +24 -1
- package/dist/gtd.bundle.mjs +174 -23
- package/dist/testkit.mjs +327 -27
- package/dist/wire.mjs +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +119 -6
- 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 +146 -8
- package/package.json +1 -1
- package/src/cli/Cli.ts +5 -5
- package/src/cli/History.ts +5 -0
- package/src/cli/Program.ts +1 -0
- package/src/edge/Edge.test.ts +160 -0
- package/src/edge/Edge.ts +90 -8
- package/src/emit/GitScript.test.ts +39 -3
- package/src/emit/GitScript.ts +25 -1
- package/src/emit/index.ts +2 -0
- package/src/flows/helpers.ts +28 -0
- package/src/flows/runtime.ts +7 -0
- package/src/install/Install.ts +2 -1
- package/src/platform/Git.test.ts +25 -1
- package/src/platform/Git.ts +30 -0
- package/src/platform/index.ts +1 -0
- package/src/replay/Format.test.ts +16 -1
- package/src/replay/Replay.test.ts +91 -0
- package/src/replay/Replay.ts +22 -2
- package/src/replay/Trailers.test.ts +110 -1
- package/src/replay/Trailers.ts +37 -1
- package/src/replay/index.ts +2 -0
- package/src/replay/seamGate.test.ts +62 -0
- package/src/step/LandStep.ts +1 -0
- package/src/step/planStep.test.ts +32 -0
- package/src/step/planStep.ts +3 -1
- package/src/testkit/EmittedScriptRecognizer.test.ts +27 -0
- package/src/testkit/EmittedScriptRecognizer.ts +33 -0
- package/src/testkit/EmittedScripts.test.ts +9 -0
- package/src/testkit/FakeGitOperations.ts +5 -0
- package/src/testkit/GitTiers.ts +158 -0
- package/src/testkit/InMemRepo.ts +38 -3
- package/src/wire/BeatDocument.test.ts +25 -0
- package/src/wire/BeatDocument.ts +4 -2
- package/src/wire/BeatPlain.test.ts +1 -0
- package/src/wire/BeatStatus.test.ts +2 -0
- package/src/wire/BeatStatus.ts +13 -2
- package/src/wire/WireSchema.test.ts +10 -0
- package/src/wire/types.ts +8 -0
- package/src/workflow/Workflow.ts +1 -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 +1 -0
- package/src/workflows/shared/access.ts +2 -1
- package/src/workflows/shared/health.test.ts +67 -3
- package/src/workflows/shared/health.ts +5 -3
- package/src/workflows/shared/options.test.ts +8 -0
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/skills.test.ts +6 -0
- package/src/workflows/shared/skills.ts +5 -1
- package/src/workflows/shared/text.ts +35 -0
- package/src/workflows/shared/vars.ts +1 -0
package/dist/wire.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { C as EXIT_SIGTERM, D as UNATTRIBUTED_MODEL, E as ACCESS_REFUSAL, O as WIRE_SCHEMA, S as EXIT_SIGINT, T as EXIT_USAGE_ERROR, _ as demandOf, a as landFields, b as EXIT_OK, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, h as BEAT_KINDS, i as judgeJson, k as renderFormat, l as renderLandPlain, m as statusOf, n as resolveSession, o as landProseText, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches, u as DEMAND_BRIEFING, v as stallDiagnosis, w as EXIT_STRAYS_FOUND, x as EXIT_RUNTIME_ERROR, y as EXIT_CODES } from "./chunks/wire-
|
|
1
|
+
import { C as EXIT_SIGTERM, D as UNATTRIBUTED_MODEL, E as ACCESS_REFUSAL, O as WIRE_SCHEMA, S as EXIT_SIGINT, T as EXIT_USAGE_ERROR, _ as demandOf, a as landFields, b as EXIT_OK, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, h as BEAT_KINDS, i as judgeJson, k as renderFormat, l as renderLandPlain, m as statusOf, n as resolveSession, o as landProseText, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches, u as DEMAND_BRIEFING, v as stallDiagnosis, w as EXIT_STRAYS_FOUND, x as EXIT_RUNTIME_ERROR, y as EXIT_CODES } from "./chunks/wire-C12dahrH.mjs";
|
|
2
2
|
export { ACCESS_REFUSAL, BEAT_KINDS, DEMAND_BRIEFING, EXIT_CODES, EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_SIGINT, EXIT_SIGTERM, EXIT_STRAYS_FOUND, EXIT_USAGE_ERROR, UNATTRIBUTED_MODEL, WIRE_SCHEMA, beatDocument, beatKindOf, demandOf, globMatches, judgeJson, landFields, landProseText, noopText, renderBeatJson, renderBeatPlain, renderFormat, renderLandJson, renderLandPlain, resolveSession, selectPath, stallDiagnosis, statusOf };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { agent, changes, glob, read, refuse, scope, start, vars } from "../flows.mjs";
|
|
2
|
-
import { a as flagShipped, b as coder, c as envDefaults, i as skills, n as summary, r as access, s as defaults, t as steering } from "../chunks/reserved-
|
|
3
|
-
import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-
|
|
2
|
+
import { a as flagShipped, b as coder, c as envDefaults, i as skills, n as summary, r as access, s as defaults, t as steering } from "../chunks/reserved-BPdVZkgv.mjs";
|
|
3
|
+
import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-iLAMoigl.mjs";
|
|
4
4
|
import { i as requireFlag, r as registryEntry, t as flagReferences } from "../chunks/registry-CP3YuV9j.mjs";
|
|
5
5
|
//#region src/workflows/cleanup/cleanup.ts
|
|
6
6
|
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.`;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { answered, changes, changesSince, glob, hasThreadFor, head, human, judge, numeric, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restoreScript, revertScript, run, scope, start, vars } from "../flows.mjs";
|
|
1
|
+
import { answered, changes, changesSince, glob, hasThreadFor, head, human, judge, numeric, quote, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restoreScript, revertScript, run, scope, start, vars } from "../flows.mjs";
|
|
2
2
|
import { review as review$1 } from "../workflow-kit.mjs";
|
|
3
|
-
import {
|
|
4
|
-
import { a as tail, i as refusePendingHandOff, n as closeReview } from "../chunks/tail-
|
|
3
|
+
import { A as architectureAuthorPrompt, B as designGateAnswerMessage, C as fixRisks, D as reviewing, F as buildReviewCollectingPrompt, H as designTriagePrompt, I as buildReviewFixNitsPrompt, J as packagesItemFixSuitePrompt, K as idleMessage, L as buildReviewTriageMessage, M as architectureGateAnswerMessage, N as buildReviewAnswerQuestionsPrompt, O as agentWithSkills, P as buildReviewAwaitAfterDeferMessage, Q as reviewEditsCapture, R as builderSystem, T as reviewMissing, V as designSystem, X as refactorFixPrompt, Y as refactorBuildingPrompt, Z as reviewEditNotesCapture, _ as REQUIREMENTS, b as coder, c as envDefaults, d as gate, et as reviewNotesCapture, h as FEEDBACK, i as skills, it as unwindFailure, j as architectureDecomposePrompt, k as architectSystem, m as ARCHITECTURE, n as summary, nt as startGateBlockedMessage, o as shipped, p as healthy, q as packagesItemBuildingPrompt, r as access, rt as unwindFailedMessage, s as defaults, t as steering, tt as reviewerSystem, v as REVIEW, w as planner, x as fixCheck, y as awaitReview, z as deferNotesScript } from "../chunks/reserved-BPdVZkgv.mjs";
|
|
4
|
+
import { a as tail, i as refusePendingHandOff, n as closeReview } from "../chunks/tail-iLAMoigl.mjs";
|
|
5
5
|
//#region src/workflows/feature/steps.ts
|
|
6
6
|
/** Turn the sketch since `base` into `.gtd/REQUIREMENTS.md`. */
|
|
7
7
|
const triage = (base) => agentWithSkills("triage", designTriagePrompt(base), {
|
|
@@ -282,7 +282,7 @@ const triage$1 = (severities) => async (notes) => {
|
|
|
282
282
|
return [n.id, severity.tier === "could" ? "defer" : "edit"];
|
|
283
283
|
}));
|
|
284
284
|
};
|
|
285
|
-
const isCode = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
|
|
285
|
+
const isCode$1 = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
|
|
286
286
|
/**
|
|
287
287
|
* A reviewer writes `.gtd/REVIEW.md` over everything since `base`, a human
|
|
288
288
|
* reviews and signs off or comments, and each note is judged: edits go to
|
|
@@ -295,7 +295,7 @@ const review = (base, escalations = { rounds: 0 }) => {
|
|
|
295
295
|
return review$1(base, {
|
|
296
296
|
file: REVIEW,
|
|
297
297
|
feedback: REQUIREMENTS,
|
|
298
|
-
isCode,
|
|
298
|
+
isCode: isCode$1,
|
|
299
299
|
reviewing: (b, carry) => {
|
|
300
300
|
deferred = [];
|
|
301
301
|
return reviewing(b, carry);
|
|
@@ -405,7 +405,120 @@ const ordinaryStart = async () => {
|
|
|
405
405
|
};
|
|
406
406
|
const feature = shipped(ordinaryStart);
|
|
407
407
|
//#endregion
|
|
408
|
+
//#region src/workflows/feature/refactor.ts
|
|
409
|
+
const REFACTOR_PACKAGE = ".gtd/REFACTOR.md";
|
|
410
|
+
const isCode = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
|
|
411
|
+
const requireWithin = (allowed) => {
|
|
412
|
+
const outside = changes().map((c) => c.path).filter((path) => isCode(path) && !allowed.includes(path));
|
|
413
|
+
if (outside.length === 0) return;
|
|
414
|
+
refuse(`gtd land: refactor-scope: the refactor may change only its stray paths — revert these:\n${outside.map((path) => ` - ${path}`).join("\n")}`);
|
|
415
|
+
};
|
|
416
|
+
const flagExpression = (pattern) => {
|
|
417
|
+
if (pattern.trim() === "") throw new Error("gtd: refactorFlagPattern must not be blank");
|
|
418
|
+
let expression;
|
|
419
|
+
try {
|
|
420
|
+
expression = new RegExp(pattern, "g");
|
|
421
|
+
} catch (error) {
|
|
422
|
+
throw new Error(`gtd: refactorFlagPattern is not a valid regular expression: ${String(error)}`);
|
|
423
|
+
}
|
|
424
|
+
if (expression.test("")) throw new Error("gtd: refactorFlagPattern must not match the empty string");
|
|
425
|
+
return expression;
|
|
426
|
+
};
|
|
427
|
+
const countFlags = (text, expression, into) => {
|
|
428
|
+
let zeroLength = false;
|
|
429
|
+
for (const [name] of text.matchAll(expression)) if (name === "") zeroLength = true;
|
|
430
|
+
else into.set(name, (into.get(name) ?? 0) + 1);
|
|
431
|
+
return zeroLength;
|
|
432
|
+
};
|
|
433
|
+
const listPaths = (paths) => paths.map((path) => ` - ${path}`).join("\n");
|
|
434
|
+
const requireNoNewFlags = (pattern, since) => {
|
|
435
|
+
const expression = flagExpression(pattern);
|
|
436
|
+
const before = /* @__PURE__ */ new Map();
|
|
437
|
+
const after = /* @__PURE__ */ new Map();
|
|
438
|
+
const unreadable = [];
|
|
439
|
+
const zeroLength = [];
|
|
440
|
+
for (const { path, status, before: was, after: now } of changesSince(since)) {
|
|
441
|
+
if (!isCode(path)) continue;
|
|
442
|
+
const oldSide = status === "added" ? "" : was;
|
|
443
|
+
const newSide = status === "deleted" ? "" : now;
|
|
444
|
+
if (oldSide === void 0 || newSide === void 0) {
|
|
445
|
+
unreadable.push(path);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
countFlags(oldSide, expression, before);
|
|
449
|
+
if (countFlags(newSide, expression, after)) zeroLength.push(path);
|
|
450
|
+
}
|
|
451
|
+
if (unreadable.length > 0) refuse(`gtd land: refactor-flags: cannot compare flag reads in:\n${listPaths(unreadable)}`);
|
|
452
|
+
if (zeroLength.length > 0) refuse(`gtd land: refactor-flags: refactorFlagPattern (${pattern}) produced a zero-length match in:\n${listPaths(zeroLength)}`);
|
|
453
|
+
const raised = [...after].filter(([name, count]) => count > (before.get(name) ?? 0));
|
|
454
|
+
if (raised.length === 0) return;
|
|
455
|
+
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")}`);
|
|
456
|
+
};
|
|
457
|
+
const packageLines = (strays, pattern, reference) => [
|
|
458
|
+
"# Behaviour-neutral refactor",
|
|
459
|
+
"",
|
|
460
|
+
"## Scope (read-only)",
|
|
461
|
+
"",
|
|
462
|
+
...strays.map((path) => `- \`${path}\``),
|
|
463
|
+
"",
|
|
464
|
+
"This list is fixed when the package is derived: editing it here widens or narrows nothing.",
|
|
465
|
+
"",
|
|
466
|
+
"## Rules",
|
|
467
|
+
"",
|
|
468
|
+
"- The suite's outcome must not change",
|
|
469
|
+
"- Change only the paths above; a test file only when it is one of them",
|
|
470
|
+
`- Introduce no flag read and add no second read of an existing one (\`${pattern}\`)`,
|
|
471
|
+
"",
|
|
472
|
+
"## Reference",
|
|
473
|
+
"",
|
|
474
|
+
reference === void 0 ? "None." : `Reading material only, never applied: \`git show ${reference} -- ${strays.map(quote).join(" ")}\``,
|
|
475
|
+
""
|
|
476
|
+
];
|
|
477
|
+
const deriveScript = (lines) => `mkdir -p .gtd
|
|
478
|
+
printf '%s\\n' ${lines.map(quote).join(" ")} > ${quote(REFACTOR_PACKAGE)}
|
|
479
|
+
`;
|
|
480
|
+
const refactorFirst = async (shape, options = {}) => {
|
|
481
|
+
if (shape.choice !== "refactor-first" || shape.unreadable.length > 0) return false;
|
|
482
|
+
const pattern = vars.refactorFlagPattern ?? defaults.refactorFlagPattern;
|
|
483
|
+
const expression = flagExpression(pattern);
|
|
484
|
+
const { strays } = shape;
|
|
485
|
+
const zeroLength = strays.filter((path) => countFlags(read(path) ?? "", expression, /* @__PURE__ */ new Map()));
|
|
486
|
+
if (zeroLength.length > 0) throw new Error(`gtd: refactorFlagPattern (${pattern}) produces a zero-length match in:\n${listPaths(zeroLength)}`);
|
|
487
|
+
const reference = options.reference?.trim() ? options.reference : void 0;
|
|
488
|
+
return scope({
|
|
489
|
+
name: "refactor",
|
|
490
|
+
access: { write: [...strays, ".gtd/**"] }
|
|
491
|
+
}, async () => {
|
|
492
|
+
await run("deriving", deriveScript(packageLines(strays, pattern, reference)), {
|
|
493
|
+
label: "Deriving the refactor package",
|
|
494
|
+
file: REFACTOR_PACKAGE
|
|
495
|
+
});
|
|
496
|
+
const from = head();
|
|
497
|
+
const guard = () => {
|
|
498
|
+
requireWithin(strays);
|
|
499
|
+
requireNoNewFlags(pattern, from);
|
|
500
|
+
};
|
|
501
|
+
await agentWithSkills("building", refactorBuildingPrompt(REFACTOR_PACKAGE, strays, pattern, reference), {
|
|
502
|
+
label: "Refactoring",
|
|
503
|
+
model: coder(),
|
|
504
|
+
system: builderSystem()
|
|
505
|
+
});
|
|
506
|
+
guard();
|
|
507
|
+
await healthy(async () => {
|
|
508
|
+
await agentWithSkills("fix.suite.fixing", refactorFixPrompt(strays, pattern), {
|
|
509
|
+
label: "Fixing the check",
|
|
510
|
+
file: FEEDBACK,
|
|
511
|
+
model: coder(),
|
|
512
|
+
system: builderSystem()
|
|
513
|
+
});
|
|
514
|
+
guard();
|
|
515
|
+
}, { describeGuard: guard });
|
|
516
|
+
await run("closing", removeScript([REFACTOR_PACKAGE]), { label: "Closing out the refactor" });
|
|
517
|
+
return true;
|
|
518
|
+
});
|
|
519
|
+
};
|
|
520
|
+
//#endregion
|
|
408
521
|
//#region src/workflows/feature/index.ts
|
|
409
522
|
var feature_default = feature;
|
|
410
523
|
//#endregion
|
|
411
|
-
export { access, afterTail, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, build, buildTail, collecting, declaredTests, decompose, feature_default as default, defaults, deferNotes, design, envDefaults, feature, fixNits, fixSuite, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, reUnwind, requireDeclaredTests, skills, steering, summary, triage, unwind };
|
|
524
|
+
export { REFACTOR_PACKAGE, access, afterTail, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, build, buildTail, collecting, declaredTests, decompose, feature_default as default, defaults, deferNotes, design, envDefaults, feature, fixNits, fixSuite, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, reUnwind, refactorFirst, requireDeclaredTests, requireNoNewFlags, requireWithin, skills, steering, summary, triage, unwind };
|
package/dist/workflows/fix.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { read, refuse, removeScript, run, scope, start } from "../flows.mjs";
|
|
2
2
|
import { d as reviewNotes, f as reviewRisks } from "../chunks/steering-DVILanVf.mjs";
|
|
3
|
-
import { O as agentWithSkills, U as finisherSystem, W as fixReviewNotesPrompt, b as coder, c as envDefaults, i as skills, l as baseline, n as summary, o as shipped, r as access, s as defaults, t as steering, v as REVIEW } from "../chunks/reserved-
|
|
4
|
-
import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-
|
|
3
|
+
import { O as agentWithSkills, U as finisherSystem, W as fixReviewNotesPrompt, b as coder, c as envDefaults, i as skills, l as baseline, n as summary, o as shipped, r as access, s as defaults, t as steering, v as REVIEW } from "../chunks/reserved-BPdVZkgv.mjs";
|
|
4
|
+
import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-iLAMoigl.mjs";
|
|
5
5
|
//#region src/workflows/fix/fix.ts
|
|
6
6
|
/**
|
|
7
7
|
* Fix every note a human left in a handed-off `.gtd/REVIEW.md` and every
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { start } from "../flows.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-
|
|
2
|
+
import { $ as reviewGateBlockedMessage, c as envDefaults, d as gate, i as skills, n as summary, o as shipped, r as access, s as defaults, t as steering } from "../chunks/reserved-BPdVZkgv.mjs";
|
|
3
|
+
import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-iLAMoigl.mjs";
|
|
4
4
|
//#region src/workflows/review/review.ts
|
|
5
5
|
/**
|
|
6
6
|
* Pure review of everything since `reviewBase`: a green gate, the quality
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { agent, changesSince, head, read, refuse, scope, vars } from "../flows.mjs";
|
|
2
|
-
import { a as flagShipped, b as coder, c as envDefaults, d as gate, i as skills, n as summary, r as access, s as defaults, t as steering } from "../chunks/reserved-
|
|
2
|
+
import { a as flagShipped, b as coder, c as envDefaults, d as gate, i as skills, n as summary, r as access, s as defaults, t as steering } from "../chunks/reserved-BPdVZkgv.mjs";
|
|
3
3
|
import { i as requireFlag, n as flipProblems } from "../chunks/registry-CP3YuV9j.mjs";
|
|
4
4
|
//#region src/workflows/rollout/rollout.ts
|
|
5
5
|
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.`;
|
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
|
|
@@ -17809,9 +17829,9 @@ const guarded = (turn, guard) => async (...a) => {
|
|
|
17809
17829
|
await turn(...a);
|
|
17810
17830
|
guard?.();
|
|
17811
17831
|
};
|
|
17812
|
-
const guardedSteps = (guard) => ({
|
|
17832
|
+
const guardedSteps = (guard, describeGuard) => ({
|
|
17813
17833
|
limit: escalationSteps.limit,
|
|
17814
|
-
describe: guarded(escalationSteps.describe, guard),
|
|
17834
|
+
describe: guarded(guarded(escalationSteps.describe, guard), describeGuard),
|
|
17815
17835
|
stop: guarded(escalationSteps.stop, guard),
|
|
17816
17836
|
exhausted: guarded(escalationSteps.exhausted, guard)
|
|
17817
17837
|
});
|
|
@@ -17863,7 +17883,7 @@ const healthy = (fix, options = {}) => healthy$1(guarded(fix, options.guard), {
|
|
|
17863
17883
|
fixesSoFar: options.fixesSoFar ?? 0,
|
|
17864
17884
|
sameFailure,
|
|
17865
17885
|
escalations: options.escalations ?? { rounds: 0 },
|
|
17866
|
-
escalation: guardedSteps(options.guard)
|
|
17886
|
+
escalation: guardedSteps(options.guard, options.describeGuard)
|
|
17867
17887
|
});
|
|
17868
17888
|
/**
|
|
17869
17889
|
* Hold the process at `<name>.blocked` until the suite is green. `guard` runs
|
|
@@ -37198,6 +37218,7 @@ const defaults = {
|
|
|
37198
37218
|
judgeIdenticalMinP: "0.7",
|
|
37199
37219
|
reviewNoteActionable: "0.7",
|
|
37200
37220
|
reviewSeverityMinP: "0.7",
|
|
37221
|
+
refactorFlagPattern: "\\bGTD_FLAG_\\w+",
|
|
37201
37222
|
qualityReviews: "correctness, owasp-security, ponytail-review, test-audit, conventions, spec-challenge"
|
|
37202
37223
|
};
|
|
37203
37224
|
/** Environment settings. */
|
|
@@ -37215,6 +37236,7 @@ const ShippedOptions = Struct({
|
|
|
37215
37236
|
judgeIdenticalMinP: setting,
|
|
37216
37237
|
reviewNoteActionable: setting,
|
|
37217
37238
|
reviewSeverityMinP: setting,
|
|
37239
|
+
refactorFlagPattern: setting,
|
|
37218
37240
|
qualityReviews: setting,
|
|
37219
37241
|
testCommand: setting,
|
|
37220
37242
|
plannerModel: setting,
|
|
@@ -37614,7 +37636,7 @@ const triage$1 = (severities) => async (notes) => {
|
|
|
37614
37636
|
return [n.id, severity.tier === "could" ? "defer" : "edit"];
|
|
37615
37637
|
}));
|
|
37616
37638
|
};
|
|
37617
|
-
const isCode = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
|
|
37639
|
+
const isCode$1 = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
|
|
37618
37640
|
/**
|
|
37619
37641
|
* A reviewer writes `.gtd/REVIEW.md` over everything since `base`, a human
|
|
37620
37642
|
* reviews and signs off or comments, and each note is judged: edits go to
|
|
@@ -37627,7 +37649,7 @@ const review$1 = (base, escalations = { rounds: 0 }) => {
|
|
|
37627
37649
|
return review$2(base, {
|
|
37628
37650
|
file: REVIEW,
|
|
37629
37651
|
feedback: REQUIREMENTS,
|
|
37630
|
-
isCode,
|
|
37652
|
+
isCode: isCode$1,
|
|
37631
37653
|
reviewing: (b, carry) => {
|
|
37632
37654
|
deferred = [];
|
|
37633
37655
|
return reviewing(b, carry);
|
|
@@ -37756,6 +37778,9 @@ const skills = (vars) => ({
|
|
|
37756
37778
|
"build.review.fix.nits": ["incremental-implementation", "code-simplification"],
|
|
37757
37779
|
"build.review.fix.risks": ["debugging-and-error-recovery", "incremental-implementation"],
|
|
37758
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"],
|
|
37759
37784
|
...lensScopes("build.quality", lensesOf(vars.qualityReviews ?? ""), (lens) => builtInLenses[lens]?.skills ?? [lens])
|
|
37760
37785
|
});
|
|
37761
37786
|
//#endregion
|
|
@@ -37779,6 +37804,119 @@ const steering = {
|
|
|
37779
37804
|
[REVIEW]: "review"
|
|
37780
37805
|
};
|
|
37781
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
|
|
37782
37920
|
//#region src/workflows/review/review.ts
|
|
37783
37921
|
/**
|
|
37784
37922
|
* Pure review of everything since `reviewBase`: a green gate, the quality
|
|
@@ -37996,4 +38134,4 @@ var workflows_default = feature;
|
|
|
37996
38134
|
*/
|
|
37997
38135
|
const doors = {};
|
|
37998
38136
|
//#endregion
|
|
37999
|
-
export { ARCHITECTURE, ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, QUALITY, 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, requireDeclaredTests, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewing, rollout, rolloutFlow, 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
package/src/cli/Cli.ts
CHANGED
|
@@ -705,11 +705,11 @@ const COMMAND_ROWS: readonly CommandRow[] = [
|
|
|
705
705
|
arity: { name: "ref" },
|
|
706
706
|
details: [
|
|
707
707
|
"Replay one kept process under today's config and print its",
|
|
708
|
-
"steps
|
|
709
|
-
"(or unique prefix) or a branch name. Refuses
|
|
710
|
-
"config file differs from its content at the
|
|
711
|
-
"version mismatch only warns on stderr. --json
|
|
712
|
-
"trail as one document; writes nothing",
|
|
708
|
+
"steps, judge verdicts and approvals. <ref> is a full ref, an",
|
|
709
|
+
"entry hash (or unique prefix) or a branch name. Refuses",
|
|
710
|
+
"(exit 1) when a config file differs from its content at the",
|
|
711
|
+
"ref's tip. A version mismatch only warns on stderr. --json",
|
|
712
|
+
"prints the trail as one document; writes nothing",
|
|
713
713
|
],
|
|
714
714
|
},
|
|
715
715
|
{
|
package/src/cli/History.ts
CHANGED
|
@@ -128,6 +128,11 @@ export const runHistoryShowCommand = (
|
|
|
128
128
|
for (const step of trail.steps) {
|
|
129
129
|
lines.push(`${seven(step.hash)} ${step.subject}`)
|
|
130
130
|
for (const v of step.verdicts) lines.push(` judge ${v.id} = ${v.answer} (p=${v.p})`)
|
|
131
|
+
if (step.approval !== undefined) {
|
|
132
|
+
lines.push(
|
|
133
|
+
` approved ${step.approval.step} by ${step.approval.name} <${step.approval.email}>`,
|
|
134
|
+
)
|
|
135
|
+
}
|
|
131
136
|
}
|
|
132
137
|
lines.push(`ended (${trail.ended})`)
|
|
133
138
|
out.write(`${lines.join("\n")}\n`)
|
package/src/cli/Program.ts
CHANGED
package/src/edge/Edge.test.ts
CHANGED
|
@@ -151,6 +151,7 @@ describe("currentRun", () => {
|
|
|
151
151
|
trace: [],
|
|
152
152
|
costEntries: [],
|
|
153
153
|
judgeVerdicts: [],
|
|
154
|
+
approvals: [],
|
|
154
155
|
pinnedVars: {},
|
|
155
156
|
headTurn: undefined,
|
|
156
157
|
closingHash: undefined,
|
|
@@ -1306,6 +1307,28 @@ export default async () => {
|
|
|
1306
1307
|
expect(trail.steps.filter((s) => s.verdicts.length > 0)).toHaveLength(1)
|
|
1307
1308
|
})
|
|
1308
1309
|
|
|
1310
|
+
it("sets approval on the approval step only", async () => {
|
|
1311
|
+
const repo = repoWith(`import { human } from "@pmelab/gtd/flows"
|
|
1312
|
+
|
|
1313
|
+
export default async () => {
|
|
1314
|
+
await human("idle")
|
|
1315
|
+
await human("gate", { message: "seam report", approve: true })
|
|
1316
|
+
await human("after")
|
|
1317
|
+
}
|
|
1318
|
+
`)
|
|
1319
|
+
await land(repo, { "src/a.ts": "a\n" })
|
|
1320
|
+
const approval = await land(repo)
|
|
1321
|
+
const tip = await land(repo, { "src/b.ts": "b\n" })
|
|
1322
|
+
const trail = await provide(historyTrail(tip), repo)
|
|
1323
|
+
expect(trail.steps.find((s) => s.hash === approval)?.approval).toEqual({
|
|
1324
|
+
step: "gate#1",
|
|
1325
|
+
name: "Test",
|
|
1326
|
+
email: "test@test.com",
|
|
1327
|
+
commit: approval,
|
|
1328
|
+
})
|
|
1329
|
+
expect(trail.steps.filter((s) => s.approval !== undefined)).toHaveLength(1)
|
|
1330
|
+
})
|
|
1331
|
+
|
|
1309
1332
|
it("records the version and warns about none while they match", async () => {
|
|
1310
1333
|
const { repo, tip } = await finished()
|
|
1311
1334
|
const trail = await provide(historyTrail(tip), repo)
|
|
@@ -1369,3 +1392,140 @@ export default async () => {
|
|
|
1369
1392
|
expect(String(Exit.isFailure(exit) && exit.cause)).toContain("gtd.config.ts")
|
|
1370
1393
|
})
|
|
1371
1394
|
})
|
|
1395
|
+
|
|
1396
|
+
describe("landing at an approve rest", () => {
|
|
1397
|
+
const GATED = `import { human } from "@pmelab/gtd/flows"
|
|
1398
|
+
|
|
1399
|
+
export default async () => {
|
|
1400
|
+
await human("idle")
|
|
1401
|
+
await human("gate", { message: "seam report", approve: true })
|
|
1402
|
+
await human("after")
|
|
1403
|
+
}
|
|
1404
|
+
`
|
|
1405
|
+
|
|
1406
|
+
const atGate = async (): Promise<InMemRepo> => {
|
|
1407
|
+
const repo = repoWith(GATED)
|
|
1408
|
+
await land(repo, { "src/a.ts": "a\n" })
|
|
1409
|
+
return repo
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
const snapshotAt = async (repo: InMemRepo, git?: GitOperations) => {
|
|
1413
|
+
const rest = await provide(currentRest, repo)
|
|
1414
|
+
const effect = snapshotFromRest(rest)
|
|
1415
|
+
return git === undefined ? await provide(effect, repo) : await provideWithGit(effect, git, repo)
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
it("rests at a message step carrying approve", async () => {
|
|
1419
|
+
const rest = await provide(currentRest, await atGate())
|
|
1420
|
+
expect(rest.state).toBe("gate")
|
|
1421
|
+
expect(rest.stepDef).toMatchObject({ kind: "message", content: "seam report", approve: true })
|
|
1422
|
+
})
|
|
1423
|
+
|
|
1424
|
+
it("refuses a dirty tree and commits nothing", async () => {
|
|
1425
|
+
const repo = await atGate()
|
|
1426
|
+
const head = headOf(repo)
|
|
1427
|
+
repo.writeFile("src/edited.ts", "edit\n")
|
|
1428
|
+
const { landing } = await snapshotAt(repo)
|
|
1429
|
+
expect(landing).toEqual({
|
|
1430
|
+
kind: "refusal",
|
|
1431
|
+
message:
|
|
1432
|
+
"gtd land: approval-gate: gate#1 approves exactly the judged diff — revert your edits and run `gtd land` on a clean tree to approve, or run `gtd abandon`",
|
|
1433
|
+
})
|
|
1434
|
+
expect(headOf(repo)).toBe(head)
|
|
1435
|
+
})
|
|
1436
|
+
|
|
1437
|
+
const stageThenRevert = (repo: InMemRepo): void => {
|
|
1438
|
+
repo.writeFile("src/a.ts", "edited\n")
|
|
1439
|
+
repo.stageAll()
|
|
1440
|
+
repo.writeFile("src/a.ts", "a\n")
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
it("refuses an edit staged and then reverted in the worktree", async () => {
|
|
1444
|
+
const repo = await atGate()
|
|
1445
|
+
stageThenRevert(repo)
|
|
1446
|
+
expect((await snapshotAt(repo)).landing).toMatchObject({
|
|
1447
|
+
kind: "refusal",
|
|
1448
|
+
message: expect.stringContaining("approves exactly the judged diff"),
|
|
1449
|
+
})
|
|
1450
|
+
})
|
|
1451
|
+
|
|
1452
|
+
it("counts no index-only edit at a rest that approves nothing", async () => {
|
|
1453
|
+
const repo = await atGate()
|
|
1454
|
+
await land(repo)
|
|
1455
|
+
stageThenRevert(repo)
|
|
1456
|
+
const rest = await provide(currentRest, repo)
|
|
1457
|
+
expect(rest.state).toBe("after")
|
|
1458
|
+
expect(rest.changes).toEqual([])
|
|
1459
|
+
expect((await snapshotAt(repo)).landing).toEqual({ kind: "noop", settled: false })
|
|
1460
|
+
})
|
|
1461
|
+
|
|
1462
|
+
it("refuses a clean tree when git has no author identity", async () => {
|
|
1463
|
+
const repo = await atGate()
|
|
1464
|
+
const git = { ...fakeGitOperations(repo), authorIdent: () => Effect.succeed(Option.none()) }
|
|
1465
|
+
const { landing } = await snapshotAt(repo, git)
|
|
1466
|
+
expect(landing).toEqual({
|
|
1467
|
+
kind: "refusal",
|
|
1468
|
+
message:
|
|
1469
|
+
"gtd land: approval-gate: gate#1 records who approved it, but git has no author identity — set user.name and user.email",
|
|
1470
|
+
})
|
|
1471
|
+
})
|
|
1472
|
+
|
|
1473
|
+
it("refuses an identity a trailer cannot carry", async () => {
|
|
1474
|
+
const repo = await atGate()
|
|
1475
|
+
const git = {
|
|
1476
|
+
...fakeGitOperations(repo),
|
|
1477
|
+
authorIdent: () => Effect.succeed(Option.some({ name: "Ada\u001b[31m", email: "a@x.io" })),
|
|
1478
|
+
}
|
|
1479
|
+
expect((await snapshotAt(repo, git)).landing).toEqual({
|
|
1480
|
+
kind: "refusal",
|
|
1481
|
+
message:
|
|
1482
|
+
"gtd land: approval-gate: gate#1 cannot record Ada\u001b[31m <a@x.io> as approver — it would not read back unchanged from its Gtd-Approved trailer (a control character, for example)",
|
|
1483
|
+
})
|
|
1484
|
+
})
|
|
1485
|
+
|
|
1486
|
+
it("approve implies acceptClean even when acceptClean is false", async () => {
|
|
1487
|
+
const repo = repoWith(GATED.replace("approve: true", "approve: true, acceptClean: false"))
|
|
1488
|
+
await land(repo, { "src/a.ts": "a\n" })
|
|
1489
|
+
expect((await snapshotAt(repo)).landing.kind).toBe("commit")
|
|
1490
|
+
})
|
|
1491
|
+
|
|
1492
|
+
it("lands a clean tree as a commit carrying the approver", async () => {
|
|
1493
|
+
const repo = await atGate()
|
|
1494
|
+
const { landing } = await snapshotAt(repo)
|
|
1495
|
+
if (landing.kind !== "commit") throw new Error(`expected commit, got ${landing.kind}`)
|
|
1496
|
+
expect(landing.to).toBe("after")
|
|
1497
|
+
expect(landing.spec.approved).toEqual({
|
|
1498
|
+
step: { name: "gate", occurrence: 1 },
|
|
1499
|
+
name: "Test",
|
|
1500
|
+
email: "test@test.com",
|
|
1501
|
+
})
|
|
1502
|
+
})
|
|
1503
|
+
|
|
1504
|
+
it("reads the author identity only at an approve rest", async () => {
|
|
1505
|
+
const repo = repoWith(GATED)
|
|
1506
|
+
repo.writeFile("src/a.ts", "a\n")
|
|
1507
|
+
const git = {
|
|
1508
|
+
...fakeGitOperations(repo),
|
|
1509
|
+
authorIdent: () => Effect.die(new Error("authorIdent read at a plain rest")),
|
|
1510
|
+
}
|
|
1511
|
+
expect((await snapshotAt(repo, git)).landing.kind).toBe("commit")
|
|
1512
|
+
})
|
|
1513
|
+
|
|
1514
|
+
it("collects each approval of the current process, oldest first", async () => {
|
|
1515
|
+
const repo = await atGate()
|
|
1516
|
+
expect((await provide(currentRun, repo)).approvals).toEqual([])
|
|
1517
|
+
const approval = await land(repo)
|
|
1518
|
+
expect((await provide(currentRun, repo)).approvals).toEqual([
|
|
1519
|
+
{ step: "gate#1", name: "Test", email: "test@test.com", commit: approval },
|
|
1520
|
+
])
|
|
1521
|
+
})
|
|
1522
|
+
|
|
1523
|
+
it("resumes past the approval commit without asking again", async () => {
|
|
1524
|
+
const repo = await atGate()
|
|
1525
|
+
await land(repo)
|
|
1526
|
+
expect(repo.lastCommitMessage()).toContain(
|
|
1527
|
+
'Gtd-Approved: {"step":"gate#1","name":"Test","email":"test@test.com"}',
|
|
1528
|
+
)
|
|
1529
|
+
expect((await provide(currentRest, repo)).state).toBe("after")
|
|
1530
|
+
})
|
|
1531
|
+
})
|