@pmelab/gtd 24.3.0 → 24.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.
Files changed (60) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +15 -4
  3. package/dist/chunks/{reserved-DJwU5C4z.mjs → reserved-B9pG93Fc.mjs} +52 -12
  4. package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
  5. package/dist/flows.mjs +9 -1
  6. package/dist/gtd.bundle.mjs +779 -812
  7. package/dist/steering.mjs +2 -2
  8. package/dist/testkit.mjs +663 -158
  9. package/dist/workflow-kit.mjs +9 -7
  10. package/dist/workflows/feature.mjs +152 -35
  11. package/dist/workflows/fix.mjs +2 -2
  12. package/dist/workflows/review.mjs +1 -1
  13. package/dist/workflows.mjs +223 -54
  14. package/package.json +1 -1
  15. package/src/cli/Program.test.ts +3 -8
  16. package/src/cli/Program.ts +1 -8
  17. package/src/edge/Edge.test.ts +5 -2
  18. package/src/edge/Edge.ts +3 -4
  19. package/src/emit/index.ts +1 -0
  20. package/src/flows/scripts.ts +6 -0
  21. package/src/platform/Git.test.ts +6 -9
  22. package/src/steering/FORMATS.md +1 -1
  23. package/src/steering/SteeringFormat.test.ts +9 -1
  24. package/src/steering/SteeringFormat.ts +7 -0
  25. package/src/steering/contract.test.ts +3 -0
  26. package/src/steering/index.ts +1 -0
  27. package/src/steering/review.test.ts +30 -0
  28. package/src/steering/review.ts +8 -2
  29. package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
  30. package/src/testkit/EmittedScriptRecognizer.ts +148 -51
  31. package/src/testkit/EmittedScripts.test.ts +166 -0
  32. package/src/testkit/FlowScripts.test.ts +242 -0
  33. package/src/testkit/FlowScripts.ts +326 -0
  34. package/src/testkit/GitTiers.ts +140 -49
  35. package/src/testkit/InMemRepo.ts +35 -2
  36. package/src/testkit/Layers.ts +10 -2
  37. package/src/testkit/TestRepo.ts +4 -1
  38. package/src/testkit/index.ts +1 -1
  39. package/src/workflow/discovery.test.ts +147 -0
  40. package/src/workflow/discovery.ts +5 -0
  41. package/src/workflow/fingerprint.test.ts +20 -4
  42. package/src/workflow/fingerprint.ts +8 -9
  43. package/src/workflow/index.ts +1 -6
  44. package/src/workflow/load.test.ts +5 -19
  45. package/src/workflow/load.ts +94 -10
  46. package/src/workflow-kit/review.test.ts +84 -0
  47. package/src/workflow-kit/review.ts +9 -6
  48. package/src/workflows/feature/review.test.ts +238 -12
  49. package/src/workflows/feature/review.ts +172 -31
  50. package/src/workflows/feature/steps.test.ts +12 -0
  51. package/src/workflows/feature/steps.ts +8 -1
  52. package/src/workflows/shared/access.test.ts +4 -2
  53. package/src/workflows/shared/access.ts +1 -0
  54. package/src/workflows/shared/options.test.ts +8 -0
  55. package/src/workflows/shared/options.ts +1 -0
  56. package/src/workflows/shared/steps.ts +2 -0
  57. package/src/workflows/shared/tail.ts +3 -11
  58. package/src/workflows/shared/text.test.ts +106 -0
  59. package/src/workflows/shared/text.ts +57 -1
  60. package/src/workflows/shared/vars.ts +1 -0
package/dist/testkit.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { configProblems, configure, installContext, isFactoryWorkflow, protocolMismatch, t as flows_exports } from "./flows.mjs";
1
+ import { checkScript, configProblems, configure, installContext, isFactoryWorkflow, moveScript, protocolMismatch, removeScript, restoreScript, revertScript, showScript, t as flows_exports } from "./flows.mjs";
2
2
  import { O as renderFormat, T as ACCESS_REFUSAL, _ as demandOf, a as landFields, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, i as judgeJson, l as renderLandPlain, m as statusOf, n as resolveSession, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches } from "./chunks/wire-DA7q2ikx.mjs";
3
- import { C as headingSections, S as headingSectionBodies, a as unansweredQuestions, b as parseThreadsWithFindings, n as openThreads, r as steeringFormatFor, s as parseCodeThreads, t as BUILT_IN_MODE_NAMES } from "./chunks/steering-D-PvRC5W.mjs";
3
+ import { C as headingSections, S as headingSectionBodies, a as unansweredQuestions, b as parseThreadsWithFindings, n as openThreads, r as steeringFormatFor, s as parseCodeThreads, t as BUILT_IN_MODE_NAMES, w as formatFinding } from "./chunks/steering-DVILanVf.mjs";
4
4
  import { $ as makeSemaphore, $i as toJSON, $n as mapBoth, $r as none$1, $t as provideMerge$1, A as catchTag, Ai as getOption, An as scopeTag, Ar as interrupt$2, At as sync$2, B as flatten$4, Bi as isSome, Bn as make$16, Br as tap$1, Bt as unsafeMakeSemaphore$1, C as all$1, Ci as unsafeFromArray, Cn as isInterruptedOnly, Cr as exitFailCause, Ct as scope, D as async, Di as Tag, Dn as ensuring$3, Dr as fiberId, Dt as serviceOption, E as asVoid$1, Ei as GenericTag, En as squash, Er as exitZipRight, Et as scopedWith$1, F as exit, Fi as filter$1, Fn as sequential, Fr as onInterrupt, Ft as tryPromise, G as forkScoped, Gi as isLeft, Gn as Class, Gr as withFiberRuntime, Gt as zipRight$3, H as forEach$2, Hi as match$4, Hn as set$1, Hr as uninterruptibleMask$1, Ht as withFiberRuntime$1, I as fail$5, Ii as flatMap$5, In as bind, Ir as scopeClose, It as try_, J as interrupt$4, Ji as right, Jn as die, Jr as empty$6, Jt as TaggedError$1, K as gen, Ki as left, Kn as spanTag, Kr as zipRight$1, Kt as zipWith, L as failCause$4, Li as fromNullable, Ln as bindTo, Lr as succeed$4, Lt as uninterruptible$1, M as dieMessage, Mi as empty$5, Mn as inheritAll$1, Mr as interruptFiber, Mt as tapErrorCause, N as either, Ni as fromIterable, Nn as interruptAllAs$1, Nr as interruptible$1, Nt as timeout, O as catchAll$1, Oi as add, On as forEachConcurrentDiscard, Or as fiberRefLocally, Ot as succeed$6, P as ensuring$2, Pi as splitAt, Pn as join$3, Pr as map$3, Pt as tryMap, Q as logWarning, Qi as format, Qn as map$5, Qr as make$19, Qt as provide$1, R as fiberIdWith, Ri as getOrElse, Rn as sleep, Rr as suspend$4, Rt as uninterruptibleMask, S as addFinalizer$1, Si as toArray, Sn as isInterrupted, Sr as exitFail, St as sandbox, T as as, Ti as unsafeHead, Tn as reduceWithContext, Tr as exitVoid, Tt as scoped$4, U as forkDaemon, Ui as none, Un as update$1, Ur as void_$2, Ut as zip$1, V as fnUntraced, Vi as map$8, Vn as modify$1, Vr as uninterruptible, Vt as when, W as forkIn, Wi as some, Wn as TypeId$10, Wr as whenEffect, Wt as zipLeft, X as intoDeferred, Xi as BaseProto, Xn as failCause$3, Xr as combineAll, Xt as fromEffect$5, Y as interruptible, Yi as pipeArguments, Yn as fail$4, Yr as combine, Yt as tagged, Z as isEffect, Zi as NodeInspectSymbol, Zn as isFailure, Zr as ids, Zt as mergeAll$2, _ as decodeSync, _i as prepend, _n as failureOrCause, _r as deferredMake, _t as repeat, a as Literal, aa as constVoid, ai as decode, an as forkIn$1, ar as as$1, at as matchCauseEffect, b as acquireRelease, bi as take$2, bn as interruptors, br as deferredUnsafeMake, bt as runPromiseExit$1, c as String$, ca as pipe, ci as appendAll, cn as addFinalizer, cr as catchAll, ct as onError, d as Union, di as filter, dn as extend, dr as deferredComplete, dt as orElseSucceed, ea as equals, ei as get$4, en as scoped$3, er as match$3, et as map$4, f as between, fi as fromIterable$1, fn as fork, fr as deferredFail, ft as promise, gi as of, gn as fail$3, gr as deferredIsDone, gt as raceWith, hi as map$7, hn as die$1, hr as deferredInterruptWith, ht as race, i as Defect, ia as constUndefined, ii as size$2, in as updateContext$1, ir as zipRight$2, it as matchCause, j as die$2, ji as omit, jn as _await$2, jr as interruptAsFiber, jt as tap, k as catchAllCause, ki as get$5, kn as scopeMake, kr as flatMap$3, kt as suspend$5, l as Struct, li as drop, ln as addFinalizerExit, lr as currentContext$1, lt as orDie, m as optional$1, mi as isEmpty$2, mn as NoSuchElementException, mr as deferredInterrupt, mt as provideService, n as Boolean$, na as constFalse, ni as set$2, nn as unsafeFork, nr as void_$3, nt as mapError, o as Never, oa as dual, oi as isFinite, on as unsafeMakeSemaphore, or as asVoid, ot as matchEffect, p as decodeUnknown, pi as isChunk, pn as make$18, pr as deferredFailCause, pt as provide$2, q as ignore, qi as match$2, qn as all, qr as DeferredTypeId$1, qt as spaced$1, r as Cause, ra as constTrue, ri as has, rn as unsafeRunPromiseExit, rr as zip$2, rt as match$5, s as Number$, sa as identity, si as append$1, sn as Scope, sr as asyncInterrupt, st as negate, t as Array$, ta as hasProperty, ti as make$17, tn as succeed$3, tr as succeed$5, tt as mapBoth$1, u as TaggedError, ui as empty$4, un as close, ur as deferredAwait, ut as orElse, v as encodeSync, vi as reduce, vn as flipCauseOption, vr as deferredSucceed, vt as retry, w as annotateLogs, wi as unsafeGet, wn as map$6, wr as exitSucceed, wt as scopeWith, x as acquireUseRelease$2, xi as takeRight, xn as isDieType, xr as exitAs, xt as runtime, y as _void, yi as reverse, yn as interrupt$3, yr as deferredUnsafeDone, yt as runPromise, z as flatMap$4, zi as isNone, zn as get$3, zr as sync$1, zt as unsafeMakeLatch } from "./chunks/Schema-B5yK7QrT.mjs";
5
5
  import { createRequire } from "node:module";
6
6
  import * as Crypto from "node:crypto";
@@ -9,11 +9,11 @@ import * as Path from "node:path";
9
9
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
10
10
  import { PassThrough } from "node:stream";
11
11
  import * as NFS from "node:fs";
12
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
12
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
13
13
  import * as OS from "node:os";
14
14
  import { homedir, tmpdir } from "node:os";
15
15
  import * as ChildProcess from "node:child_process";
16
- import { execFileSync, execSync } from "node:child_process";
16
+ import { execFileSync, execSync, spawnSync } from "node:child_process";
17
17
  import * as NodeUrl from "node:url";
18
18
  import { fileURLToPath, pathToFileURL } from "node:url";
19
19
  import * as readline from "node:readline";
@@ -2438,9 +2438,9 @@ const pipeToOrFail = /*#__PURE__*/ dual(2, (self, that) => suspend$3(() => {
2438
2438
  return pipeTo$1(pipeTo$1(pipeTo$1(self, reader), that), writer);
2439
2439
  }));
2440
2440
  /** @internal */
2441
- const run$3 = (self) => scopedWith$1((scope) => runIn(self, scope));
2441
+ const run$4 = (self) => scopedWith$1((scope) => runIn(self, scope));
2442
2442
  /** @internal */
2443
- const runDrain = (self) => run$3(drain$2(self));
2443
+ const runDrain = (self) => run$4(drain$2(self));
2444
2444
  /** @internal */
2445
2445
  const runScoped = (self) => scopeWith((scope) => runIn(self, scope));
2446
2446
  /** @internal */
@@ -3013,7 +3013,7 @@ const map = /*#__PURE__*/ dual(2, (self, f) => new StreamImpl(pipe(toChannel$2(s
3013
3013
  /** @internal */
3014
3014
  const mapChunks$1 = /*#__PURE__*/ dual(2, (self, f) => new StreamImpl(pipe(toChannel$2(self), mapOut(f))));
3015
3015
  /** @internal */
3016
- const mkString$1 = (self) => run$2(self, mkString$2);
3016
+ const mkString$1 = (self) => run$3(self, mkString$2);
3017
3017
  /** @internal */
3018
3018
  const pipeThroughChannel = /*#__PURE__*/ dual(2, (self, channel) => new StreamImpl(pipeTo$1(toChannel$2(self), channel)));
3019
3019
  /** @internal */
@@ -3022,9 +3022,9 @@ const repeatEffectChunkOption = (effect) => unfoldChunkEffect(effect, (effect) =
3022
3022
  onSome: fail$5
3023
3023
  }))));
3024
3024
  /** @internal */
3025
- const run$2 = /*#__PURE__*/ dual(2, (self, sink) => toChannel$2(self).pipe(pipeToOrFail(toChannel$3(sink)), runDrain));
3025
+ const run$3 = /*#__PURE__*/ dual(2, (self, sink) => toChannel$2(self).pipe(pipeToOrFail(toChannel$3(sink)), runDrain));
3026
3026
  /** @internal */
3027
- const runCollect$1 = (self) => run$2(self, collectAll());
3027
+ const runCollect$1 = (self) => run$3(self, collectAll());
3028
3028
  /** @internal */
3029
3029
  const runIntoQueueScoped = /*#__PURE__*/ dual(2, (self, queue) => {
3030
3030
  const writer = readWithCause({
@@ -3764,7 +3764,7 @@ const mkString = mkString$1;
3764
3764
  * @since 2.0.0
3765
3765
  * @category destructors
3766
3766
  */
3767
- const run$1 = run$2;
3767
+ const run$2 = run$3;
3768
3768
  /**
3769
3769
  * Runs the stream and collects all of its elements to a chunk.
3770
3770
  *
@@ -4726,7 +4726,7 @@ const makeExecutor$1 = (start) => {
4726
4726
  stream,
4727
4727
  string: (command, encoding = "utf-8") => {
4728
4728
  const decoder = new TextDecoder(encoding);
4729
- return pipe(start(command), flatMap$4((process) => run$1(process.stdout, collectUint8Array)), map$4((bytes) => decoder.decode(bytes)), scoped$4);
4729
+ return pipe(start(command), flatMap$4((process) => run$2(process.stdout, collectUint8Array)), map$4((bytes) => decoder.decode(bytes)), scoped$4);
4730
4730
  },
4731
4731
  lines: (command, encoding = "utf-8") => {
4732
4732
  return pipe(streamLines(command, encoding), runCollect, map$4(toArray));
@@ -4959,7 +4959,7 @@ const ProcessProto = {
4959
4959
  };
4960
4960
  }
4961
4961
  };
4962
- const runCommand$1 = (fileSystem) => (command) => {
4962
+ const runCommand$2 = (fileSystem) => (command) => {
4963
4963
  switch (command._tag) {
4964
4964
  case "StandardCommand": {
4965
4965
  const spawn = flatMap$4(make$15(), (exitCode) => async((resume) => {
@@ -5031,17 +5031,17 @@ const runCommand$1 = (fileSystem) => (command) => {
5031
5031
  stderr,
5032
5032
  stdout
5033
5033
  });
5034
- }), typeof command.stdin === "string" ? identity : tap((process) => forkDaemon(run$1(command.stdin, process.stdin))));
5034
+ }), typeof command.stdin === "string" ? identity : tap((process) => forkDaemon(run$2(command.stdin, process.stdin))));
5035
5035
  }
5036
5036
  case "PipedCommand": {
5037
5037
  const flattened = flatten(command);
5038
- if (flattened.length === 1) return pipe(flattened[0], runCommand$1(fileSystem));
5038
+ if (flattened.length === 1) return pipe(flattened[0], runCommand$2(fileSystem));
5039
5039
  const head = flattened[0];
5040
5040
  const tail = flattened.slice(1);
5041
5041
  const initial = tail.slice(0, tail.length - 1);
5042
5042
  const last = tail[tail.length - 1];
5043
- const stream = initial.reduce((stdin$2, command) => pipe(stdin(command, stdin$2), runCommand$1(fileSystem), map$4((process) => process.stdout), unwrapScoped), pipe(runCommand$1(fileSystem)(head), map$4((process) => process.stdout), unwrapScoped));
5044
- return pipe(stdin(last, stream), runCommand$1(fileSystem));
5043
+ const stream = initial.reduce((stdin$2, command) => pipe(stdin(command, stdin$2), runCommand$2(fileSystem), map$4((process) => process.stdout), unwrapScoped), pipe(runCommand$2(fileSystem)(head), map$4((process) => process.stdout), unwrapScoped));
5044
+ return pipe(stdin(last, stream), runCommand$2(fileSystem));
5045
5045
  }
5046
5046
  }
5047
5047
  };
@@ -5051,7 +5051,7 @@ const runCommand$1 = (fileSystem) => (command) => {
5051
5051
  * @since 1.0.0
5052
5052
  * @category layer
5053
5053
  */
5054
- const layer$5 = /* @__PURE__ */ effect$1(CommandExecutor, /*#__PURE__*/ pipe(FileSystem, /*#__PURE__*/ map$4((fileSystem) => makeExecutor(runCommand$1(fileSystem)))));
5054
+ const layer$5 = /* @__PURE__ */ effect$1(CommandExecutor, /*#__PURE__*/ pipe(FileSystem, /*#__PURE__*/ map$4((fileSystem) => makeExecutor(runCommand$2(fileSystem)))));
5055
5055
  //#endregion
5056
5056
  //#region node_modules/@effect/platform-node-shared/dist/esm/internal/path.js
5057
5057
  const fromFileUrl = (url) => try_({
@@ -5352,7 +5352,7 @@ const classifyUntracked = (exec, ref, untrackedPaths) => gen(function* () {
5352
5352
  * `git add`. Callers that expect a probe to fail handle it with an explicit
5353
5353
  * `catchAll`.
5354
5354
  */
5355
- const run = (root, ...args) => scoped$4(gen(function* () {
5355
+ const run$1 = (root, ...args) => scoped$4(gen(function* () {
5356
5356
  const process = yield* (yield* CommandExecutor).start(make$1(...args).pipe(workingDirectory(root), stdout("pipe"), stderr("pipe")));
5357
5357
  const collect = (stream) => stream.pipe(decodeText(), mkString);
5358
5358
  const [stdout$3, stderr$3, exitCode] = yield* all$1([
@@ -5386,7 +5386,7 @@ var InvalidRefError = class extends Error {
5386
5386
  */
5387
5387
  const resolvedRefOrCorrupted = (ref, hash) => /^[0-9a-f]{40}$/.test(hash) ? succeed$6(hash) : fail$5(new InvalidRefError(`Invalid ref: ${ref}`, [`ref: ${ref}`]));
5388
5388
  const makeGitImpl = (executor, root) => {
5389
- const exec = (...args) => run(root, ...args).pipe(provide$2(succeed$1(CommandExecutor, executor)));
5389
+ const exec = (...args) => run$1(root, ...args).pipe(provide$2(succeed$1(CommandExecutor, executor)));
5390
5390
  return {
5391
5391
  lastCommitSubject: (ref = "HEAD") => exec("git", "log", "-1", "--pretty=%s", "--end-of-options", ref).pipe(map$4((s) => s.trim())),
5392
5392
  lastCommitMessage: (ref = "HEAD") => exec("git", "log", "-1", "--pretty=%B", "--end-of-options", ref).pipe(map$4((s) => s.trim())),
@@ -5420,7 +5420,7 @@ const makeGitImpl = (executor, root) => {
5420
5420
  }),
5421
5421
  resolveRef: (ref) => exec("git", "rev-parse", "--verify", "--end-of-options", ref).pipe(map$4((s) => s.trim()), flatMap$4((hash) => resolvedRefOrCorrupted(ref, hash))),
5422
5422
  readRefOption: (ref) => exec("git", "rev-parse", "--verify", "--quiet", ref).pipe(map$4((s) => some(s.trim())), catchAll$1(() => succeed$6(none()))),
5423
- isAncestor: (a, b) => run(root, "git", "merge-base", "--is-ancestor", a, b).pipe(provide$2(succeed$1(CommandExecutor, executor)), map$4(() => true), catchAll$1(() => succeed$6(false))),
5423
+ isAncestor: (a, b) => run$1(root, "git", "merge-base", "--is-ancestor", a, b).pipe(provide$2(succeed$1(CommandExecutor, executor)), map$4(() => true), catchAll$1(() => succeed$6(false))),
5424
5424
  mergeBase: (a, b) => exec("git", "merge-base", a, b).pipe(map$4((s) => some(s.trim())), catchAll$1(() => succeed$6(none()))),
5425
5425
  defaultBranch: () => exec("git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD").pipe(map$4((s) => s.trim()), catchAll$1(() => succeed$6("")), map$4((name) => name === "" ? "main" : name)),
5426
5426
  topLevel: () => exec("git", "rev-parse", "--show-toplevel").pipe(map$4((s) => s.trim())),
@@ -7503,6 +7503,30 @@ var ConfigDiscovery = class ConfigDiscovery extends Tag("ConfigDiscovery")() {
7503
7503
  });
7504
7504
  };
7505
7505
  //#endregion
7506
+ //#region src/workflow/fingerprint.ts
7507
+ const HASH_LENGTH = 16;
7508
+ const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f]/;
7509
+ const contentHash = (content) => createHash("sha256").update(content).digest("hex").slice(0, HASH_LENGTH);
7510
+ const realOr = (path) => {
7511
+ try {
7512
+ return realpathSync(path);
7513
+ } catch {
7514
+ return path;
7515
+ }
7516
+ };
7517
+ const configFingerprint = (files, root, read) => {
7518
+ const fingerprint = {};
7519
+ const realRoot = realOr(root);
7520
+ for (const file of files) {
7521
+ const path = relative(realRoot, realOr(file)).split(sep).join("/");
7522
+ if (CONTROL_CHARACTER.test(path)) continue;
7523
+ const content = read(file);
7524
+ if (content !== void 0) fingerprint[path] = contentHash(content);
7525
+ }
7526
+ return fingerprint;
7527
+ };
7528
+ const changedConfigFiles = (recorded, current) => [.../* @__PURE__ */ new Set([...Object.keys(recorded), ...Object.keys(current)])].filter((path) => recorded[path] !== current[path]).sort();
7529
+ //#endregion
7506
7530
  //#region src/workflow/Diagnostic.ts
7507
7531
  const compareSegment = (a, b) => {
7508
7532
  if (typeof a === "number" && typeof b === "number") return a - b;
@@ -7637,9 +7661,16 @@ const load = gen(function* () {
7637
7661
  },
7638
7662
  ...compiled.ui !== void 0 ? { ui: compiled.ui } : {},
7639
7663
  warnings: diagnostics.filter((d) => d.severity === "warning"),
7640
- workflowFiles: loaded.file.dependencyFiles
7664
+ fingerprint: configFingerprint(loaded.file.dependencyFiles, host.root, module.read ?? readFromDisk)
7641
7665
  };
7642
7666
  });
7667
+ const readFromDisk = (path) => {
7668
+ try {
7669
+ return readFileSync(path, "utf8");
7670
+ } catch {
7671
+ return;
7672
+ }
7673
+ };
7643
7674
  const TRANSFORM_MEMO_SIZE = 512;
7644
7675
  const memoized = (transform) => {
7645
7676
  const memo = /* @__PURE__ */ new Map();
@@ -7656,12 +7687,18 @@ const memoized = (transform) => {
7656
7687
  };
7657
7688
  let jitiModule;
7658
7689
  let transform;
7659
- const jiti = (shipped) => {
7690
+ const jiti = (shipped, local) => {
7660
7691
  const require = createRequire(import.meta.url);
7661
7692
  jitiModule ??= require("jiti");
7662
7693
  transform ??= memoized(require(join(dirname(require.resolve("jiti/package.json")), "dist", "babel.cjs")));
7663
7694
  return jitiModule.createJiti(import.meta.url, {
7664
- virtualModules: shipped.modules,
7695
+ virtualModules: new Proxy(shipped.modules, {
7696
+ has: (modules, id) => id in modules || typeof id === "string" && local?.resolve(id) !== void 0,
7697
+ get: (modules, id) => {
7698
+ const path = typeof id === "string" ? local?.resolve(id) : void 0;
7699
+ return path === void 0 ? modules[id] : local.load(path);
7700
+ }
7701
+ }),
7665
7702
  alias: shipped.missing,
7666
7703
  fsCache: false,
7667
7704
  moduleCache: false,
@@ -7858,12 +7895,9 @@ const namespaceOf = (exported) => typeof exported === "object" && exported !== n
7858
7895
  * pulled in, transitively.
7859
7896
  */
7860
7897
  const loadWorkflows = (module, shipped) => {
7861
- const cache = {};
7862
- const exports = namespaceOf(jiti(shipped).evalModule(module.source, {
7863
- filename: module.filepath,
7864
- cache
7865
- }));
7866
- const file = fileFrom(exports, module.filepath, Object.keys(cache));
7898
+ const { exports: evaluated, files } = module.read === void 0 ? evaluateFromDisk(module, shipped) : evaluateRead(module, module.read, shipped);
7899
+ const exports = namespaceOf(evaluated);
7900
+ const file = fileFrom(exports, module.filepath, files);
7867
7901
  const exported = Object.entries(exports).filter(([name, value]) => !RESERVED_EXPORTS.has(name) && isWorkflow(value));
7868
7902
  const value = defaultExport(exports);
7869
7903
  const defaultName = exported.find(([, other]) => other === value)?.[0] ?? "default";
@@ -7887,6 +7921,49 @@ const loadWorkflows = (module, shipped) => {
7887
7921
  diagnostics: nameRejections(rejections)
7888
7922
  };
7889
7923
  };
7924
+ const evaluateFromDisk = (module, shipped) => {
7925
+ const cache = {};
7926
+ return {
7927
+ exports: jiti(shipped).evalModule(module.source, {
7928
+ filename: module.filepath,
7929
+ cache
7930
+ }),
7931
+ files: Object.keys(cache)
7932
+ };
7933
+ };
7934
+ const moduleCandidates = (path) => [
7935
+ path,
7936
+ path.replace(/\.js$/, ".ts"),
7937
+ path.replace(/\.mjs$/, ".mts"),
7938
+ `${path}.ts`,
7939
+ join(path, "index.ts")
7940
+ ];
7941
+ /**
7942
+ * Evaluates the config with every local module it imports taken from `read`
7943
+ * instead of the disk. jiti matches a virtual module on the specifier as
7944
+ * written, so each module gets its own instance resolving against its own
7945
+ * directory. A specifier `read` cannot serve falls through to the disk, and
7946
+ * fails the way a missing file does.
7947
+ */
7948
+ const evaluateRead = (module, read, shipped) => {
7949
+ const files = [module.filepath];
7950
+ const evaluated = /* @__PURE__ */ new Map();
7951
+ const evaluate = (filepath, source) => jiti(shipped, {
7952
+ resolve: (specifier) => /^\.\.?\//.test(specifier) ? moduleCandidates(join(dirname(filepath), specifier)).find((candidate) => read(candidate) !== void 0) : void 0,
7953
+ load: (path) => {
7954
+ if (!evaluated.has(path)) {
7955
+ evaluated.set(path, {});
7956
+ files.push(path);
7957
+ evaluated.set(path, evaluate(path, read(path)));
7958
+ }
7959
+ return evaluated.get(path);
7960
+ }
7961
+ }).evalModule(source, { filename: filepath });
7962
+ return {
7963
+ exports: evaluate(module.filepath, module.source),
7964
+ files
7965
+ };
7966
+ };
7890
7967
  const isWorkflow = (value) => typeof value === "function" || isFactoryWorkflow(value);
7891
7968
  const defaultExport = (exports) => {
7892
7969
  const value = exports.default;
@@ -8114,35 +8191,6 @@ const resolveScopeAccess = (def, skills, vars) => gen(function* () {
8114
8191
  //#region src/workflow/Workflow.ts
8115
8192
  const knownModes = (def) => Object.keys(def.modes);
8116
8193
  //#endregion
8117
- //#region src/workflow/fingerprint.ts
8118
- const HASH_LENGTH = 16;
8119
- const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f]/;
8120
- const contentHash = (content) => createHash("sha256").update(content).digest("hex").slice(0, HASH_LENGTH);
8121
- const realOr = (path) => {
8122
- try {
8123
- return realpathSync(path);
8124
- } catch {
8125
- return path;
8126
- }
8127
- };
8128
- const configFingerprint = (files, root) => {
8129
- const fingerprint = {};
8130
- const realRoot = realOr(root);
8131
- for (const file of files) {
8132
- const path = relative(realRoot, realOr(file)).split(sep).join("/");
8133
- if (CONTROL_CHARACTER.test(path)) continue;
8134
- let content;
8135
- try {
8136
- content = readFileSync(file);
8137
- } catch {
8138
- continue;
8139
- }
8140
- fingerprint[path] = contentHash(content);
8141
- }
8142
- return fingerprint;
8143
- };
8144
- const changedConfigFiles = (recorded, current) => [.../* @__PURE__ */ new Set([...Object.keys(recorded), ...Object.keys(current)])].filter((path) => recorded[path] !== current[path]).sort();
8145
- //#endregion
8146
8194
  //#region src/step/planStep.ts
8147
8195
  /**
8148
8196
  * At the human review gate an unconditional `gtd uncheck '<file>'` runs
@@ -8858,7 +8906,7 @@ const restAt = (ref, startOverrides = {}) => gen(function* () {
8858
8906
  path: e.path
8859
8907
  })) : [];
8860
8908
  const memory = memoryOf(outcome.trace, run);
8861
- const configNow = configFingerprint(config.workflowFiles, host.root);
8909
+ const configNow = config.fingerprint;
8862
8910
  if (ref === void 0) yield* warnConfigChanges(run, configNow, workspace);
8863
8911
  return {
8864
8912
  def,
@@ -9087,7 +9135,7 @@ const inRepoConfigPaths = (root, files, recorded) => {
9087
9135
  const configRefusal = (tip, paths) => /* @__PURE__ */ new Error(`gtd history: refused — config differs at ${tip.slice(0, 7)}: ${paths.join(", ")}. A history ref replays under today's config, so it is unreadable after the config changes`);
9088
9136
  const differingConfigPaths = (git, workspace, root, config, run, hash) => gen(function* () {
9089
9137
  const differing = [];
9090
- for (const path of inRepoConfigPaths(root, config.workflowFiles, run.pinnedConfig?.files ?? {})) if ((yield* orElseSucceed(git.readFileAtRef(hash, path), () => void 0)) !== workspace.readSync(path)) differing.push(path);
9138
+ for (const path of inRepoConfigPaths(root, Object.keys(config.fingerprint), run.pinnedConfig?.files ?? {})) if ((yield* orElseSucceed(git.readFileAtRef(hash, path), () => void 0)) !== workspace.readSync(path)) differing.push(path);
9091
9139
  return differing;
9092
9140
  });
9093
9141
  const notClosedError = (hash, outcome) => outcome.kind === "rest" ? /* @__PURE__ */ new Error(`gtd history: ${hash.slice(0, 7)} does not close a process under today's config`) : replayError(outcome) ?? /* @__PURE__ */ new Error("gtd history: replay failed");
@@ -10079,12 +10127,6 @@ const runValidateCommand = (out) => gen(function* () {
10079
10127
  const resolved = yield* resolveValidateScript(rest);
10080
10128
  out.write(resolved !== void 0 && resolved.script.length > 0 ? `${resolved.script}\n` : `nothing to validate at "${rest.state}"\n`);
10081
10129
  });
10082
- /** `file:line:col: message` — the shape editors and grep already jump on. Stored 0-based, printed 1-based. A finding with a `line` but no `range`, or none at all, degrades to the shorter forms. */
10083
- const formatFinding = (file, finding) => {
10084
- if (finding.line === void 0) return finding.message;
10085
- const col = finding.range !== void 0 ? `:${finding.range.start.character + 1}` : "";
10086
- return `${file}:${finding.line + 1}${col}: ${finding.message}`;
10087
- };
10088
10130
  /**
10089
10131
  * Runs `<mode>`'s parser over `<file>`, exiting non-zero on any finding.
10090
10132
  * Resolves no workflow state and reads no config — this is what a seeded
@@ -10300,7 +10342,7 @@ const dispatchVoidCommand = (command, json, out) => {
10300
10342
  * (the `standaloneKinds` run bare). Both guard checks run before `dispatch`,
10301
10343
  * so a refusal writes nothing and emits no script by construction.
10302
10344
  */
10303
- const runCommand = (command, json, out) => {
10345
+ const runCommand$1 = (command, json, out) => {
10304
10346
  const dispatch = dispatchVoidCommand(command, json, out);
10305
10347
  if (needsOf(command.kind) !== "state") return dispatch;
10306
10348
  return gen(function* () {
@@ -11261,7 +11303,7 @@ const runCli = (argv, io) => {
11261
11303
  io.exit(2);
11262
11304
  });
11263
11305
  const out = bufferedArtifactOut(io);
11264
- return runCommand(plan.command, plan.json, out).pipe(map$4(() => {
11306
+ return runCommand$1(plan.command, plan.json, out).pipe(map$4(() => {
11265
11307
  out.flush();
11266
11308
  io.exit(0);
11267
11309
  }), provide$2(io.layers(plan.verbose)), sandbox, catchAll$1((cause) => isInterruptedOnly(cause) ? failCause$4(cause) : report(io, plan.json.kind !== "off")(cause)));
@@ -11340,10 +11382,10 @@ var InMemRepo = class InMemRepo {
11340
11382
  }
11341
11383
  resolveRef(ref) {
11342
11384
  if (/^[0-9a-f]{40}$/.test(ref)) return this.commits.has(ref) ? ref : null;
11343
- const tildeMatch = /^(HEAD|[0-9a-f]{40})(~(\d+))?$/.exec(ref);
11385
+ const tildeMatch = /^(HEAD|[0-9a-f]{40})(~(\d+)|\^)?$/.exec(ref);
11344
11386
  if (tildeMatch) {
11345
11387
  const base = tildeMatch[1];
11346
- const steps = tildeMatch[3] !== void 0 ? parseInt(tildeMatch[3], 10) : 0;
11388
+ const steps = tildeMatch[3] !== void 0 ? parseInt(tildeMatch[3], 10) : tildeMatch[2] === "^" ? 1 : 0;
11347
11389
  let cur = base === "HEAD" ? this.head : this.commits.has(base) ? base : null;
11348
11390
  for (let i = 0; i < steps; i++) {
11349
11391
  if (cur === null) return null;
@@ -11480,6 +11522,33 @@ var InMemRepo = class InMemRepo {
11480
11522
  const dirPrefix = prefix.endsWith("/") ? prefix : `${prefix}/`;
11481
11523
  return [...this.worktree.keys()].filter((key) => key === prefix || key.startsWith(dirPrefix)).sort();
11482
11524
  }
11525
+ /** Everything git would keep on disk, serialized — two snapshots compare equal exactly when nothing was written. */
11526
+ snapshot() {
11527
+ const sorted = (m) => [...m.entries()].sort(([a], [b]) => a.localeCompare(b));
11528
+ return JSON.stringify({
11529
+ head: this.head,
11530
+ branch: this.currentBranch,
11531
+ commits: [...this.commits.keys()].sort(),
11532
+ branches: sorted(this.branches),
11533
+ refs: sorted(this.refs),
11534
+ index: sorted(this.index),
11535
+ worktree: sorted(this.worktree)
11536
+ });
11537
+ }
11538
+ /** Every ref name under `prefix` (`refs/…`), sorted — `git for-each-ref <prefix>`. */
11539
+ refNamesUnder(prefix) {
11540
+ return [...this.refs.keys()].filter((ref) => ref.startsWith(prefix)).sort();
11541
+ }
11542
+ /** Whether `path` is in the index — what `git diff <commit> -- <path>` compares against the worktree. */
11543
+ isTracked(path) {
11544
+ return this.index.has(path);
11545
+ }
11546
+ /** `git add <path>` — the index takes `path` as the worktree has it, or drops it. */
11547
+ stage(path) {
11548
+ const content = this.worktree.get(path);
11549
+ if (content === void 0) this.index.delete(path);
11550
+ else this.index.set(path, content);
11551
+ }
11483
11552
  /** `git add -A` — the index becomes exactly the current worktree. */
11484
11553
  stageAll() {
11485
11554
  this.index = new Map(this.worktree);
@@ -11809,7 +11878,8 @@ const makeInMemoryConfigDiscovery = (repo, root) => {
11809
11878
  const source = workspace.atPath(filepath);
11810
11879
  if (source !== void 0) return {
11811
11880
  filepath,
11812
- source
11881
+ source,
11882
+ read: workspace.atPath
11813
11883
  };
11814
11884
  }
11815
11885
  },
@@ -11872,6 +11942,269 @@ const makeCapturingCliIo = (repo, env = {}, tools = {}) => {
11872
11942
  };
11873
11943
  };
11874
11944
  //#endregion
11945
+ //#region src/testkit/FlowScripts.ts
11946
+ const SHEBANG = "#!/usr/bin/env sh";
11947
+ const words$1 = (text) => [...text.matchAll(/'((?:[^']|'\\'')*)'/g)].map((m) => m[1].replaceAll("'\\''", "'"));
11948
+ const ok = (output = "") => ({
11949
+ ok: true,
11950
+ output
11951
+ });
11952
+ /** `rm -rf -- <paths>`. `InMemRepo.pathsUnder("")` is the whole tree, where a real `rm -- ''` removes nothing. */
11953
+ const removeAll = (repo, paths) => {
11954
+ if (paths.includes("")) throw new Error("testkit: rm: refusing an empty path");
11955
+ for (const path of paths) for (const file of repo.pathsUnder(path)) repo.deleteFile(file);
11956
+ };
11957
+ const deleteIfPresent = (repo, path) => {
11958
+ if (repo.readFile(path) !== void 0) repo.deleteFile(path);
11959
+ };
11960
+ const recognizeRemove = (repo, script) => {
11961
+ const line = script.split("\n").find((l) => l.startsWith("rm -rf -- ")) ?? "";
11962
+ const paths = words$1(line);
11963
+ if (removeScript(paths) !== script) return void 0;
11964
+ removeAll(repo, paths);
11965
+ return ok();
11966
+ };
11967
+ const recognizeMove = (repo, script) => {
11968
+ const line = script.split("\n").find((l) => l.startsWith("mv -- ")) ?? "";
11969
+ const [from, to] = words$1(line);
11970
+ if (from === void 0 || to === void 0 || moveScript(from, to) !== script) return void 0;
11971
+ const content = repo.readFile(from);
11972
+ if (content === void 0) return {
11973
+ ok: false,
11974
+ error: `mv: ${from}: No such file`,
11975
+ output: ""
11976
+ };
11977
+ repo.deleteFile(from);
11978
+ repo.writeFile(to, content);
11979
+ return ok();
11980
+ };
11981
+ const recognizeShow = (repo, script) => {
11982
+ const line = script.split("\n").find((l) => l.startsWith("git show ")) ?? "";
11983
+ const [source, to] = words$1(line);
11984
+ const at = source?.indexOf(":") ?? -1;
11985
+ if (source === void 0 || to === void 0 || at === -1) return void 0;
11986
+ const ref = source.slice(0, at);
11987
+ const path = source.slice(at + 1);
11988
+ if (showScript(ref, path, to) !== script) return void 0;
11989
+ const content = repo.fileAtRef(ref, path);
11990
+ if (content === null) return {
11991
+ ok: false,
11992
+ error: `git show: ${source} does not exist`,
11993
+ output: ""
11994
+ };
11995
+ repo.writeFile(to, content);
11996
+ return ok();
11997
+ };
11998
+ /** `git diff --quiet <commit> -- <path>`: the worktree copy of a tracked path, or nothing for an untracked one, against `commit`'s. */
11999
+ const unchangedSince = (repo, commit, path) => {
12000
+ return (repo.isTracked(path) ? repo.readFile(path) ?? null : null) === repo.fileAtRef(commit, path);
12001
+ };
12002
+ const RESTORE_LINE = / && git restore --source=/;
12003
+ const REMOVE_LINE = / && rm -f -- /;
12004
+ const parseRestoreLine = (line) => {
12005
+ const [commit, path, source] = words$1(line);
12006
+ if (commit === void 0 || path === void 0) return void 0;
12007
+ if (RESTORE_LINE.test(line) && source !== void 0) return {
12008
+ commit,
12009
+ path,
12010
+ source
12011
+ };
12012
+ return REMOVE_LINE.test(line) ? {
12013
+ commit,
12014
+ path
12015
+ } : void 0;
12016
+ };
12017
+ /** Applies one line, returning its exit status as `sh` would see it. */
12018
+ const applyRestoreLine = (repo, { commit, path, source }) => {
12019
+ if (!unchangedSince(repo, commit, path)) return false;
12020
+ if (source !== void 0 && repo.resolveRef(source) === null) return false;
12021
+ const content = source === void 0 ? null : repo.fileAtRef(source, path);
12022
+ if (content !== null) repo.writeFile(path, content);
12023
+ else if (source === void 0 || repo.isTracked(path)) deleteIfPresent(repo, path);
12024
+ return true;
12025
+ };
12026
+ const recognizeRestore = (repo, script) => {
12027
+ const parsed = script.split("\n").slice(2, -1).map(parseRestoreLine);
12028
+ if (parsed.includes(void 0)) return void 0;
12029
+ const lines = parsed;
12030
+ const restoring = lines.filter((l) => l.source !== void 0);
12031
+ const paths = {
12032
+ restore: restoring.map((l) => l.path),
12033
+ remove: lines.filter((l) => l.source === void 0).map((l) => l.path)
12034
+ };
12035
+ if (restoreScript(lines[0]?.commit ?? "", paths, restoring[0]?.source) !== script) return void 0;
12036
+ if (lines.map((line) => applyRestoreLine(repo, line)).at(-1) === false) return {
12037
+ ok: false,
12038
+ error: "the last path changed since the commit",
12039
+ output: ""
12040
+ };
12041
+ return ok();
12042
+ };
12043
+ const PATCH_FAILED = "error: patch does not apply";
12044
+ /**
12045
+ * `git diff C^ C | git apply -R`, which is all or nothing. A file still
12046
+ * exactly as `C` left it reverts; a file gone, or one `C` added and that
12047
+ * changed since, fails the whole patch. A file `C` modified and that changed
12048
+ * since may still apply through its context — that needs real git, so it
12049
+ * throws rather than guess.
12050
+ */
12051
+ const reverseApply = (repo, commit) => {
12052
+ const parent = `${commit}^`;
12053
+ const changed = [.../* @__PURE__ */ new Set([...repo.pathsAtRef(parent), ...repo.pathsAtRef(commit)])].filter((p) => repo.fileAtRef(parent, p) !== repo.fileAtRef(commit, p));
12054
+ for (const path of changed) {
12055
+ const after = repo.fileAtRef(commit, path);
12056
+ const current = repo.readFile(path) ?? null;
12057
+ if (current === after) continue;
12058
+ if (current === null || after === null || repo.fileAtRef(parent, path) === null) return `${PATCH_FAILED}: ${path}`;
12059
+ throw new Error(`testkit: cannot tell in memory whether reverting ${commit} applies to the hand-edited ${path} — run this @live`);
12060
+ }
12061
+ for (const path of changed) {
12062
+ const before = repo.fileAtRef(parent, path);
12063
+ if (before === null) repo.deleteFile(path);
12064
+ else repo.writeFile(path, before);
12065
+ }
12066
+ };
12067
+ const recognizeRevert = (repo, script) => {
12068
+ const lines = script.split("\n");
12069
+ const [, commit] = words$1(lines.find((l) => l.startsWith("if ! git diff --quiet ")) ?? "");
12070
+ const [, failure] = words$1(lines.find((l) => l.includes("printf '%s\\n\\n' ")) ?? "");
12071
+ const [report] = words$1(lines.find((l) => l.startsWith(" } > ")) ?? "");
12072
+ if (commit === void 0 || failure === void 0 || report === void 0) return void 0;
12073
+ if (revertScript(commit, report, failure) !== script) return void 0;
12074
+ const error = reverseApply(repo, commit);
12075
+ if (error !== void 0) repo.writeFile(report, `${failure}\n\n${error}\n`);
12076
+ return ok();
12077
+ };
12078
+ /** One preamble line — the closed vocabulary the bundled workflows write there. */
12079
+ const runPreambleLine = (repo, line) => {
12080
+ const quoted = words$1(line);
12081
+ if (line === "exit 0") return {
12082
+ exit: true,
12083
+ output: ""
12084
+ };
12085
+ if (line.startsWith("mkdir -p ")) return {
12086
+ exit: false,
12087
+ output: ""
12088
+ };
12089
+ if (line.startsWith("rm -f ")) {
12090
+ for (const path of quoted) deleteIfPresent(repo, path);
12091
+ return {
12092
+ exit: false,
12093
+ output: ""
12094
+ };
12095
+ }
12096
+ if (line.startsWith("rm -rf -- ")) {
12097
+ removeAll(repo, quoted);
12098
+ return {
12099
+ exit: false,
12100
+ output: ""
12101
+ };
12102
+ }
12103
+ const printf = /^printf '%s\\n' '(?:[^']|'\\'')*' (>&2|> '(?:[^']|'\\'')*')$/.exec(line);
12104
+ if (printf !== null) {
12105
+ const [, text, file] = quoted;
12106
+ if (printf[1] === ">&2") return {
12107
+ exit: false,
12108
+ output: `${text}\n`
12109
+ };
12110
+ repo.writeFile(file, `${text}\n`);
12111
+ return {
12112
+ exit: false,
12113
+ output: ""
12114
+ };
12115
+ }
12116
+ throw new Error(`testkit: cannot run the preamble line \`${line}\` in memory`);
12117
+ };
12118
+ /** Rejoins lines a single-quoted word spans, so a preamble entry carrying a newline stays one line. */
12119
+ const shellLines = (lines) => {
12120
+ const joined = [];
12121
+ let open = false;
12122
+ for (const line of lines) {
12123
+ if (open) joined[joined.length - 1] += `\n${line}`;
12124
+ else joined.push(line);
12125
+ for (let i = 0; i < line.length; i++) if (line[i] === "'" && (open || line[i - 1] !== "\\")) open = !open;
12126
+ }
12127
+ return joined;
12128
+ };
12129
+ const outsideCommand = (lines, mkdir) => {
12130
+ const before = shellLines(lines.slice(2, mkdir));
12131
+ const swept = before.at(-1)?.startsWith("rm -rf -- ") === true;
12132
+ const green = lines.find((l) => l.startsWith(" rm -rf -- "));
12133
+ return {
12134
+ sweep: swept ? words$1(before.at(-1)) : [],
12135
+ sweepOnGreen: green === void 0 ? [] : words$1(green),
12136
+ preamble: swept ? before.slice(0, -1) : before
12137
+ };
12138
+ };
12139
+ const parseCheck = (script) => {
12140
+ const lines = script.split("\n");
12141
+ const mkdir = lines.findIndex((l) => l.startsWith("mkdir -p \"$(dirname "));
12142
+ const close = lines.findIndex((l, i) => i > mkdir && /^\) > '.*' 2>&1$/.test(l));
12143
+ if (mkdir === -1 || lines[mkdir + 1] !== "(" || close === -1) return void 0;
12144
+ const [report] = words$1(lines[mkdir]);
12145
+ const [, stamp] = words$1(lines.find((l) => l.includes("<!-- gtd check %s -->")) ?? "");
12146
+ if (report === void 0 || stamp === void 0) return void 0;
12147
+ return {
12148
+ command: lines.slice(mkdir + 2, close).join("\n"),
12149
+ report,
12150
+ stamp,
12151
+ ...outsideCommand(lines, mkdir)
12152
+ };
12153
+ };
12154
+ const runCommand = (repo, commands, command) => {
12155
+ const scripted = commands.get(command);
12156
+ if (scripted === void 0) throw new Error(`testkit: cannot run \`${command}\` in memory — script it ("the shell command … exits …")`);
12157
+ if (scripted.kind === "exit") return scripted;
12158
+ if (scripted.kind === "missing") return {
12159
+ status: 127,
12160
+ output: `sh: ${command}: not found\n`
12161
+ };
12162
+ repo.writeFile(scripted.file, rewritten(repo, scripted));
12163
+ return {
12164
+ status: 0,
12165
+ output: ""
12166
+ };
12167
+ };
12168
+ /** What a scripted rewrite leaves in its file. */
12169
+ const rewritten = (repo, command) => command.append === true ? `${repo.readFile(command.file) ?? ""}${command.content}` : command.content;
12170
+ const recognizeCheck = (repo, commands, script) => {
12171
+ const parsed = parseCheck(script);
12172
+ if (parsed === void 0) return void 0;
12173
+ const { command, report, stamp, sweep, sweepOnGreen, preamble } = parsed;
12174
+ if (checkScript(command, {
12175
+ report,
12176
+ stamp,
12177
+ sweep,
12178
+ sweepOnGreen,
12179
+ preamble
12180
+ }) !== script) return;
12181
+ let printed = "";
12182
+ for (const line of preamble) {
12183
+ const outcome = runPreambleLine(repo, line);
12184
+ printed += outcome.output;
12185
+ if (outcome.exit) return ok(printed);
12186
+ }
12187
+ removeAll(repo, sweep);
12188
+ const { status, output } = runCommand(repo, commands, command);
12189
+ if (status === 0) {
12190
+ deleteIfPresent(repo, report);
12191
+ removeAll(repo, sweepOnGreen);
12192
+ return ok(printed);
12193
+ }
12194
+ const body = output.length > 0 ? output : `the test command failed with exit code ${status} and produced no output.`;
12195
+ repo.writeFile(report, `${body}\n<!-- gtd check ${stamp} -->\n`);
12196
+ return ok(printed);
12197
+ };
12198
+ /**
12199
+ * Applies a `src/flows/scripts.ts` script to `repo`, or `undefined` when
12200
+ * `script` is none of them. `commands` stands in for the real programs a
12201
+ * check runs, keyed by the exact command string.
12202
+ */
12203
+ const applyFlowScript = (repo, commands, script) => {
12204
+ if (!script.startsWith(`${SHEBANG}\n`)) return void 0;
12205
+ return recognizeRemove(repo, script) ?? recognizeMove(repo, script) ?? recognizeShow(repo, script) ?? recognizeRevert(repo, script) ?? recognizeRestore(repo, script) ?? recognizeCheck(repo, commands, script);
12206
+ };
12207
+ //#endregion
11875
12208
  //#region src/testkit/EmittedScriptRecognizer.ts
11876
12209
  /**
11877
12210
  * Reverses `GitScript.ts`'s `shellQuote`: extracts every `'...'` token from
@@ -11908,6 +12241,8 @@ const extractQuotedTokens = (text) => {
11908
12241
  }
11909
12242
  return tokens;
11910
12243
  };
12244
+ /** What the in-memory tier prints for `$PATH` — there is none to name. */
12245
+ const IN_MEMORY_PATH = "(in-memory: no PATH)";
11911
12246
  const SET_FLAGS_RE = /^set\s+-\S+(\s+\S+)*$/;
11912
12247
  const recognizeSetFlags = (block) => SET_FLAGS_RE.test(block) ? { kind: "noop" } : void 0;
11913
12248
  const PRECONDITION_RE = /^\[ "\$\(git rev-parse HEAD\)" = '([0-9a-f]{40})' \] \|\| \{ echo "precondition failed: HEAD moved" >&2; exit 1; \}$/;
@@ -11915,11 +12250,9 @@ const recognizePrecondition = (repo, block) => {
11915
12250
  const match = PRECONDITION_RE.exec(block);
11916
12251
  if (!match) return void 0;
11917
12252
  const expected = match[1];
11918
- const actual = repo.resolveRef("HEAD");
11919
- if (actual === expected) return { kind: "noop" };
11920
- return {
12253
+ return repo.resolveRef("HEAD") === expected ? { kind: "noop" } : {
11921
12254
  kind: "failed",
11922
- error: `precondition failed: HEAD is ${actual ?? "(no commits)"}, expected ${expected}`
12255
+ error: "precondition failed: HEAD moved"
11923
12256
  };
11924
12257
  };
11925
12258
  /**
@@ -12006,9 +12339,9 @@ const recognizeFileExistsGuard = (repo, block) => {
12006
12339
  if (file === void 0 || fileExistsGuard(file) !== block) return void 0;
12007
12340
  return repo.readFile(file) !== void 0 ? { kind: "noop" } : { kind: "stopped" };
12008
12341
  };
12009
- /** `binaryGuard`. The in-memory tier has no real `$PATH` to probe — a scripted command IS the stand-in for "this binary exists" — so this always no-ops; the guard actually tripping is `@live`-only coverage. */
12342
+ /** `binaryGuard`. The in-memory tier has no `$PATH`: a binary is present unless declared `missing`. */
12010
12343
  const BINARY_GUARD_MESSAGE_RE = /^gtd: mode "([^"]+)": "(format|validate)" command not found: (.+)$/;
12011
- const recognizeBinaryGuard = (block) => {
12344
+ const recognizeBinaryGuard = (commands, block) => {
12012
12345
  const [binary, , message] = extractQuotedTokens(block);
12013
12346
  if (binary === void 0 || message === void 0) return void 0;
12014
12347
  const match = BINARY_GUARD_MESSAGE_RE.exec(message);
@@ -12016,10 +12349,29 @@ const recognizeBinaryGuard = (block) => {
12016
12349
  const [, mode, key, messageBinary] = match;
12017
12350
  if (messageBinary !== binary) return void 0;
12018
12351
  if (binaryGuard(binary, mode, key) !== block) return void 0;
12019
- return { kind: "noop" };
12352
+ if (commands.get(binary)?.kind !== "missing") return { kind: "noop" };
12353
+ return {
12354
+ kind: "failed",
12355
+ error: `${message}\n$PATH: ${IN_MEMORY_PATH}`,
12356
+ status: 127
12357
+ };
12020
12358
  };
12021
12359
  /** Recognized by `OUTCOME_MARKER` rather than re-derived and string-compared: an outcome only prints, so no git effect a loose match could miss — unlike `recognizeGitBuilders`, where a near-miss would silently skip a real mutation. */
12022
- const recognizeOutcome = (block) => block.startsWith("# gtd: outcome (print-only)") ? { kind: "noop" } : void 0;
12360
+ const recognizeOutcome = (repo, block) => {
12361
+ if (!block.startsWith(`# gtd: outcome (print-only)\nprintf `)) return void 0;
12362
+ const line = block.slice(28);
12363
+ const [format, ...args] = shellWords(repo, line.slice(7));
12364
+ return {
12365
+ kind: "noop",
12366
+ output: renderFormat(format.replace(/\\n/g, "\n"), ...args)
12367
+ };
12368
+ };
12369
+ /** The word list of `printf`'s arguments: quoted literals, and the `"$(git …)"` lookups outcome lines carry. */
12370
+ const shellWords = (repo, text) => [...text.matchAll(/'((?:[^']|'\\'')*)'|"\$\(git (rev-parse --short|log -1 --format=%s) '([^']*)'\)"/g)].map((m) => {
12371
+ if (m[1] !== void 0) return m[1].replaceAll("'\\''", "'");
12372
+ const hash = repo.resolveRef(m[3]) ?? "";
12373
+ return m[2] === "rev-parse --short" ? hash.slice(0, 7) : repo.lastCommitSubject(hash) ?? "";
12374
+ });
12023
12375
  /**
12024
12376
  * `failurePromptWrapper`. The constants below bracket the template's one
12025
12377
  * variable part; the extracted pair is confirmed by RE-RUNNING the real
@@ -12036,8 +12388,27 @@ const expandFileVar = (text) => {
12036
12388
  if (file === void 0) return void 0;
12037
12389
  return text.slice(match[0].length).replace(/"\$GTD_FILE"|"\$\{GTD_FILE\}"/g, shellQuote(file)).replace(/\$\{GTD_FILE\}|\$GTD_FILE\b/g, file);
12038
12390
  };
12039
- /** A mode's `format:`/`validate:` command: `gtd check`, or a scripted command. */
12040
- const recognizeModeCommand = (repo, commands, command) => recognizeGtdCheck(repo, command) ?? recognizeScriptedCommand(repo, commands, command);
12391
+ /**
12392
+ * A mode's `format:`/`validate:` command, under `withFileVar` or not:
12393
+ * `gtd check`, or a scripted command — keyed by the command as expanded, or
12394
+ * as written in the config (`fmt $GTD_FILE`), which is the only key a command
12395
+ * run on a scratch path can have.
12396
+ */
12397
+ const recognizeModeCommand = (repo, commands, block) => {
12398
+ const command = expandFileVar(block) ?? block;
12399
+ const exact = recognizeGtdCheck(repo, command) ?? recognizeScriptedCommand(repo, commands, command);
12400
+ if (exact !== void 0) return exact;
12401
+ const match = FILE_VAR_PREFIX_RE.exec(block);
12402
+ if (match === null) return void 0;
12403
+ const scripted = commands.get(block.slice(match[0].length));
12404
+ const [file] = extractQuotedTokens(match[1]);
12405
+ if (scripted === void 0 || file === void 0) return void 0;
12406
+ const bound = scripted.kind === "rewrite" && scripted.file === "$GTD_FILE" ? {
12407
+ ...scripted,
12408
+ file
12409
+ } : scripted;
12410
+ return recognizeScriptedCommand(repo, /* @__PURE__ */ new Map([[command, bound]]), command);
12411
+ };
12041
12412
  const FAILURE_PROMPT_HEADER = "gtd_validate_status=0\ngtd_validate_out=\"$( {\n";
12042
12413
  const FAILURE_PROMPT_MIDDLE = "\n} 2>&1 )\" || gtd_validate_status=$?\nif [ \"$gtd_validate_status\" -ne 0 ]; then\n printf '%s\\n\\n%s\\n' ";
12043
12414
  const FAILURE_PROMPT_FOOTER = " \"$gtd_validate_out\"\n exit \"$gtd_validate_status\"\nfi";
@@ -12049,11 +12420,12 @@ const recognizeFailurePromptWrapper = (repo, commands, block) => {
12049
12420
  const promptQuoted = block.slice(middleIndex + 101, block.length - 53);
12050
12421
  const [prompt] = extractQuotedTokens(promptQuoted);
12051
12422
  if (prompt === void 0 || failurePromptWrapper(inner, prompt) !== block) return void 0;
12052
- const innerOutcome = recognizeModeCommand(repo, commands, expandFileVar(inner) ?? inner);
12423
+ const innerOutcome = recognizeModeCommand(repo, commands, inner);
12053
12424
  if (innerOutcome === void 0) return void 0;
12054
12425
  if (innerOutcome.kind === "failed") return {
12055
12426
  kind: "failed",
12056
- error: `${prompt}\n\n${innerOutcome.error}`
12427
+ error: `${prompt}\n\n${innerOutcome.error.replace(/\n+$/, "")}`,
12428
+ status: innerOutcome.status ?? 1
12057
12429
  };
12058
12430
  return innerOutcome;
12059
12431
  };
@@ -12066,7 +12438,10 @@ const recognizeModeContradictionSkipNotice = (block) => {
12066
12438
  if (!match) return void 0;
12067
12439
  const mode = match[1];
12068
12440
  if (modeContradictionSkipNotice(mode) !== block) return void 0;
12069
- return { kind: "noop" };
12441
+ return {
12442
+ kind: "noop",
12443
+ output: `${message}\n`
12444
+ };
12070
12445
  };
12071
12446
  /** The printf line's own pieces — the sample bytes, the scratch path, and the literal PREFIX text (up to and including that path) — or `undefined` when `block` doesn't open with `printf '%s' ...`. */
12072
12447
  const parsePrintfPrefix = (block) => {
@@ -12129,7 +12504,7 @@ const simulateModeContradictionCheck = (repo, commands, parsed) => {
12129
12504
  const { mode, samplePath, sample, formatCommand, format } = parsed;
12130
12505
  repo.writeFile(samplePath, sample);
12131
12506
  const command = expandFileVar(formatCommand) ?? formatCommand;
12132
- const formatOutcome = recognizeScriptedCommand(repo, commands, command);
12507
+ const formatOutcome = recognizeModeCommand(repo, commands, formatCommand);
12133
12508
  if (formatOutcome === void 0) {
12134
12509
  repo.deleteFile(samplePath);
12135
12510
  return {
@@ -12171,16 +12546,16 @@ const recognizeGtdCheck = (repo, block) => {
12171
12546
  const format = steeringFormatFor(mode);
12172
12547
  if (format === void 0 || file === void 0) return {
12173
12548
  kind: "failed",
12174
- error: `gtd check: no built-in steering format named "${mode}"`
12549
+ error: `gtd check: unknown mode "${mode}" — known modes: ${BUILT_IN_MODE_NAMES.join(", ")}`
12175
12550
  };
12176
12551
  const content = repo.readFile(file);
12177
12552
  if (content === void 0) return { kind: "noop" };
12178
- const findings = format.validate(content);
12179
- if (findings.length > 0) return {
12553
+ const findings = format.validate(content).map((finding) => formatFinding(file, finding));
12554
+ if (findings.length === 0) return { kind: "noop" };
12555
+ return {
12180
12556
  kind: "failed",
12181
- error: `gtd check ${mode} ${file}: ${findings.map((f) => f.line !== void 0 ? `${file}:${f.line + 1}: ${f.message}` : f.message).join("; ")}`
12557
+ error: `gtd check: ${file} is not valid under mode "${mode}" (${findings.length} finding(s)):\n` + findings.join("\n")
12182
12558
  };
12183
- return { kind: "noop" };
12184
12559
  };
12185
12560
  const GTD_UNCHECK_RE = /^gtd uncheck (.+)$/;
12186
12561
  /** `gtd uncheck <file>` — the review-gate reset prepended ahead of the human's commit, so no tick ever reaches it. Re-runs the REAL `clearTicks`, writing back only on an actual change. */
@@ -12204,27 +12579,44 @@ const recognizeGtdUncheck = (repo, block) => {
12204
12579
  */
12205
12580
  const recognizeDidNotRunComment = (block) => block === "# gtd emitted this and did NOT run it — pipe it into `sh` to land the turn" ? { kind: "noop" } : void 0;
12206
12581
  /** `combinedScript`'s optional half. ALWAYS reports `noop` whatever the inner result, mirroring bash's `( … ) || <warning>`, where the subshell's exit status is swallowed and must never fail the outer script. */
12582
+ const PRESENTATION_FAILURE_TEXT = `${extractQuotedTokens(PRESENTATION_FAILURE_WARNING)[0].replace(/\\n$/, "")}\n`;
12207
12583
  const PRESENTATION_SUBSHELL_PREFIX = `${PRESENTATION_ONLY_COMMENT}\n(\n`;
12208
12584
  const PRESENTATION_SUBSHELL_SUFFIX = `\n) || ${PRESENTATION_FAILURE_WARNING}`;
12209
12585
  const recognizePresentationSubshell = (repo, commands, block) => {
12210
12586
  if (!block.startsWith(PRESENTATION_SUBSHELL_PREFIX) || !block.endsWith(PRESENTATION_SUBSHELL_SUFFIX)) return;
12211
12587
  const inner = block.slice(PRESENTATION_SUBSHELL_PREFIX.length, block.length - PRESENTATION_SUBSHELL_SUFFIX.length);
12212
- applyEmittedScript(repo, commands, inner);
12213
- return { kind: "noop" };
12588
+ const result = applyEmittedScript(repo, commands, inner);
12589
+ if (result.ok) return {
12590
+ kind: "noop",
12591
+ output: result.output
12592
+ };
12593
+ return {
12594
+ kind: "noop",
12595
+ output: `${result.output}${PRESENTATION_FAILURE_TEXT}`
12596
+ };
12214
12597
  };
12215
12598
  /** Anything left over must be an EXACT hit in the scripted-command table — mirrors `ScriptedCommand`'s two `kind`s (`Layers.ts`). */
12216
12599
  const recognizeScriptedCommand = (repo, commands, block) => {
12217
12600
  const scripted = commands.get(block);
12218
12601
  if (scripted === void 0) return void 0;
12219
12602
  if (scripted.kind === "rewrite") {
12220
- repo.writeFile(scripted.file, scripted.content);
12603
+ repo.writeFile(scripted.file, rewritten(repo, scripted));
12221
12604
  return { kind: "applied" };
12222
12605
  }
12606
+ if (scripted.kind === "missing") return {
12607
+ kind: "failed",
12608
+ error: `sh: ${block.split(" ")[0]}: not found`,
12609
+ status: 127
12610
+ };
12223
12611
  if (scripted.status !== 0) return {
12224
12612
  kind: "failed",
12225
- error: `scripted command "${block}" exited ${scripted.status}: ${scripted.output}`
12613
+ error: scripted.output,
12614
+ status: scripted.status
12615
+ };
12616
+ return {
12617
+ kind: "noop",
12618
+ output: scripted.output
12226
12619
  };
12227
- return { kind: "noop" };
12228
12620
  };
12229
12621
  /**
12230
12622
  * Splits on blank lines, IGNORING those inside a single-quoted string: every
@@ -12280,14 +12672,27 @@ const splitBlocks = (script) => {
12280
12672
  if (current.trim().length > 0) blocks.push(current.trim());
12281
12673
  return blocks;
12282
12674
  };
12675
+ /** `deferNotesScript`: append its one section to `.gtd/TODO.md`, reading the section's lines off the final `printf`. */
12676
+ const DEFER_HEAD = "mkdir -p .gtd\nif [ -s .gtd/TODO.md ]; then";
12677
+ const recognizeDeferNotes = (repo, block) => {
12678
+ if (!block.startsWith(DEFER_HEAD)) return void 0;
12679
+ const at = block.lastIndexOf("\nprintf '%s\\n' ");
12680
+ if (at === -1) return void 0;
12681
+ const lines = extractQuotedTokens(block.slice(at + 15));
12682
+ const old = repo.readFile(".gtd/TODO.md") ?? "";
12683
+ const sep = old === "" ? "" : `${old.endsWith("\n") ? "" : "\n"}\n`;
12684
+ repo.writeFile(".gtd/TODO.md", `${old}${sep}${lines.join("\n")}\n`);
12685
+ return { kind: "applied" };
12686
+ };
12283
12687
  /** Every recognizer `applyEmittedScript` tries, first match wins — `recognizeScriptedCommand` stays last since it's the exact-hit fallback for anything the rest don't claim. */
12284
12688
  const recognizersFor = (repo, commands) => [
12285
12689
  (block) => recognizeSetFlags(block),
12286
12690
  (block) => recognizeDidNotRunComment(block),
12287
12691
  (block) => recognizePrecondition(repo, block),
12288
12692
  (block) => recognizeFileExistsGuard(repo, block),
12289
- (block) => recognizeBinaryGuard(block),
12693
+ (block) => recognizeBinaryGuard(commands, block),
12290
12694
  (block) => recognizeGitBuilders(repo, block),
12695
+ (block) => recognizeDeferNotes(repo, block),
12291
12696
  (block) => recognizeFailurePromptWrapper(repo, commands, block),
12292
12697
  (block) => recognizePresentationSubshell(repo, commands, block),
12293
12698
  (block) => recognizeModeContradictionSkipNotice(block),
@@ -12295,10 +12700,9 @@ const recognizersFor = (repo, commands) => [
12295
12700
  (block) => recognizeGtdCheck(repo, block),
12296
12701
  (block) => recognizeGtdUncheck(repo, block),
12297
12702
  (block) => {
12298
- const command = expandFileVar(block);
12299
- return command === void 0 ? void 0 : recognizeModeCommand(repo, commands, command);
12703
+ return FILE_VAR_PREFIX_RE.test(block) ? recognizeModeCommand(repo, commands, block) : void 0;
12300
12704
  },
12301
- (block) => recognizeOutcome(block),
12705
+ (block) => recognizeOutcome(repo, block),
12302
12706
  (block) => recognizeScriptedCommand(repo, commands, block)
12303
12707
  ];
12304
12708
  const recognizeBlock = (recognizers, block) => {
@@ -12314,21 +12718,50 @@ const recognizeBlock = (recognizers, block) => {
12314
12718
  * real script. No block after the failing one is applied.
12315
12719
  */
12316
12720
  const applyEmittedScript = (repo, commands, script) => {
12721
+ const flow = applyFlowScript(repo, commands, script);
12722
+ if (flow !== void 0) return flow.ok ? {
12723
+ ok: true,
12724
+ status: 0,
12725
+ output: flow.output
12726
+ } : {
12727
+ ok: false,
12728
+ status: 1,
12729
+ output: flow.output,
12730
+ error: flow.error ?? ""
12731
+ };
12732
+ return script.startsWith("#!") ? unrecognized(script) : applyBlocks(repo, commands, script);
12733
+ };
12734
+ const applyBlocks = (repo, commands, script) => {
12317
12735
  const recognizers = recognizersFor(repo, commands);
12736
+ let output = "";
12318
12737
  for (const block of splitBlocks(script)) {
12319
12738
  const outcome = recognizeBlock(recognizers, block);
12320
- if (outcome === void 0) return {
12321
- ok: false,
12322
- error: `unrecognized script block: ${block.split("\n")[0]}`
12323
- };
12324
- if (outcome.kind === "stopped") return { ok: true };
12325
- if (outcome.kind === "failed") return {
12326
- ok: false,
12327
- error: outcome.error
12328
- };
12739
+ if (outcome === void 0) return unrecognized(block, output);
12740
+ if (outcome.kind === "noop") output += outcome.output ?? "";
12741
+ if (outcome.kind === "stopped") break;
12742
+ if (outcome.kind === "failed") return failedAt(outcome, output);
12329
12743
  }
12330
- return { ok: true };
12744
+ return {
12745
+ ok: true,
12746
+ status: 0,
12747
+ output
12748
+ };
12331
12749
  };
12750
+ const failedAt = (outcome, output) => {
12751
+ const printed = outcome.error.length === 0 || outcome.error.endsWith("\n") ? "" : "\n";
12752
+ return {
12753
+ ok: false,
12754
+ status: outcome.status ?? 1,
12755
+ output: `${output}${outcome.error}${printed}`,
12756
+ error: outcome.error
12757
+ };
12758
+ };
12759
+ const unrecognized = (block, output = "") => ({
12760
+ ok: false,
12761
+ status: 1,
12762
+ output,
12763
+ error: `unrecognized script block: ${block.split("\n")[0]}`
12764
+ });
12332
12765
  //#endregion
12333
12766
  //#region src/testkit/TestRepo.ts
12334
12767
  const REGISTRY = Symbol.for("@pmelab/gtd/testkit:modules");
@@ -12463,7 +12896,9 @@ const testRepo = (options) => {
12463
12896
  commit: (message) => repo.commitAllWithPrefix(message),
12464
12897
  show: (ref, path) => repo.fileAtRef(ref, path) ?? void 0,
12465
12898
  runScript: (script) => {
12466
- for (const line of script.split("\n")) runLine(repo, line.trim());
12899
+ const flow = applyFlowScript(repo, /* @__PURE__ */ new Map(), script);
12900
+ if (flow === void 0) for (const line of script.split("\n")) runLine(repo, line.trim());
12901
+ else if (!flow.ok) throw new Error(`testkit: ${flow.error ?? ""}`);
12467
12902
  },
12468
12903
  gtd,
12469
12904
  start: (workflow, ...args) => begin([
@@ -12549,7 +12984,8 @@ const makeLiveTier = (initialCommit = true) => {
12549
12984
  onDiskContent: true,
12550
12985
  commitHooks: true,
12551
12986
  linkedWorktrees: true,
12552
- directoryChainConfig: true
12987
+ directoryChainConfig: true,
12988
+ remotes: true
12553
12989
  },
12554
12990
  provide,
12555
12991
  provideExit,
@@ -12588,7 +13024,37 @@ const makeLiveTier = (initialCommit = true) => {
12588
13024
  }
12589
13025
  },
12590
13026
  readWorktreeFile: (path) => readFileSync(join(root, path), "utf8"),
12591
- existsPath: (path) => existsSync(join(root, path))
13027
+ existsPath: (path) => existsSync(join(root, path)),
13028
+ snapshot: async () => {
13029
+ gitExec("status", "--porcelain");
13030
+ await new Promise((r) => setTimeout(r, 1100));
13031
+ gitExec("status", "--porcelain");
13032
+ const files = readdirSync(join(root, ".git"), {
13033
+ recursive: true,
13034
+ withFileTypes: true
13035
+ }).filter((entry) => entry.isFile()).map((entry) => join(entry.parentPath, entry.name)).filter((path) => !path.includes(`${sep}gtd-cache${sep}`)).sort().map((path) => `${path}:${statSync(path).size}:${statSync(path).mtimeMs}`);
13036
+ return JSON.stringify({
13037
+ files,
13038
+ status: gitExec("status", "--porcelain", "-uall")
13039
+ });
13040
+ },
13041
+ headSubject: () => gitExec("log", "-1", "--pretty=%s"),
13042
+ headTouchedPaths: () => gitExec("show", "--name-only", "--pretty=format:", "HEAD").split("\n").filter((line) => line.length > 0)
13043
+ },
13044
+ runScript: (script) => {
13045
+ const result = spawnSync("sh", ["-c", `exec 2>&1\n${script}`], {
13046
+ cwd: root,
13047
+ encoding: "utf8",
13048
+ env: {
13049
+ ...process.env,
13050
+ PATH: `${gtdShimDir()}:${process.env["PATH"] ?? ""}`
13051
+ }
13052
+ });
13053
+ return {
13054
+ ok: result.status === 0,
13055
+ status: result.status ?? 1,
13056
+ output: result.stdout
13057
+ };
12592
13058
  },
12593
13059
  dispose: () => rmSync(root, {
12594
13060
  recursive: true,
@@ -12596,6 +13062,15 @@ const makeLiveTier = (initialCommit = true) => {
12596
13062
  })
12597
13063
  };
12598
13064
  };
13065
+ /** A dir whose `gtd` runs this build's bundle, so a script's bare `gtd` is the code under test. */
13066
+ let shimDir;
13067
+ const gtdShimDir = () => {
13068
+ if (shimDir !== void 0) return shimDir;
13069
+ shimDir = mkdtempSync(join(tmpdir(), "gtd-tier-shim-"));
13070
+ const bundle = join(import.meta.dirname, "../../dist/gtd.bundle.mjs");
13071
+ writeFileSync(join(shimDir, "gtd"), `#!/bin/sh\nexec node "${bundle}" "$@"\n`, { mode: 493 });
13072
+ return shimDir;
13073
+ };
12599
13074
  const IN_MEM_ROOT = "/repo";
12600
13075
  const makeInMemTier = (initialCommit = true) => {
12601
13076
  const repo = new InMemRepo();
@@ -12613,7 +13088,8 @@ const makeInMemTier = (initialCommit = true) => {
12613
13088
  onDiskContent: false,
12614
13089
  commitHooks: false,
12615
13090
  linkedWorktrees: false,
12616
- directoryChainConfig: true
13091
+ directoryChainConfig: true,
13092
+ remotes: false
12617
13093
  },
12618
13094
  provide,
12619
13095
  provideExit,
@@ -12646,7 +13122,21 @@ const makeInMemTier = (initialCommit = true) => {
12646
13122
  if (content === void 0) throw new Error(`ENOENT: no such file or directory, open '${path}'`);
12647
13123
  return content;
12648
13124
  },
12649
- existsPath: (path) => repo.hasPath(path)
13125
+ existsPath: (path) => repo.hasPath(path),
13126
+ snapshot: async () => repo.snapshot(),
13127
+ headSubject: () => repo.lastCommitSubject() ?? "",
13128
+ headTouchedPaths: () => {
13129
+ return [.../* @__PURE__ */ new Set([...repo.pathsAtRef("HEAD~1"), ...repo.pathsAtRef("HEAD")])].filter((p) => repo.fileAtRef("HEAD~1", p) !== repo.fileAtRef("HEAD", p)).sort();
13130
+ }
13131
+ },
13132
+ runScript: (script, commands = /* @__PURE__ */ new Map()) => {
13133
+ const { ok, status, output, error } = applyEmittedScript(repo, commands, script);
13134
+ if (error?.startsWith("unrecognized script block:") === true) throw new Error(error);
13135
+ return {
13136
+ ok,
13137
+ status,
13138
+ output
13139
+ };
12650
13140
  },
12651
13141
  dispose: () => {}
12652
13142
  };
@@ -12693,6 +13183,32 @@ const runGitServiceContract = (makeTier) => {
12693
13183
  afterEach(() => {
12694
13184
  t.dispose();
12695
13185
  });
13186
+ it("no reader writes to the repository", async () => {
13187
+ t.seed.commit("feat: second", { "second.txt": "two" });
13188
+ t.seed.writeFile("dirty.txt", "dirty");
13189
+ const before = await t.observe.snapshot();
13190
+ await runGit(t, (g) => all$1([
13191
+ g.lastCommitSubject(),
13192
+ g.lastCommitMessage(),
13193
+ g.hasCommits(),
13194
+ g.resolveRef("HEAD"),
13195
+ g.readRefOption("refs/gtd/none"),
13196
+ g.isAncestor("HEAD~1", "HEAD"),
13197
+ g.mergeBase("HEAD~1", "HEAD"),
13198
+ g.defaultBranch(),
13199
+ g.topLevel(),
13200
+ g.gitDir(),
13201
+ g.gitCommonDir(),
13202
+ g.commitHistory(),
13203
+ g.subjectHistory(10, 0),
13204
+ g.readFileAtRef("HEAD", "second.txt"),
13205
+ g.changedPaths(),
13206
+ g.changedPaths("HEAD~1")
13207
+ ]));
13208
+ expect(await t.observe.snapshot()).toBe(before);
13209
+ await runGit(t, (g) => g.updateRef("refs/gtd/marker", t.observe.resolveRef("HEAD")));
13210
+ expect(await t.observe.snapshot()).not.toBe(before);
13211
+ });
12696
13212
  describe("resolveRef", () => {
12697
13213
  it("resolves HEAD to a 40-char hash", async () => {
12698
13214
  expect(await runGit(t, (g) => g.resolveRef("HEAD"))).toMatch(/^[0-9a-f]{40}$/);
@@ -13228,31 +13744,18 @@ const runGitServiceContract = (makeTier) => {
13228
13744
  });
13229
13745
  };
13230
13746
  /**
13231
- * Runs a `GitScript.ts` builder's output through a REAL shell against `t.root`
13232
- * — the same execution model the eventual driver uses (`bash -c <script>`),
13233
- * so a builder that's syntactically fine but semantically wrong (a missing
13234
- * `&&`, a misquoted path) fails here exactly as it would in production.
13747
+ * The SAME writer postconditions `runGitServiceContract` asserts against
13748
+ * `GitOperations`, asserted here against `GitScript.ts`'s builders as a driver
13749
+ * runs them: real `sh` on Live, the in-memory twin on InMemory.
13235
13750
  */
13236
- const execScript = (t, script) => {
13237
- execFileSync("bash", ["-c", script], {
13238
- cwd: t.root,
13239
- stdio: "pipe"
13240
- });
13751
+ const run = (t, script) => {
13752
+ const result = t.runScript(script);
13753
+ if (!result.ok) throw new Error(`script failed on ${t.name}: ${result.output}`);
13241
13754
  };
13242
- /** Names touched by HEAD's own commit — `git show`'s diff against its parent, not the whole tree. */
13243
- const headTouchedPaths = (t) => gitExecIn(t.root, "show", "--name-only", "--pretty=format:", "HEAD").split("\n").filter((line) => line.length > 0);
13244
- const headSubject = (t) => gitExecIn(t.root, "log", "-1", "--pretty=%s");
13245
- /**
13246
- * The SAME 9 writer postconditions `runGitServiceContract` asserts against
13247
- * `GitOperations`, asserted here against `GitScript.ts`'s bash builders
13248
- * instead — driven through real `bash` against a real repo (`makeLiveTier`),
13249
- * never the in-memory fake, since a fake root (`/repo`) has no shell to run
13250
- * against.
13251
- */
13252
- const runGitScriptContract = () => {
13755
+ const runGitScriptContract = (makeTier) => {
13253
13756
  let t;
13254
13757
  beforeEach(() => {
13255
- t = makeLiveTier();
13758
+ t = makeTier();
13256
13759
  });
13257
13760
  afterEach(() => {
13258
13761
  t.dispose();
@@ -13261,17 +13764,17 @@ const runGitScriptContract = () => {
13261
13764
  it("stages (git add -A) before committing — a new untracked file lands in the commit", () => {
13262
13765
  t.seed.writeFile("new.ts", "export const x = 1");
13263
13766
  const headBefore = t.observe.resolveRef("HEAD");
13264
- execScript(t, commitAll("gtd: building"));
13767
+ run(t, commitAll("gtd: building"));
13265
13768
  expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
13266
- expect(headSubject(t)).toBe("gtd: building");
13267
- expect(headTouchedPaths(t)).toEqual(["new.ts"]);
13769
+ expect(t.observe.headSubject()).toBe("gtd: building");
13770
+ expect(t.observe.headTouchedPaths()).toEqual(["new.ts"]);
13268
13771
  expect(t.observe.statusPorcelain().trim()).toBe("");
13269
13772
  });
13270
13773
  it("commits even on a clean tree (--allow-empty), never 'nothing to commit'", () => {
13271
13774
  const headBefore = t.observe.resolveRef("HEAD");
13272
- execScript(t, commitAll("gtd: grilled"));
13775
+ run(t, commitAll("gtd: grilled"));
13273
13776
  expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
13274
- expect(headSubject(t)).toBe("gtd: grilled");
13777
+ expect(t.observe.headSubject()).toBe("gtd: grilled");
13275
13778
  });
13276
13779
  });
13277
13780
  describe("commitAsIs", () => {
@@ -13279,22 +13782,22 @@ const runGitScriptContract = () => {
13279
13782
  t.seed.writeFile("a.txt", "a");
13280
13783
  t.seed.stageAll();
13281
13784
  t.seed.writeFile("b.txt", "b");
13282
- execScript(t, commitAsIs("feat: squash"));
13283
- expect(headSubject(t)).toBe("feat: squash");
13284
- expect(headTouchedPaths(t)).toEqual(["a.txt"]);
13785
+ run(t, commitAsIs("feat: squash"));
13786
+ expect(t.observe.headSubject()).toBe("feat: squash");
13787
+ expect(t.observe.headTouchedPaths()).toEqual(["a.txt"]);
13285
13788
  });
13286
13789
  it("commits even on a clean index (--allow-empty), never 'nothing to commit'", () => {
13287
13790
  const headBefore = t.observe.resolveRef("HEAD");
13288
- execScript(t, commitAsIs("gtd: empty squash"));
13791
+ run(t, commitAsIs("gtd: empty squash"));
13289
13792
  expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
13290
- expect(headSubject(t)).toBe("gtd: empty squash");
13793
+ expect(t.observe.headSubject()).toBe("gtd: empty squash");
13291
13794
  });
13292
13795
  });
13293
13796
  describe("softResetTo", () => {
13294
13797
  it("moves HEAD back but keeps worktree changes from the second commit", () => {
13295
13798
  const firstHash = t.observe.resolveRef("HEAD");
13296
13799
  t.seed.commit("feat: second", { "second.txt": "second content" });
13297
- execScript(t, softResetTo(firstHash));
13800
+ run(t, softResetTo(firstHash));
13298
13801
  expect(t.observe.resolveRef("HEAD")).toBe(firstHash);
13299
13802
  expect(t.observe.statusPorcelain()).toContain("second.txt");
13300
13803
  });
@@ -13303,7 +13806,7 @@ const runGitScriptContract = () => {
13303
13806
  it("moves HEAD, index, and working tree content back to the target ref", () => {
13304
13807
  const firstHash = t.observe.resolveRef("HEAD");
13305
13808
  t.seed.commit("feat: second", { "readme.txt": "modified content" });
13306
- execScript(t, hardResetTo(firstHash));
13809
+ run(t, hardResetTo(firstHash));
13307
13810
  expect(t.observe.resolveRef("HEAD")).toBe(firstHash);
13308
13811
  expect(t.observe.statusPorcelain()).toBe("");
13309
13812
  expect(t.observe.readWorktreeFile("readme.txt")).toBe("hello");
@@ -13313,7 +13816,7 @@ const runGitScriptContract = () => {
13313
13816
  it("moves HEAD and index, leaving the working tree untouched", () => {
13314
13817
  const firstHash = t.observe.resolveRef("HEAD");
13315
13818
  t.seed.commit("feat: second", { "second.txt": "second content" });
13316
- execScript(t, mixedResetTo(firstHash));
13819
+ run(t, mixedResetTo(firstHash));
13317
13820
  expect(t.observe.resolveRef("HEAD")).toBe(firstHash);
13318
13821
  expect(t.observe.statusPorcelain()).toContain("second.txt");
13319
13822
  });
@@ -13322,36 +13825,37 @@ const runGitScriptContract = () => {
13322
13825
  it("drops both tracked modifications and untracked files", () => {
13323
13826
  t.seed.writeFile("readme.txt", "modified");
13324
13827
  t.seed.writeFile("untracked.txt", "new");
13325
- execScript(t, discardPending());
13828
+ run(t, discardPending());
13326
13829
  expect(t.observe.statusPorcelain()).toBe("");
13327
13830
  });
13328
13831
  });
13329
13832
  describe("updateRef / deleteRef", () => {
13330
13833
  it("updateRef points a ref at a commit", () => {
13331
13834
  const head = t.observe.resolveRef("HEAD");
13332
- execScript(t, updateRef("refs/gtd/marker", head));
13835
+ run(t, updateRef("refs/gtd/marker", head));
13333
13836
  expect(t.observe.refExists("refs/gtd/marker")).toBe(true);
13334
13837
  expect(t.observe.resolveRef("refs/gtd/marker")).toBe(head);
13335
13838
  });
13336
13839
  it("updateRef with HEAD points the ref at the commit just made", () => {
13337
- execScript(t, commitAll("feat: just made"));
13338
- execScript(t, updateRef("refs/gtd/history/x", "HEAD"));
13840
+ run(t, commitAll("feat: just made"));
13841
+ run(t, updateRef("refs/gtd/history/x", "HEAD"));
13339
13842
  expect(t.observe.resolveRef("refs/gtd/history/x")).toBe(t.observe.resolveRef("HEAD"));
13340
13843
  });
13341
13844
  it("deleteRef removes an existing ref, and tolerates deleting it again", () => {
13342
13845
  const head = t.observe.resolveRef("HEAD");
13343
- execScript(t, updateRef("refs/gtd/marker", head));
13344
- execScript(t, deleteRef("refs/gtd/marker"));
13345
- execScript(t, deleteRef("refs/gtd/marker"));
13846
+ run(t, updateRef("refs/gtd/marker", head));
13847
+ run(t, deleteRef("refs/gtd/marker"));
13848
+ run(t, deleteRef("refs/gtd/marker"));
13346
13849
  expect(t.observe.refExists("refs/gtd/marker")).toBe(false);
13347
13850
  });
13348
13851
  it("deleteRef tolerates a ref that was never created", () => {
13349
- expect(() => execScript(t, deleteRef("refs/gtd/never-created"))).not.toThrow();
13852
+ expect(() => run(t, deleteRef("refs/gtd/never-created"))).not.toThrow();
13350
13853
  expect(t.observe.refExists("refs/gtd/never-created")).toBe(false);
13351
13854
  });
13352
13855
  });
13353
13856
  describe("fetchHistoryRefs", () => {
13354
- it("pulls every refs/gtd/history/* from a bare remote and prints the count", () => {
13857
+ it("pulls every refs/gtd/history/* from a bare remote and prints the count", (ctx) => {
13858
+ if (!t.capabilities.remotes) ctx.skip();
13355
13859
  const bare = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-bare-")));
13356
13860
  try {
13357
13861
  gitExecIn(bare, "init", "-q", "--bare");
@@ -13375,7 +13879,8 @@ const runGitScriptContract = () => {
13375
13879
  });
13376
13880
  }
13377
13881
  });
13378
- it("fails loudly when a local history ref conflicts with the remote's", () => {
13882
+ it("fails loudly when a local history ref conflicts with the remote's", (ctx) => {
13883
+ if (!t.capabilities.remotes) ctx.skip();
13379
13884
  const bare = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-bare-")));
13380
13885
  try {
13381
13886
  gitExecIn(bare, "init", "-q", "--bare");