@pmelab/gtd 26.1.0 → 27.1.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 (39) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +16 -3
  3. package/dist/chunks/{Schema-B5yK7QrT.mjs → Schema-CyImoHTj.mjs} +56 -3
  4. package/dist/chunks/{reserved-6pAgFGgB.mjs → reserved-BL9krPao.mjs} +1 -1
  5. package/dist/chunks/{wire-DA7q2ikx.mjs → wire-BY1S-obW.mjs} +5 -2
  6. package/dist/flows.mjs +74 -1
  7. package/dist/gtd.bundle.mjs +159 -17
  8. package/dist/testkit.mjs +163 -17
  9. package/dist/wire.mjs +2 -2
  10. package/dist/workflows/feature.mjs +1 -1
  11. package/dist/workflows/fix.mjs +1 -1
  12. package/dist/workflows/review.mjs +1 -1
  13. package/dist/workflows.mjs +5 -0
  14. package/package.json +2 -2
  15. package/src/cli/Cli.test.ts +53 -1
  16. package/src/cli/Cli.ts +48 -6
  17. package/src/cli/Output.ts +3 -1
  18. package/src/cli/Program.test.ts +1 -0
  19. package/src/cli/Program.ts +28 -7
  20. package/src/cli/Strays.test.ts +88 -0
  21. package/src/cli/Strays.ts +56 -0
  22. package/src/flows/helpers.ts +111 -0
  23. package/src/flows/runtime.ts +5 -2
  24. package/src/platform/Git.test.ts +96 -1
  25. package/src/platform/Git.ts +24 -0
  26. package/src/replay/judgeDiffShape.test.ts +208 -0
  27. package/src/step/index.ts +1 -0
  28. package/src/step/strayPaths.test.ts +17 -0
  29. package/src/step/strayPaths.ts +8 -0
  30. package/src/testkit/FakeGitOperations.ts +2 -0
  31. package/src/testkit/GitTiers.ts +67 -1
  32. package/src/testkit/InMemRepo.ts +7 -0
  33. package/src/testkit/Layers.test.ts +34 -1
  34. package/src/testkit/Layers.ts +21 -1
  35. package/src/testkit/TestRepo.ts +1 -1
  36. package/src/wire/ExitCodes.test.ts +11 -3
  37. package/src/wire/ExitCodes.ts +3 -0
  38. package/src/wire/Glob.test.ts +35 -0
  39. package/src/wire/Glob.ts +1 -1
package/dist/testkit.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { checkScript, configProblems, configure, installContext, isFactoryWorkflow, moveScript, protocolMismatch, removeScript, restoreScript, revertScript, showScript, t as flows_exports } from "./flows.mjs";
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";
2
+ import { E as ACCESS_REFUSAL, _ as demandOf, a as landFields, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, i as judgeJson, k as renderFormat, l as renderLandPlain, m as statusOf, n as resolveSession, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches } from "./chunks/wire-BY1S-obW.mjs";
3
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
- 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";
4
+ import { $ as logWarning, $i as format, $n as map$5, $r as make$19, $t as provide$1, A as catchIf, Ai as get$5, An as scopeMake, Ar as flatMap$3, At as suspend$5, B as flatMap$4, Bi as isNone, Bn as get$3, Br as sync$1, Bt as unsafeMakeLatch, C as all$1, Ci as toArray, Cn as isInterrupted, Cr as exitFail, Ct as sandbox, D as async, Di as GenericTag, Dn as squash, Dr as exitZipRight, Dt as scopedWith$1, E as asVoid$1, Ei as unsafeHead, En as reduceWithContext, Er as exitVoid, Et as scoped$4, F as ensuring$2, Fi as splitAt, Fn as join$3, Fr as map$3, Ft as tryMap, G as forkIn, Gi as some, Gn as TypeId$10, Gr as whenEffect, Gt as zipLeft, H as fnUntraced, Hi as map$8, Hn as modify$1, Hr as uninterruptible, Ht as when, I as exit, Ii as filter$1, In as sequential, Ir as onInterrupt, It as tryPromise, J as ignore, Ji as match$2, Jn as all, Jr as DeferredTypeId$1, Jt as spaced$1, K as forkScoped, Ki as isLeft, Kn as Class, Kr as withFiberRuntime, Kt as zipRight$3, L as fail$5, Li as flatMap$5, Ln as bind, Lr as scopeClose, Lt as try_, M as die$2, Mi as omit, Mn as _await$2, Mr as interruptAsFiber, Mt as tap, N as dieMessage, Ni as empty$5, Nn as inheritAll$1, Nr as interruptFiber, Nt as tapErrorCause, O as catchAll$1, Oi as Tag, On as ensuring$3, Or as fiberId, Ot as serviceOption, P as either, Pi as fromIterable, Pn as interruptAllAs$1, Pr as interruptible$1, Pt as timeout, Q as isEffect, Qi as NodeInspectSymbol, Qn as isFailure, Qr as ids, Qt as mergeAll$2, R as failCause$4, Ri as fromNullable, Rn as bindTo, Rr as succeed$4, Rt as uninterruptible$1, S as addFinalizer$1, Si as takeRight, Sn as isDieType, Sr as exitAs, St as runtime, T as as, Ti as unsafeGet, Tn as map$6, Tr as exitSucceed, Tt as scopeWith, U as forEach$2, Ui as match$4, Un as set$1, Ur as uninterruptibleMask$1, Ut as withFiberRuntime$1, V as flatten$4, Vi as isSome, Vn as make$16, Vr as tap$1, Vt as unsafeMakeSemaphore$1, W as forkDaemon, Wi as none, Wn as update$1, Wr as void_$2, Wt as zip$1, X as interruptible, Xi as pipeArguments, Xn as fail$4, Xr as combine, Xt as tagged, Y as interrupt$4, Yi as right, Yn as die, Yr as empty$6, Yt as TaggedError$1, Z as intoDeferred, Zi as BaseProto, Zn as failCause$3, Zr as combineAll, Zt as fromEffect$5, _ as decodeSync, _i as of, _n as fail$3, _r as deferredIsDone, _t as raceWith, a as Literal, aa as constUndefined, ai as size$2, an as updateContext$1, ar as zipRight$2, at as matchCause, b as acquireRelease, bi as reverse, bn as interrupt$3, br as deferredUnsafeDone, bt as runPromise, c as String$, ca as identity, ci as append$1, cn as Scope, cr as asyncInterrupt, ct as negate, d as Union, di as empty$4, dn as close, dr as deferredAwait, dt as orElse, ea as toJSON, ei as none$1, en as provideMerge$1, er as mapBoth, et as makeSemaphore, f as between, fi as filter, fn as extend, fr as deferredComplete, ft as orElseSucceed, gi as map$7, gn as die$1, gr as deferredInterruptWith, gt as race, hi as isEmpty$2, hn as NoSuchElementException, hr as deferredInterrupt, ht as provideService, i as Defect, ia as constTrue, ii as has, in as unsafeRunPromiseExit, ir as zip$2, it as match$5, j as catchTag, ji as getOption, jn as scopeTag, jr as interrupt$2, jt as sync$2, k as catchAllCause, ki as add, kn as forEachConcurrentDiscard, kr as fiberRefLocally, kt as succeed$6, l as Struct, la as pipe, li as appendAll, ln as addFinalizer, lr as catchAll, lt as onError, m as optional$1, mi as isChunk, mn as make$18, mr as deferredFailCause, mt as provide$2, n as Boolean$, na as hasProperty, ni as make$17, nn as succeed$3, nr as succeed$5, nt as mapBoth$1, o as Never, oa as constVoid, oi as decode, on as forkIn$1, or as as$1, ot as matchCauseEffect, p as decodeUnknown, pi as fromIterable$1, pn as fork, pr as deferredFail, pt as promise, q as gen, qi as left, qn as spanTag, qr as zipRight$1, qt as zipWith, r as Cause, ra as constFalse, ri as set$2, rn as unsafeFork, rr as void_$3, rt as mapError, s as Number$, sa as dual, si as isFinite, sn as unsafeMakeSemaphore, sr as asVoid, st as matchEffect, t as Array$, ta as equals, ti as get$4, tn as scoped$3, tr as match$3, tt as map$4, u as TaggedError, ui as drop, un as addFinalizerExit, ur as currentContext$1, ut as orDie, v as encodeSync, vi as prepend, vn as failureOrCause, vr as deferredMake, vt as repeat, w as annotateLogs, wi as unsafeFromArray, wn as isInterruptedOnly, wr as exitFailCause, wt as scope, x as acquireUseRelease$2, xi as take$2, xn as interruptors, xr as deferredUnsafeMake, xt as runPromiseExit$1, y as _void, yi as reduce, yn as flipCauseOption, yr as deferredSucceed, yt as retry, z as fiberIdWith, zi as getOrElse, zn as sleep, zr as suspend$4, zt as uninterruptibleMask } from "./chunks/Schema-CyImoHTj.mjs";
5
5
  import { createRequire } from "node:module";
6
6
  import * as Crypto from "node:crypto";
7
7
  import { createHash } from "node:crypto";
@@ -5401,6 +5401,7 @@ const makeGitImpl = (executor, root) => {
5401
5401
  }))),
5402
5402
  hasCommits: () => exec("git", "rev-parse", "--verify", "HEAD").pipe(map$4(() => true), catchAll$1(() => succeed$6(false))),
5403
5403
  readFileAtRef: (ref, path) => exec("git", "show", `${ref}:${path}`),
5404
+ diffPaths: (base, tip) => exec("git", "diff", "--name-only", "-z", "--no-renames", "--no-relative", "--ignore-submodules=none", "--end-of-options", base, tip, "--").pipe(map$4(splitNul)),
5404
5405
  changedPaths: (base) => gen(function* () {
5405
5406
  const ref = base ?? "HEAD";
5406
5407
  const nameStatusOut = yield* exec("git", "diff", "--name-status", "-z", ref).pipe(catchAll$1(() => succeed$6("")));
@@ -8467,6 +8468,10 @@ const resolveBase = (commandLabel, value) => gen(function* () {
8467
8468
  return mergeBase.value;
8468
8469
  });
8469
8470
  //#endregion
8471
+ //#region src/step/strayPaths.ts
8472
+ /** Globs use the `access.write` dialect (`globMatches`). */
8473
+ const strayPaths = (touched, allow) => touched.filter((path) => !allow.some((glob) => globMatches(path, glob))).sort();
8474
+ //#endregion
8470
8475
  //#region src/edge/Edge.ts
8471
8476
  const EMPTY_TREE$1 = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
8472
8477
  const ACTORS = /* @__PURE__ */ new Set([
@@ -9763,6 +9768,24 @@ const writeSelection = (out, fields, path) => gen(function* () {
9763
9768
  else if (selection.kind === "unknown") return yield* fail$5(new SelectorUsageError(`gtd: unknown --json selector "${selection.path}" — see \`gtd --help\``));
9764
9769
  });
9765
9770
  //#endregion
9771
+ //#region src/cli/Strays.ts
9772
+ const resolveSide = (git, side) => git.resolveRef(`${side}^{commit}`).pipe(mapError(() => new GtdError(`gtd strays: unknown revision '${side}'`)));
9773
+ /** stdout carries the array alone; the summary is ungated stderr so a quiet run still explains its exit code, the per-path lines are narration. */
9774
+ const runStraysCommand = (command, json, out) => gen(function* () {
9775
+ const git = yield* GitService;
9776
+ const narrator = yield* Narrator;
9777
+ const base = yield* resolveSide(git, command.base);
9778
+ const tip = yield* resolveSide(git, command.tip);
9779
+ const touched = yield* git.diffPaths(base, tip);
9780
+ const strays = strayPaths(touched, command.globs);
9781
+ const total = touched.length;
9782
+ if (json.kind === "select") yield* writeSelection(out, strays, json.path).pipe(catchIf((error) => error instanceof SelectorUsageError && /^\d+$/.test(json.path), () => _void));
9783
+ else out.write(`${JSON.stringify(strays)}\n`);
9784
+ for (const path of strays) yield* narrator.narrate(`stray: ${JSON.stringify(path)}`);
9785
+ yield* narrator.warn(strays.length === 0 ? `gtd strays: no strays — ${total} touched files, all inside the allowlist` : `gtd strays: ${strays.length} of ${total} touched files outside the allowlist`);
9786
+ return strays.length === 0 ? 0 : 10;
9787
+ });
9788
+ //#endregion
9766
9789
  //#region src/cli/History.ts
9767
9790
  /** Newest tip first. */
9768
9791
  const historyEntries = gen(function* () {
@@ -10415,7 +10438,8 @@ const needsOf = (kind) => {
10415
10438
  case "lsp":
10416
10439
  case "install":
10417
10440
  case "loop":
10418
- case "judgeRun": return "none";
10441
+ case "judgeRun":
10442
+ case "strays": return "none";
10419
10443
  case "check":
10420
10444
  case "uncheck": return "fs";
10421
10445
  default: return "state";
@@ -10424,10 +10448,13 @@ const needsOf = (kind) => {
10424
10448
  /**
10425
10449
  * Dispatches to the named `run*Command` handler for every `Command.kind` —
10426
10450
  * the counterpart of `Cli.ts`'s `parseArgv`, which has already validated
10427
- * every field a handler receives. A command choosing its own exit code is
10428
- * unrepresentable: every branch returns `Effect<void>`, and `Cli.ts`'s
10429
- * `runCli` supplies `EXIT_OK` once, after this Effect succeeds.
10451
+ * every field a handler receives. Only `strays` chooses its exit code; every
10452
+ * other branch returns `Effect<void>` and resolves to `EXIT_OK`.
10430
10453
  */
10454
+ const dispatchCommand = (command, json, out) => {
10455
+ if (command.kind === "strays") return runStraysCommand(command, json, out);
10456
+ return as(dispatchVoidCommand(command, json, out), 0);
10457
+ };
10431
10458
  const dispatchVoidCommand = (command, json, out) => {
10432
10459
  switch (command.kind) {
10433
10460
  case "lsp": return runLspCommand();
@@ -10467,7 +10494,7 @@ const dispatchVoidCommand = (command, json, out) => {
10467
10494
  * so a refusal writes nothing and emits no script by construction.
10468
10495
  */
10469
10496
  const runCommand$1 = (command, json, out) => {
10470
- const dispatch = dispatchVoidCommand(command, json, out);
10497
+ const dispatch = dispatchCommand(command, json, out);
10471
10498
  if (needsOf(command.kind) !== "state") return dispatch;
10472
10499
  return gen(function* () {
10473
10500
  const git = yield* GitService;
@@ -10480,7 +10507,7 @@ const runCommand$1 = (command, json, out) => {
10480
10507
  warn: () => _void
10481
10508
  }));
10482
10509
  for (const warning of config.warnings) yield* narrator.warn(`gtd: warning: ${formatDiagnostic(warning)}`);
10483
- yield* dispatch;
10510
+ return yield* dispatch;
10484
10511
  });
10485
10512
  };
10486
10513
  //#endregion
@@ -10495,13 +10522,13 @@ const FLAGS = [
10495
10522
  name: "--json",
10496
10523
  arity: "optional",
10497
10524
  repeatable: false,
10498
- scope: (kind) => kind === "next" || kind === "land" || kind === "judge" || kind === "judgeAnswer" || kind === "doors" || kind === "historyList" || kind === "historyShow",
10525
+ scope: (kind) => kind === "next" || kind === "land" || kind === "judge" || kind === "judgeAnswer" || kind === "doors" || kind === "historyList" || kind === "historyShow" || kind === "strays",
10499
10526
  decode: ([raw]) => right(raw ?? ""),
10500
- scopeError: "gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors`/`gtd history list`/`gtd history show` — every other command prints plain text; see `gtd install` for the driver protocol briefing",
10527
+ scopeError: "gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors`/`gtd history list`/`gtd history show`/`gtd strays` — every other command prints plain text; see `gtd install` for the driver protocol briefing",
10501
10528
  valueHint: "<path>",
10502
10529
  help: [
10503
10530
  "(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors/gtd history",
10504
- "list/gtd history show only) output structured JSON. Bare --json prints the whole document;",
10531
+ "list/gtd history show/gtd strays only) output structured JSON. Bare --json prints the whole document;",
10505
10532
  "--json=<path> (a dotted key path into that document, e.g.",
10506
10533
  "kind, content, session.id) prints just that value: a",
10507
10534
  "scalar raw and unquoted, a boolean as true/false, a list",
@@ -11010,6 +11037,28 @@ const COMMAND_ROWS = [
11010
11037
  "every question, with nothing on stdout"
11011
11038
  ]
11012
11039
  },
11040
+ {
11041
+ token: "strays",
11042
+ kind: "strays",
11043
+ arity: {
11044
+ name: "range",
11045
+ variadic: "glob"
11046
+ },
11047
+ details: [
11048
+ "List the files <range> (<base>..<tip>, an empty side is HEAD)",
11049
+ "touched in its committed net diff that match none of the",
11050
+ "globs. Anchored at the repo root: * stays within one path",
11051
+ "segment, ** crosses them, everything else is literal — quote",
11052
+ "globs in the shell. stdout: a sorted JSON array, [] when",
11053
+ "clean; stderr: a summary, plus one stray: line per path with",
11054
+ "--verbose. --json=<n> picks one entry, nothing past the end;",
11055
+ "a symlink is a path like any other, never followed; a",
11056
+ "submodule counts as its path, whatever diff.ignoreSubmodules",
11057
+ "or .gitmodules ignore say. Runs in any git repository, no",
11058
+ "process needed. Exits 10 when",
11059
+ "strays were found, 1 on an unknown revision, 2 on a bad range"
11060
+ ]
11061
+ },
11013
11062
  {
11014
11063
  token: "history list",
11015
11064
  kind: "historyList",
@@ -11352,6 +11401,22 @@ const parseArgv = (argv) => {
11352
11401
  json,
11353
11402
  verbose
11354
11403
  };
11404
+ if (kind === "strays") {
11405
+ const [range, ...globs] = restPositionals;
11406
+ const at = range.indexOf("..");
11407
+ if (at < 0 || range.includes("...") || range.indexOf("..", at + 1) >= 0) return usagePlan(`gtd strays: <range> must be <base>..<tip> (an empty side is HEAD) — got '${range}'`, jsonSeen);
11408
+ return {
11409
+ kind: "command",
11410
+ command: {
11411
+ kind: "strays",
11412
+ base: range.slice(0, at) || "HEAD",
11413
+ tip: range.slice(at + 2) || "HEAD",
11414
+ globs
11415
+ },
11416
+ json,
11417
+ verbose
11418
+ };
11419
+ }
11355
11420
  if (kind === "uncheck") return {
11356
11421
  kind: "command",
11357
11422
  command: {
@@ -11430,9 +11495,9 @@ const runCli = (argv, io) => {
11430
11495
  return flatMap$4(Host, ({ env }) => try_({
11431
11496
  try: () => resolveFlags(env, io.flags),
11432
11497
  catch: (e) => new GtdError(e.message)
11433
- })).pipe(flatMap$4(() => runCommand$1(plan.command, plan.json, out)), map$4(() => {
11498
+ })).pipe(flatMap$4(() => runCommand$1(plan.command, plan.json, out)), map$4((code) => {
11434
11499
  out.flush();
11435
- io.exit(0);
11500
+ io.exit(code);
11436
11501
  }), provide$2(io.layers(plan.verbose)), sandbox, catchAll$1((cause) => isInterruptedOnly(cause) ? failCause$4(cause) : report(io, plan.json.kind !== "off")(cause)));
11437
11502
  };
11438
11503
  //#endregion
@@ -11629,6 +11694,10 @@ var InMemRepo = class InMemRepo {
11629
11694
  changedPathsWorktree(base) {
11630
11695
  return diffTrees(this.treeAt(base), new Map(this.worktree));
11631
11696
  }
11697
+ diffPaths(base, tip) {
11698
+ for (const ref of [base, tip]) if (this.resolveRef(ref) === null) throw new Error(`Invalid ref: ${ref}`);
11699
+ return diffTrees(this.treeAt(base), this.treeAt(tip)).map((e) => e.path);
11700
+ }
11632
11701
  /** The worktree content of `path`, or `undefined` when absent. A LIVE read — never a snapshot. */
11633
11702
  readFile(path) {
11634
11703
  return this.worktree.get(path);
@@ -11927,7 +11996,8 @@ const makeGitReaderOps = (repo, root) => ({
11927
11996
  gitCommonDir: () => succeed$6(`${root}/.git`),
11928
11997
  commitHistory: (base, head) => succeed$6(repo.commitHistory(base, head)),
11929
11998
  subjectHistory: (pageSize, skip, head) => succeed$6(repo.subjectHistory(pageSize, skip, head)),
11930
- changedPaths: (base) => succeed$6(repo.changedPathsWorktree(base))
11999
+ changedPaths: (base) => succeed$6(repo.changedPathsWorktree(base)),
12000
+ diffPaths: (base, tip) => tryCatch(() => repo.diffPaths(base, tip))
11931
12001
  });
11932
12002
  const failOn = (output) => {
11933
12003
  if (output !== void 0) throw new Error(output);
@@ -12064,6 +12134,13 @@ const missingToolCommands = {
12064
12134
  };
12065
12135
  /** The fine-grained `GitService` layer alone — for unit tests that need only git. */
12066
12136
  const gitTestLayer = (repo, root = "/repo") => succeed$1(GitService, fakeGitOperations(repo, root));
12137
+ /**
12138
+ * The one sanctioned leak of the real `process.env` into the in-memory `Host`:
12139
+ * the runner's `GTD_FLAG_*` state, so a flag-on `npm test` state exercises the
12140
+ * flag in memory as it does live. A test asserting one flag side sets the flag
12141
+ * itself; explicit env wins.
12142
+ */
12143
+ const ambientFlagEnv = (env = process.env) => Object.fromEntries(Object.entries(env).filter((e) => e[0].startsWith("GTD_FLAG_") && e[1] !== void 0));
12067
12144
  /** Every layer `makeProgram` needs — `CommandRequirements`'s return type here IS the guarantee: a new port added there fails this function's typecheck instead of silently under-providing. */
12068
12145
  function testLayers(repo, opts = {}) {
12069
12146
  const root = opts.root ?? "/repo";
@@ -12071,7 +12148,10 @@ function testLayers(repo, opts = {}) {
12071
12148
  return mergeAll$1(gitTestLayer(repo, root), succeed$1(Workspace, makeInMemoryWorkspaceOps(repo, root)), Host.layer({
12072
12149
  root,
12073
12150
  home,
12074
- env: opts.env ?? {},
12151
+ env: {
12152
+ ...ambientFlagEnv(),
12153
+ ...opts.env
12154
+ },
12075
12155
  realPath: () => succeed$6(root)
12076
12156
  }), ConfigService.Live, makeInMemoryConfigDiscovery(repo, root), Narrator.layer(opts.narrate ?? (() => {}), opts.verbose ?? true), succeed$1(ToolCommands, {
12077
12157
  ...missingToolCommands,
@@ -13395,7 +13475,7 @@ const PATHOLOGICAL_PATHS = [
13395
13475
  }
13396
13476
  ];
13397
13477
  /**
13398
- * Exercise all 21 `GitOperations` methods identically against `makeTier()` —
13478
+ * Exercise every `GitOperations` methods identically against `makeTier()` —
13399
13479
  * called once per tier by `src/platform/Git.test.ts`. A capability-gated group
13400
13480
  * (`t.capabilities.X`) is skipped, not faked, on a tier that can't support it.
13401
13481
  */
@@ -13430,7 +13510,8 @@ const runGitServiceContract = (makeTier) => {
13430
13510
  g.subjectHistory(10, 0),
13431
13511
  g.readFileAtRef("HEAD", "second.txt"),
13432
13512
  g.changedPaths(),
13433
- g.changedPaths("HEAD~1")
13513
+ g.changedPaths("HEAD~1"),
13514
+ g.diffPaths("HEAD~1", "HEAD")
13434
13515
  ]));
13435
13516
  expect(await t.observe.snapshot()).toBe(before);
13436
13517
  await runGit(t, (g) => g.updateRef("refs/gtd/marker", t.observe.resolveRef("HEAD")));
@@ -13835,6 +13916,71 @@ const runGitServiceContract = (makeTier) => {
13835
13916
  expect(isFailure(await runGitExit(t, (g) => g.readFileAtRef("HEAD", "nonexistent.txt")))).toBe(true);
13836
13917
  });
13837
13918
  });
13919
+ describe("diffPaths", () => {
13920
+ const sorted = (paths) => [...paths].sort();
13921
+ it("reports added, modified and deleted paths between two commits", async () => {
13922
+ t.seed.commit("chore: seed", {
13923
+ "mod.txt": "one",
13924
+ "del.txt": "bye"
13925
+ });
13926
+ const base = t.observe.resolveRef("HEAD");
13927
+ t.seed.writeFile("mod.txt", "two");
13928
+ t.seed.stageAll();
13929
+ t.seed.commitDeletion("del.txt", "chore: delete");
13930
+ t.seed.commit("feat: add", {
13931
+ "add.txt": "new",
13932
+ "mod.txt": "two"
13933
+ });
13934
+ const tip = t.observe.resolveRef("HEAD");
13935
+ const paths = await runGit(t, (g) => g.diffPaths(base, tip));
13936
+ expect(sorted(paths)).toEqual([
13937
+ "add.txt",
13938
+ "del.txt",
13939
+ "mod.txt"
13940
+ ]);
13941
+ });
13942
+ it("reports both paths of a rename", async () => {
13943
+ t.seed.commit("chore: seed", { "old.txt": "same content unique-marker\n" });
13944
+ const base = t.observe.resolveRef("HEAD");
13945
+ t.seed.deleteFile("old.txt");
13946
+ t.seed.commit("refactor: rename", { "new.txt": "same content unique-marker\n" });
13947
+ const tip = t.observe.resolveRef("HEAD");
13948
+ expect(sorted(await runGit(t, (g) => g.diffPaths(base, tip)))).toEqual(["new.txt", "old.txt"]);
13949
+ });
13950
+ it("omits a file changed then restored between the endpoints", async () => {
13951
+ t.seed.commit("chore: seed", { "flip.txt": "one" });
13952
+ const base = t.observe.resolveRef("HEAD");
13953
+ t.seed.commit("chore: change", { "flip.txt": "two" });
13954
+ t.seed.commit("chore: restore", { "flip.txt": "one" });
13955
+ const tip = t.observe.resolveRef("HEAD");
13956
+ expect(await runGit(t, (g) => g.diffPaths(base, tip))).toEqual([]);
13957
+ });
13958
+ it("never reports staged, unstaged or untracked changes", async () => {
13959
+ t.seed.commit("chore: seed", {
13960
+ "a.txt": "a",
13961
+ "b.txt": "b"
13962
+ });
13963
+ const base = t.observe.resolveRef("HEAD");
13964
+ t.seed.writeFile("a.txt", "staged");
13965
+ t.seed.stageAll();
13966
+ t.seed.writeFile("b.txt", "unstaged");
13967
+ t.seed.writeFile("untracked.txt", "u");
13968
+ expect(await runGit(t, (g) => g.diffPaths(base, "HEAD"))).toEqual([]);
13969
+ });
13970
+ it("is [] for diffPaths(x, x)", async () => {
13971
+ const head = t.observe.resolveRef("HEAD");
13972
+ expect(await runGit(t, (g) => g.diffPaths(head, head))).toEqual([]);
13973
+ });
13974
+ for (const { label, path } of PATHOLOGICAL_PATHS) it(`reports a path containing ${label}, verbatim`, async () => {
13975
+ const base = t.observe.resolveRef("HEAD");
13976
+ t.seed.commit("feat: pathological", { [path]: "content" });
13977
+ expect(await runGit(t, (g) => g.diffPaths(base, "HEAD"))).toEqual([path]);
13978
+ });
13979
+ it("fails when either side does not resolve", async () => {
13980
+ expect(isFailure(await runGitExit(t, (g) => g.diffPaths("totally-invalid-ref-xyz", "HEAD")))).toBe(true);
13981
+ expect(isFailure(await runGitExit(t, (g) => g.diffPaths("HEAD", "totally-invalid-ref-xyz")))).toBe(true);
13982
+ });
13983
+ });
13838
13984
  describe("changedPaths", () => {
13839
13985
  it("unions a tracked modification, a tracked deletion, and an untracked addition, deduplicated by path", async () => {
13840
13986
  t.seed.commit("chore: seed", {
package/dist/wire.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { C as EXIT_SIGTERM, D as WIRE_SCHEMA, E as UNATTRIBUTED_MODEL, O as renderFormat, S as EXIT_SIGINT, T as ACCESS_REFUSAL, _ 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, 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_USAGE_ERROR, x as EXIT_RUNTIME_ERROR, y as EXIT_CODES } from "./chunks/wire-DA7q2ikx.mjs";
2
- export { ACCESS_REFUSAL, BEAT_KINDS, DEMAND_BRIEFING, EXIT_CODES, EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_SIGINT, EXIT_SIGTERM, 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
+ 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-BY1S-obW.mjs";
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 { 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";
2
2
  import { review as review$1 } from "../workflow-kit.mjs";
3
- import { $ as unwindFailedMessage, A as architectureDecomposePrompt, B as designSystem, C as fixRisks, D as agentWithSkills, E as reviewing, F as buildReviewFixNitsPrompt, G as packagesItemBuildingPrompt, I as buildReviewTriageMessage, J as reviewEditsCapture, K as packagesItemFixSuitePrompt, L as builderSystem, M as buildReviewAnswerQuestionsPrompt, N as buildReviewAwaitAfterDeferMessage, O as architectSystem, P as buildReviewCollectingPrompt, Q as startGateBlockedMessage, R as deferNotesScript, S as fixCheck, T as reviewMissing, V as designTriagePrompt, W as idleMessage, X as reviewNotesCapture, Z as reviewerSystem, _ as FEEDBACK, b as awaitReview, c as refusePendingHandOff, d as defaults, et as unwindFailure, f as envDefaults, g as ARCHITECTURE, h as healthy, i as skills, j as architectureGateAnswerMessage, k as architectureAuthorPrompt, l as tail, m as gate, n as summary, o as closeReview, q as reviewEditNotesCapture, r as access, t as steering, u as shipped, v as REQUIREMENTS, w as planner, x as coder, y as REVIEW, z as designGateAnswerMessage } from "../chunks/reserved-6pAgFGgB.mjs";
3
+ import { $ as unwindFailedMessage, A as architectureDecomposePrompt, B as designSystem, C as fixRisks, D as agentWithSkills, E as reviewing, F as buildReviewFixNitsPrompt, G as packagesItemBuildingPrompt, I as buildReviewTriageMessage, J as reviewEditsCapture, K as packagesItemFixSuitePrompt, L as builderSystem, M as buildReviewAnswerQuestionsPrompt, N as buildReviewAwaitAfterDeferMessage, O as architectSystem, P as buildReviewCollectingPrompt, Q as startGateBlockedMessage, R as deferNotesScript, S as fixCheck, T as reviewMissing, V as designTriagePrompt, W as idleMessage, X as reviewNotesCapture, Z as reviewerSystem, _ as FEEDBACK, b as awaitReview, c as refusePendingHandOff, d as defaults, et as unwindFailure, f as envDefaults, g as ARCHITECTURE, h as healthy, i as skills, j as architectureGateAnswerMessage, k as architectureAuthorPrompt, l as tail, m as gate, n as summary, o as closeReview, q as reviewEditNotesCapture, r as access, t as steering, u as shipped, v as REQUIREMENTS, w as planner, x as coder, y as REVIEW, z as designGateAnswerMessage } from "../chunks/reserved-BL9krPao.mjs";
4
4
  //#region src/workflows/feature/steps.ts
5
5
  /** Turn the sketch since `base` into `.gtd/REQUIREMENTS.md`. */
6
6
  const triage = (base) => agentWithSkills("triage", designTriagePrompt(base), {
@@ -1,6 +1,6 @@
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 { D as agentWithSkills, H as finisherSystem, U as fixReviewNotesPrompt, a as REVIEW_ORIG, d as defaults, f as envDefaults, i as skills, n as summary, p as baseline, r as access, s as handOffTail, t as steering, u as shipped, x as coder, y as REVIEW } from "../chunks/reserved-6pAgFGgB.mjs";
3
+ import { D as agentWithSkills, H as finisherSystem, U as fixReviewNotesPrompt, a as REVIEW_ORIG, d as defaults, f as envDefaults, i as skills, n as summary, p as baseline, r as access, s as handOffTail, t as steering, u as shipped, x as coder, y as REVIEW } from "../chunks/reserved-BL9krPao.mjs";
4
4
  //#region src/workflows/fix/fix.ts
5
5
  /**
6
6
  * Fix every note a human left in a handed-off `.gtd/REVIEW.md` and every
@@ -1,5 +1,5 @@
1
1
  import { start } from "../flows.mjs";
2
- import { Y as reviewGateBlockedMessage, c as refusePendingHandOff, d as defaults, f as envDefaults, i as skills, m as gate, n as summary, r as access, s as handOffTail, t as steering, u as shipped } from "../chunks/reserved-6pAgFGgB.mjs";
2
+ import { Y as reviewGateBlockedMessage, c as refusePendingHandOff, d as defaults, f as envDefaults, i as skills, m as gate, n as summary, r as access, s as handOffTail, t as steering, u as shipped } from "../chunks/reserved-BL9krPao.mjs";
3
3
  //#region src/workflows/review/review.ts
4
4
  /**
5
5
  * Pure review of everything since `reviewBase`: a green gate, the quality
@@ -295,6 +295,11 @@ const requireRevert = (edited, base) => {
295
295
  const quoted = residue.map((path) => `'${path.replace(/'/g, "'\\''")}'`).join(" ");
296
296
  refuse(`gtd land: require-revert: ${residue.join(", ")} still differ from ${base}~1 — the revert did not take. Run \`git checkout ${base}~1 -- ${quoted}\`, then \`gtd land\` again.`);
297
297
  };
298
+ [
299
+ "land: every stray is incidental (a needed import, a test fixture, a renamed call site).",
300
+ "refactor-first: the strays are a seam change the task needs; land them first as a behaviour-neutral refactor.",
301
+ "reject: the naive pass solved the task the wrong way; discard it and re-implement, no human; strays a refactor could absorb never count as reject."
302
+ ].join("\n");
298
303
  //#endregion
299
304
  //#region src/flows/factory.ts
300
305
  const FACTORY = Symbol.for("@pmelab/gtd/workflow-factory");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "26.1.0",
3
+ "version": "27.1.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -106,7 +106,7 @@
106
106
  "postbuild": "node scripts/assert-no-test-doubles.mjs",
107
107
  "watch": "tsdown --watch",
108
108
  "bench:replay": "node scripts/bench-replay.mjs",
109
- "test": "turbo run format:check typecheck lint lint:sh lint:boundaries test:unit test:workflows test:e2e:inmem test:e2e:live test:web analyze",
109
+ "test": "jiti scripts/test-states.main.ts",
110
110
  "test:unit": "vitest run --project unit",
111
111
  "test:workflows": "vitest run --project workflows",
112
112
  "test:watch": "vitest",
@@ -981,9 +981,60 @@ describe("parseArgv — gtd history", () => {
981
981
  })
982
982
  })
983
983
 
984
+ describe("parseArgv — gtd strays", () => {
985
+ const strays = (...args: string[]): CliPlan => parseArgv(["node", "gtd.js", "strays", ...args])
986
+
987
+ it("splits the range at '..' and carries the globs", () => {
988
+ const plan = strays("main..feature", "src/**", "*.md")
989
+ expect(plan).toMatchObject({
990
+ kind: "command",
991
+ command: { kind: "strays", base: "main", tip: "feature", globs: ["src/**", "*.md"] },
992
+ })
993
+ })
994
+
995
+ it("an empty side becomes HEAD; no globs is an empty list", () => {
996
+ expect(strays("main..")).toMatchObject({
997
+ command: { base: "main", tip: "HEAD", globs: [] },
998
+ })
999
+ expect(strays("..main")).toMatchObject({ command: { base: "HEAD", tip: "main" } })
1000
+ expect(strays("..")).toMatchObject({ command: { base: "HEAD", tip: "HEAD" } })
1001
+ })
1002
+
1003
+ it("a missing range, a range without '..', a '...' range and a second '..' are usage errors", () => {
1004
+ for (const args of [[], ["main"], ["main...feature"], ["a..b..c"]]) {
1005
+ expect(strays(...args).kind).toBe("usage")
1006
+ }
1007
+ const plan = strays("main")
1008
+ if (plan.kind === "usage") expect(plan.message).toContain("<base>..<tip>")
1009
+ })
1010
+
1011
+ it("--json is in scope: bare and selected", () => {
1012
+ expect(strays("a..b", "--json")).toMatchObject({ kind: "command", json: { kind: "document" } })
1013
+ expect(strays("a..b", "--json=0")).toMatchObject({
1014
+ kind: "command",
1015
+ json: { kind: "select", path: "0" },
1016
+ })
1017
+ })
1018
+
1019
+ it("--json's scope error and help name strays", () => {
1020
+ const plan = parseArgv(["node", "gtd.js", "lsp", "--json"])
1021
+ if (plan.kind === "usage") expect(plan.message).toContain("`gtd strays`")
1022
+ expect(renderHelp()).toContain("strays <range> [glob...]")
1023
+ expect(renderHelp()).toContain("gtd strays only")
1024
+ })
1025
+ })
1026
+
984
1027
  describe("standaloneKinds / needsOf", () => {
985
1028
  it("pins the standalone kinds", () => {
986
- expect(standaloneKinds()).toEqual(["lsp", "check", "uncheck", "install", "loop", "judgeRun"])
1029
+ expect(standaloneKinds()).toEqual([
1030
+ "lsp",
1031
+ "check",
1032
+ "uncheck",
1033
+ "install",
1034
+ "loop",
1035
+ "judgeRun",
1036
+ "strays",
1037
+ ])
987
1038
  })
988
1039
 
989
1040
  it("needsOf matches none/fs for the standalone kinds and state for everything else", () => {
@@ -993,6 +1044,7 @@ describe("standaloneKinds / needsOf", () => {
993
1044
  expect(needsOf("install")).toBe("none")
994
1045
  expect(needsOf("loop")).toBe("none")
995
1046
  expect(needsOf("judgeRun")).toBe("none")
1047
+ expect(needsOf("strays")).toBe("none")
996
1048
  for (const kind of [
997
1049
  "land",
998
1050
  "start",
package/src/cli/Cli.ts CHANGED
@@ -29,7 +29,7 @@ import {
29
29
  type JsonMode,
30
30
  } from "./Program.js"
31
31
  import type { ToolCommands } from "./ToolCommands.js"
32
- import { EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR } from "../wire/index.js"
32
+ import { EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR } from "../wire/index.js"
33
33
 
34
34
  export type { Command, CommandRequirements } from "./Program.js"
35
35
  export { needsOf, standaloneKinds } from "./Program.js"
@@ -96,15 +96,16 @@ const FLAGS: readonly FlagRow[] = [
96
96
  kind === "judgeAnswer" ||
97
97
  kind === "doors" ||
98
98
  kind === "historyList" ||
99
- kind === "historyShow",
99
+ kind === "historyShow" ||
100
+ kind === "strays",
100
101
  decode: ([raw]) => Either.right(raw ?? ""),
101
102
  scopeError:
102
- "gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors`/`gtd history list`/`gtd history show` — " +
103
+ "gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors`/`gtd history list`/`gtd history show`/`gtd strays` — " +
103
104
  "every other command prints plain text; see `gtd install` for the driver protocol briefing",
104
105
  valueHint: "<path>",
105
106
  help: [
106
107
  "(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors/gtd history",
107
- "list/gtd history show only) output structured JSON. Bare --json prints the whole document;",
108
+ "list/gtd history show/gtd strays only) output structured JSON. Bare --json prints the whole document;",
108
109
  "--json=<path> (a dotted key path into that document, e.g.",
109
110
  "kind, content, session.id) prints just that value: a",
110
111
  "scalar raw and unquoted, a boolean as true/false, a list",
@@ -667,6 +668,25 @@ const COMMAND_ROWS: readonly CommandRow[] = [
667
668
  "every question, with nothing on stdout",
668
669
  ],
669
670
  },
671
+ {
672
+ token: "strays",
673
+ kind: "strays",
674
+ arity: { name: "range", variadic: "glob" },
675
+ details: [
676
+ "List the files <range> (<base>..<tip>, an empty side is HEAD)",
677
+ "touched in its committed net diff that match none of the",
678
+ "globs. Anchored at the repo root: * stays within one path",
679
+ "segment, ** crosses them, everything else is literal — quote",
680
+ "globs in the shell. stdout: a sorted JSON array, [] when",
681
+ "clean; stderr: a summary, plus one stray: line per path with",
682
+ "--verbose. --json=<n> picks one entry, nothing past the end;",
683
+ "a symlink is a path like any other, never followed; a",
684
+ "submodule counts as its path, whatever diff.ignoreSubmodules",
685
+ "or .gitmodules ignore say. Runs in any git repository, no",
686
+ "process needed. Exits 10 when",
687
+ "strays were found, 1 on an unknown revision, 2 on a bad range",
688
+ ],
689
+ },
670
690
  {
671
691
  token: "history list",
672
692
  kind: "historyList",
@@ -1228,6 +1248,28 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1228
1248
  }
1229
1249
  }
1230
1250
 
1251
+ if (kind === "strays") {
1252
+ const [range, ...globs] = restPositionals as [string, ...string[]]
1253
+ const at = range.indexOf("..")
1254
+ if (at < 0 || range.includes("...") || range.indexOf("..", at + 1) >= 0) {
1255
+ return usagePlan(
1256
+ `gtd strays: <range> must be <base>..<tip> (an empty side is HEAD) — got '${range}'`,
1257
+ jsonSeen,
1258
+ )
1259
+ }
1260
+ return {
1261
+ kind: "command",
1262
+ command: {
1263
+ kind: "strays",
1264
+ base: range.slice(0, at) || "HEAD",
1265
+ tip: range.slice(at + 2) || "HEAD",
1266
+ globs,
1267
+ },
1268
+ json,
1269
+ verbose,
1270
+ }
1271
+ }
1272
+
1231
1273
  if (kind === "uncheck") {
1232
1274
  return {
1233
1275
  kind: "command",
@@ -1419,9 +1461,9 @@ export const runCli = (argv: readonly string[], io: CliIo): Effect.Effect<void,
1419
1461
  // where the whole buffered artifact is known-complete and safe to hand
1420
1462
  // to stdout (a failure discards the buffer instead — see
1421
1463
  // `bufferedArtifactOut`).
1422
- Effect.map(() => {
1464
+ Effect.map((code) => {
1423
1465
  out.flush()
1424
- io.exit(EXIT_OK)
1466
+ io.exit(code)
1425
1467
  }),
1426
1468
  Effect.provide(io.layers(plan.verbose)),
1427
1469
  Effect.sandbox,
package/src/cli/Output.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Effect } from "effect"
2
2
  import { CommandRunner, GitService, Host, Narrator, Workspace } from "../platform/index.js"
3
- import { selectPath } from "../wire/index.js"
3
+ import { selectPath, type EXIT_OK, type EXIT_STRAYS_FOUND } from "../wire/index.js"
4
4
  import { ConfigDiscovery, ConfigService } from "../workflow/index.js"
5
5
  import type { ToolCommands } from "./ToolCommands.js"
6
6
 
@@ -66,3 +66,5 @@ export type CommandRequirements =
66
66
  | Narrator
67
67
  | CommandRunner
68
68
  | ToolCommands
69
+
70
+ export type SuccessExit = typeof EXIT_OK | typeof EXIT_STRAYS_FOUND
@@ -1693,6 +1693,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
1693
1693
  historyList: { kind: "historyList" },
1694
1694
  historyShow: { kind: "historyShow", ref: "x" },
1695
1695
  historyFetch: { kind: "historyFetch", remote: "origin" },
1696
+ strays: { kind: "strays", base: "HEAD", tip: "HEAD", globs: [] },
1696
1697
  }
1697
1698
 
1698
1699
  const stateKinds = (Object.keys(commandFor) as Command["kind"][]).filter(