@pmelab/gtd 8.2.1 → 8.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -117,18 +117,23 @@ steering-file entries are chosen by which file you create:
117
117
  Both flows converge on the same tail: an agent hands you a `.gtd/REVIEW.md`
118
118
  checkbox review of the diff — the prompt never inlines the diff itself; it names
119
119
  the commit the changes are based at and the agent runs `git diff` to read the
120
- range before writing the review. Tick a box as you review each hunk (ticking
121
- just records "I read this"), and leave a **comment** to request changes: a note
122
- on a line, an inline `// TODO`-style comment in the code, or a direct code edit.
123
- Any comment sends a build + re-review round — an agent first turns your comments
124
- into an explicit instruction list, then a build turn implements it (a re-review
125
- then covers only the follow-through, and a hand-edit is treated as your own fix
126
- the agent completes without reverting your lines; a comment can't be silently
127
- dropped — a build turn that addresses nothing is refused). Ticking every box
128
- with no comment is the sign-off, which collapses the whole cycle into one commit
129
- (a **squash finale** whose message an agent drafts). Stepping with a box still
130
- unticked and no comment is refused (finish reviewing first), as is deleting
131
- `.gtd/REVIEW.md`.
120
+ range before writing the review. While the cycle rests at that gate, gtd opens a
121
+ **review checkout window**: HEAD is rewound to the review base with the working
122
+ tree untouched, so the whole reviewable change shows up as ordinary uncommitted
123
+ changes in your editor's normal git integration (and files added during the
124
+ cycle show up as ordinary untracked files, so discarding one deletes it). The
125
+ window closes on the next gtd invocation. Tick a box as you review each hunk
126
+ (ticking just records "I read this"), and leave a **comment** to request
127
+ changes: a note on a line, an inline `// TODO`-style comment in the code, or a
128
+ direct code edit. Any comment sends a build + re-review round — an agent first
129
+ turns your comments into an explicit instruction list, then a build turn
130
+ implements it (a re-review then covers only the follow-through, and a hand-edit
131
+ is treated as your own fix the agent completes without reverting your lines; a
132
+ comment can't be silently dropped — a build turn that addresses nothing is
133
+ refused). Ticking every box with no comment is the sign-off, which collapses the
134
+ whole cycle into one commit (a **squash finale** whose message an agent drafts).
135
+ Stepping with a box still unticked and no comment is refused (finish reviewing
136
+ first), as is deleting `.gtd/REVIEW.md`.
132
137
 
133
138
  The same review tail also has a direct entry point — `gtd review <commitish>`
134
139
  starts a brand new process reviewing `<commitish>..HEAD` with no cycle of its
@@ -308,6 +313,13 @@ name surfaced in `gtd next --json`/`gtd status`. The driver uses it for its
308
313
  per-beat progress lines; an outer wrapper (a terminal multiplexer, a notifier)
309
314
  can use it the same way.
310
315
 
316
+ A state can also declare an optional `memory:` scope label: consecutive agent
317
+ turns sharing the same label continue one agent session (remembered in the git
318
+ dir, so it survives restarts across the same worktree), while a changed or
319
+ absent label starts a fresh one. If the remembered session no longer exists
320
+ (retention expired, `~/.claude/projects` wiped, a machine change), the driver
321
+ degrades to a fresh session with a warning instead of stopping the loop.
322
+
311
323
  ### Terminal-multiplexer status: a herdr wrapper
312
324
 
313
325
  [herdr](https://herdr.dev) shows a per-pane status in its sidebar. This wrapper
package/bin/gtd CHANGED
@@ -23,7 +23,16 @@ set -euo pipefail
23
23
  # Every call the loop body makes below goes through `run_gtd` too, so the loop
24
24
  # always drives the exact same build/source it was launched from — no
25
25
  # dependence on a `gtd` binary being on PATH.
26
- SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
26
+ # Follow symlinks first: npm's global install exposes this script as a
27
+ # symlink in the node prefix's bin/, and dist/ only exists next to the REAL
28
+ # script inside the package.
29
+ SCRIPT_SOURCE="${BASH_SOURCE[0]}"
30
+ while [[ -L "$SCRIPT_SOURCE" ]]; do
31
+ SCRIPT_DIR="$(cd -- "$(dirname -- "$SCRIPT_SOURCE")" &>/dev/null && pwd)"
32
+ SCRIPT_SOURCE="$(readlink "$SCRIPT_SOURCE")"
33
+ [[ "$SCRIPT_SOURCE" != /* ]] && SCRIPT_SOURCE="$SCRIPT_DIR/$SCRIPT_SOURCE"
34
+ done
35
+ SCRIPT_DIR="$(cd -- "$(dirname -- "$SCRIPT_SOURCE")" &>/dev/null && pwd)"
27
36
 
28
37
  run_gtd() {
29
38
  if [[ -n "${GTD_BIN:-}" ]]; then
@@ -172,6 +181,23 @@ emit_output_since() {
172
181
  emit_block "$bytes"
173
182
  }
174
183
 
184
+ # A resume that failed because the session it names no longer exists — claude
185
+ # prints "No conversation found with session ID: <uuid>". Loose on purpose: any
186
+ # adapter whose CLI says something session-shaped gets the same recovery
187
+ # instead of stopping the loop.
188
+ SESSION_GONE_RE='no conversation found with session id|no such session|session (id )?[^ ]*( was)? not found|session [^ ]* does not exist'
189
+
190
+ # session_gone_since <offset> — true when the log bytes appended since <offset>
191
+ # carry the missing-session signature above. Blind (returns false) when no log
192
+ # is configured, so recovery is best-effort, never a hard dependency.
193
+ session_gone_since() {
194
+ local offset="$1" bytes=""
195
+ if [[ -n "${GTD_LOOP_LOG:-}" && -f "$GTD_LOOP_LOG" ]]; then
196
+ bytes="$(tail -c "+$((offset + 1))" "$GTD_LOOP_LOG" 2>/dev/null || true)"
197
+ fi
198
+ [[ -n "$bytes" ]] && grep -qiE "$SESSION_GONE_RE" <<<"$bytes"
199
+ }
200
+
175
201
  # emit_warn <message> — a surfaced but NON-fatal failure (a check script that
176
202
  # exited non-zero).
177
203
  emit_warn() {
@@ -330,7 +356,18 @@ fi
330
356
  # still resumes one planning session. The scope, session id, and a
331
357
  # resume-or-fresh flag are exported as $GTD_LOOP_MEMORY / $GTD_LOOP_SESSION_ID
332
358
  # / $GTD_LOOP_MEMORY_RESUME; the default adapter turns them into
333
- # `--resume`/`--session-id`. An adapter that ignores them keeps working.
359
+ # `--resume`/`--session-id`. An adapter that ignores them keeps working. A
360
+ # remembered session that has since vanished (the retention window elapsed, a
361
+ # wiped `~/.claude/projects`, a machine change) degrades to a fresh session
362
+ # with a warning rather than failing the turn — see run_agent_turn's own
363
+ # comment.
364
+
365
+ # new_session_id — mints an opaque session id for a fresh memory scope
366
+ # (uuidgen, falling back to the kernel's random uuid, or empty when neither is
367
+ # available — the default adapter then omits --session-id entirely).
368
+ new_session_id() {
369
+ uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || echo ""
370
+ }
334
371
 
335
372
  prev_state=""
336
373
  prev_content=""
@@ -362,7 +399,7 @@ fi
362
399
  # the resume flag; the memory scope ($memory/$session_id) is read from the
363
400
  # enclosing iteration, so a same-turn re-prompt (the validate gate) resumes the
364
401
  # same session.
365
- run_agent_turn() {
402
+ invoke_agent() {
366
403
  local prompt="$1" do_resume="$2" cmd="${GTD_LOOP_AGENT_CMD:-}"
367
404
  if [[ -z "$cmd" ]]; then
368
405
  cmd='claude -p "$GTD_LOOP_PROMPT"'
@@ -379,6 +416,33 @@ run_agent_turn() {
379
416
  >>"${GTD_LOOP_LOG:-/dev/null}" 2>&1
380
417
  }
381
418
 
419
+ # run_agent_turn <prompt> <resume> — invoke_agent, with one recovery: a
420
+ # resumed turn (do_resume == 1) that fails AND whose output carries the
421
+ # missing-session signature (session_gone_since) means the marker's session no
422
+ # longer exists on disk. Rather than failing the turn (and every turn after
423
+ # it, since the dead id would just be resumed again), mint a fresh session,
424
+ # rewrite $memory_marker with it — the self-healing part, so a later launch
425
+ # doesn't repeat this — and retry the SAME prompt once with resume off. $resume
426
+ # and $session_id are the enclosing iteration's globals; recovery rewrites them
427
+ # too, so a later validate-gate re-prompt in the same turn resumes the newly
428
+ # pinned session. Blind to the failure mode when session_gone_since can't tell
429
+ # (no log configured): a genuine agent failure then correctly propagates.
430
+ run_agent_turn() {
431
+ local prompt="$1" do_resume="$2" offset status=0
432
+ offset="$(log_offset)"
433
+ invoke_agent "$prompt" "$do_resume" && return 0
434
+ status=$?
435
+ if [[ "$do_resume" == "1" ]] && session_gone_since "$offset"; then
436
+ emit_warn "the remembered agent session for '$memory' is gone — continuing in a fresh session (this turn starts without its previous context)"
437
+ session_id="$(new_session_id)"
438
+ resume=0
439
+ printf '%s\n%s\n' "$memory" "$session_id" >"$memory_marker"
440
+ invoke_agent "$prompt" 0
441
+ return $?
442
+ fi
443
+ return $status
444
+ }
445
+
382
446
  # The furthest commit already reported to the terminal by report_commits (see
383
447
  # its own comment) — a global high-water mark rather than a per-beat
384
448
  # head_before, so a review checkout window's HEAD rewind between beats never
@@ -528,7 +592,7 @@ $next_out"
528
592
  resume=1
529
593
  session_id="$prev_session"
530
594
  else
531
- session_id="$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || echo "")"
595
+ session_id="$(new_session_id)"
532
596
  fi
533
597
  printf '%s\n%s\n' "$memory" "$session_id" >"$memory_marker"
534
598
  else
@@ -6541,6 +6541,14 @@ const Equivalence$3 = (self, that) => matchWith(self, that, {
6541
6541
  * @since 2.0.0
6542
6542
  * @category math
6543
6543
  */
6544
+ const times = /*#__PURE__*/ dual(2, (self, times) => match$6(self, {
6545
+ onMillis: (millis) => make$40(millis * times),
6546
+ onNanos: (nanos) => make$40(nanos * BigInt(times))
6547
+ }));
6548
+ /**
6549
+ * @since 2.0.0
6550
+ * @category math
6551
+ */
6544
6552
  const sum = /*#__PURE__*/ dual(2, (self, that) => matchWith(self, that, {
6545
6553
  onMillis: (self, that) => make$40(self + that),
6546
6554
  onNanos: (self, that) => make$40(self + that)
@@ -9804,6 +9812,10 @@ const defaultServicesWith = (f) => withFiberRuntime$1((fiber) => f(fiber.current
9804
9812
  const clockWith = (f) => defaultServicesWith((services) => f(services.unsafeMap.get(clockTag.key)));
9805
9813
  /** @internal */
9806
9814
  const currentTimeMillis$1 = /*#__PURE__*/ clockWith((clock) => clock.currentTimeMillis);
9815
+ /** @internal */
9816
+ const randomWith = (f) => defaultServicesWith((services) => f(services.unsafeMap.get(randomTag.key)));
9817
+ /** @internal */
9818
+ const next$1 = /*#__PURE__*/ randomWith((random) => random.next);
9807
9819
  //#endregion
9808
9820
  //#region node_modules/effect/dist/esm/Boolean.js
9809
9821
  /**
@@ -11786,7 +11798,7 @@ const fromIterable = (iterable) => {
11786
11798
  return new MetricBoundariesImpl(pipe(iterable, appendAll$2(of$1(Number.POSITIVE_INFINITY)), dedupe));
11787
11799
  };
11788
11800
  /** @internal */
11789
- const exponential = (options) => pipe(makeBy(options.count - 1, (i) => options.start * Math.pow(options.factor, i)), unsafeFromArray, fromIterable);
11801
+ const exponential$2 = (options) => pipe(makeBy(options.count - 1, (i) => options.start * Math.pow(options.factor, i)), unsafeFromArray, fromIterable);
11790
11802
  /** @internal */
11791
11803
  const MetricKeyTypeTypeId = /*#__PURE__*/ Symbol.for("effect/MetricKeyType");
11792
11804
  /** @internal */
@@ -12860,7 +12872,7 @@ const fiberSuccesses = /*#__PURE__*/ counter("effect_fiber_successes", { increme
12860
12872
  /** @internal */
12861
12873
  const fiberFailures = /*#__PURE__*/ counter("effect_fiber_failures", { incremental: true });
12862
12874
  /** @internal */
12863
- const fiberLifetimes = /*#__PURE__*/ tagged$1(/*#__PURE__*/ histogram("effect_fiber_lifetimes", /*#__PURE__*/ exponential({
12875
+ const fiberLifetimes = /*#__PURE__*/ tagged$1(/*#__PURE__*/ histogram("effect_fiber_lifetimes", /*#__PURE__*/ exponential$2({
12864
12876
  start: .5,
12865
12877
  factor: 2,
12866
12878
  count: 35
@@ -14303,7 +14315,7 @@ const isEmpty$3 = (self) => {
14303
14315
  return self.startMillis >= self.endMillis;
14304
14316
  };
14305
14317
  /** @internal */
14306
- const intersect$4 = /*#__PURE__*/ dual(2, (self, that) => {
14318
+ const intersect$5 = /*#__PURE__*/ dual(2, (self, that) => {
14307
14319
  const start = Math.max(self.startMillis, that.startMillis);
14308
14320
  const end = Math.min(self.endMillis, that.endMillis);
14309
14321
  return make$22(start, end);
@@ -14356,7 +14368,7 @@ const isEmpty$2 = isEmpty$3;
14356
14368
  * @since 2.0.0
14357
14369
  * @category ordering
14358
14370
  */
14359
- const intersect$3 = intersect$4;
14371
+ const intersect$4 = intersect$5;
14360
14372
  /**
14361
14373
  * Calculates the size of the `Interval` as the `Duration` from the start of the
14362
14374
  * interval to the end of the interval.
@@ -14383,14 +14395,14 @@ const make$20 = (intervals) => {
14383
14395
  };
14384
14396
  };
14385
14397
  /** @internal */
14386
- const intersect$2 = /*#__PURE__*/ dual(2, (self, that) => intersectLoop(self.intervals, that.intervals, empty$24()));
14398
+ const intersect$3 = /*#__PURE__*/ dual(2, (self, that) => intersectLoop(self.intervals, that.intervals, empty$24()));
14387
14399
  /** @internal */
14388
14400
  const intersectLoop = (_left, _right, _acc) => {
14389
14401
  let left = _left;
14390
14402
  let right = _right;
14391
14403
  let acc = _acc;
14392
14404
  while (isNonEmpty$4(left) && isNonEmpty$4(right)) {
14393
- const interval = pipe(headNonEmpty(left), intersect$3(headNonEmpty(right)));
14405
+ const interval = pipe(headNonEmpty(left), intersect$4(headNonEmpty(right)));
14394
14406
  const intervals = isEmpty$2(interval) ? acc : pipe(acc, prepend$1(interval));
14395
14407
  if (pipe(headNonEmpty(left), lessThan$3(headNonEmpty(right)))) left = tailNonEmpty(left);
14396
14408
  else right = tailNonEmpty(right);
@@ -14427,7 +14439,7 @@ const make$19 = make$20;
14427
14439
  * @since 2.0.0
14428
14440
  * @category utils
14429
14441
  */
14430
- const intersect$1 = intersect$2;
14442
+ const intersect$2 = intersect$3;
14431
14443
  /**
14432
14444
  * The start of the earliest interval in the specified `Intervals`.
14433
14445
  *
@@ -15740,6 +15752,15 @@ const uncapitalize = (self) => {
15740
15752
  * @since 2.0.0
15741
15753
  */
15742
15754
  const isNonEmpty$1 = (self) => self.length > 0;
15755
+ //#endregion
15756
+ //#region node_modules/effect/dist/esm/Random.js
15757
+ /**
15758
+ * Returns the next numeric value from the pseudo-random number generator.
15759
+ *
15760
+ * @since 2.0.0
15761
+ * @category constructors
15762
+ */
15763
+ const next = next$1;
15743
15764
  /** @internal */
15744
15765
  const ScheduleTypeId = /*#__PURE__*/ Symbol.for("effect/Schedule");
15745
15766
  /** @internal */
@@ -15866,9 +15887,18 @@ const checkEffect = /*#__PURE__*/ dual(2, (self, test) => makeWithState(self.ini
15866
15887
  ]);
15867
15888
  })));
15868
15889
  /** @internal */
15890
+ const delayedEffect = /*#__PURE__*/ dual(2, (self, f) => modifyDelayEffect(self, (_, delay) => f(delay)));
15891
+ /** @internal */
15892
+ const delayedSchedule = (schedule) => addDelay(schedule, (x) => x);
15893
+ /** @internal */
15869
15894
  const driver = (self) => pipe(make$29([none$4(), self.initial]), map$8((ref) => new ScheduleDriverImpl(self, ref)));
15870
15895
  /** @internal */
15871
- const intersect = /*#__PURE__*/ dual(2, (self, that) => intersectWith(self, that, intersect$1));
15896
+ const exponential$1 = (baseInput, factor = 2) => {
15897
+ const base = decode(baseInput);
15898
+ return delayedSchedule(map$5(forever, (i) => times(base, Math.pow(factor, i))));
15899
+ };
15900
+ /** @internal */
15901
+ const intersect$1 = /*#__PURE__*/ dual(2, (self, that) => intersectWith(self, that, intersect$2));
15872
15902
  /** @internal */
15873
15903
  const intersectWith = /*#__PURE__*/ dual(3, (self, that, f) => makeWithState([self.initial, that.initial], (now, input, state) => pipe(zipWith$2(self.step(now, input, state[0]), that.step(now, input, state[1]), (a, b) => [a, b]), flatMap$6(([[lState, out, lDecision], [rState, out2, rDecision]]) => {
15874
15904
  if (isContinue(lDecision) && isContinue(rDecision)) return intersectWithLoop(self, that, input, lState, out, lDecision.intervals, rState, out2, rDecision.intervals, f);
@@ -15904,6 +15934,23 @@ const intersectWithLoop = (self, that, input, lState, out, lInterval, rState, ou
15904
15934
  });
15905
15935
  };
15906
15936
  /** @internal */
15937
+ const jittered$1 = (self) => jitteredWith(self, {
15938
+ min: .8,
15939
+ max: 1.2
15940
+ });
15941
+ /** @internal */
15942
+ const jitteredWith = /*#__PURE__*/ dual(2, (self, options) => {
15943
+ const { max, min } = Object.assign({
15944
+ min: .8,
15945
+ max: 1.2
15946
+ }, options);
15947
+ return delayedEffect(self, (duration) => map$8(next, (random) => {
15948
+ const d = toMillis(duration);
15949
+ const jittered = d * min * (1 - random) + d * max * random;
15950
+ return millis(jittered);
15951
+ }));
15952
+ });
15953
+ /** @internal */
15907
15954
  const map$5 = /*#__PURE__*/ dual(2, (self, f) => mapEffect(self, (out) => sync$2(() => f(out))));
15908
15955
  /** @internal */
15909
15956
  const mapEffect = /*#__PURE__*/ dual(2, (self, f) => makeWithState(self.initial, (now, input, state) => flatMap$6(self.step(now, input, state), ([state, out, decision]) => map$8(f(out), (out2) => [
@@ -15941,7 +15988,7 @@ const passthrough = (self) => makeWithState(self.initial, (now, input, state) =>
15941
15988
  decision
15942
15989
  ])));
15943
15990
  /** @internal */
15944
- const recurs = (n) => whileOutput(forever, (out) => out < n);
15991
+ const recurs$1 = (n) => whileOutput(forever, (out) => out < n);
15945
15992
  /** @internal */
15946
15993
  const spaced$1 = (duration) => addDelay(forever, () => duration);
15947
15994
  /** @internal */
@@ -15990,7 +16037,7 @@ const repeat_combined = /*#__PURE__*/ dual(2, (self, options) => {
15990
16037
  if (typeof applied === "boolean") return succeed$8(applied);
15991
16038
  return scheduleDefectWrap(applied);
15992
16039
  }) : withWhile;
15993
- const withTimes = options.times ? intersect(withUntil, recurs(options.times)).pipe(map$5((intersectionPair) => intersectionPair[0])) : withUntil;
16040
+ const withTimes = options.times ? intersect$1(withUntil, recurs$1(options.times)).pipe(map$5((intersectionPair) => intersectionPair[0])) : withUntil;
15994
16041
  return scheduleDefectRefail(repeat_Effect(self, withTimes));
15995
16042
  });
15996
16043
  /** @internal */
@@ -16026,7 +16073,7 @@ const fromRetryOptions = (options) => {
16026
16073
  if (typeof applied === "boolean") return succeed$8(applied);
16027
16074
  return scheduleDefectWrap(applied);
16028
16075
  }) : withWhile;
16029
- return options.times !== void 0 ? intersect(withUntil, recurs(options.times)) : withUntil;
16076
+ return options.times !== void 0 ? intersect$1(withUntil, recurs$1(options.times)) : withUntil;
16030
16077
  };
16031
16078
  /** @internal */
16032
16079
  const retryOrElse_Effect = /*#__PURE__*/ dual(3, (self, policy, orElse) => flatMap$6(driver(policy), (driver) => retryOrElse_EffectLoop(provideServiceEffect(self, CurrentIterationMetadata, get$4(driver.iterationMeta)), driver, (e, out) => provideServiceEffect(orElse(e, out), CurrentIterationMetadata, get$4(driver.iterationMeta)))));
@@ -20184,6 +20231,79 @@ const provideMerge = provideMerge$1;
20184
20231
  //#endregion
20185
20232
  //#region node_modules/effect/dist/esm/Schedule.js
20186
20233
  /**
20234
+ * Creates a schedule that recurs indefinitely with exponentially increasing
20235
+ * delays.
20236
+ *
20237
+ * **Details**
20238
+ *
20239
+ * This schedule starts with an initial delay of `base` and increases the delay
20240
+ * exponentially on each repetition using the formula `base * factor^n`, where
20241
+ * `n` is the number of times the schedule has executed so far. If no `factor`
20242
+ * is provided, it defaults to `2`, causing the delay to double after each
20243
+ * execution.
20244
+ *
20245
+ * @since 2.0.0
20246
+ * @category Constructors
20247
+ */
20248
+ const exponential = exponential$1;
20249
+ /**
20250
+ * Combines two schedules, continuing only if both schedules want to continue,
20251
+ * using the longer delay.
20252
+ *
20253
+ * **Details**
20254
+ *
20255
+ * This function takes two schedules and creates a new schedule that only
20256
+ * continues execution if both schedules allow it. The interval between
20257
+ * recurrences is determined by the longer delay between the two schedules.
20258
+ *
20259
+ * The output of the resulting schedule is a tuple containing the outputs of
20260
+ * both schedules. The input type is the intersection of both schedules' input
20261
+ * types.
20262
+ *
20263
+ * This is useful when coordinating multiple scheduling conditions where
20264
+ * execution should proceed only when both schedules permit it.
20265
+ *
20266
+ * @see {@link intersectWith} If you need to use a custom merge function.
20267
+ *
20268
+ * @since 2.0.0
20269
+ * @category Composition
20270
+ */
20271
+ const intersect = intersect$1;
20272
+ /**
20273
+ * Returns a new schedule that randomly adjusts the interval size within a
20274
+ * range.
20275
+ *
20276
+ * **Details**
20277
+ *
20278
+ * This function modifies a schedule so that its delay between executions is
20279
+ * randomly varied within a range. By default, the delay is adjusted between
20280
+ * `80%` (`0.8 * interval`) and `120%` (`1.2 * interval`) of the original
20281
+ * interval size.
20282
+ *
20283
+ * This is useful for adding randomness to repeated executions, reducing
20284
+ * contention in distributed systems, and avoiding synchronized execution
20285
+ * patterns that can cause bottlenecks.
20286
+ *
20287
+ * @see {@link jitteredWith} If you need to specify custom min/max values.
20288
+ *
20289
+ * @since 2.0.0
20290
+ * @category Timing & Delay
20291
+ */
20292
+ const jittered = jittered$1;
20293
+ /**
20294
+ * A schedule that recurs a fixed number of times before terminating.
20295
+ *
20296
+ * **Details**
20297
+ *
20298
+ * This schedule will continue executing until it has been stepped `n` times,
20299
+ * after which it will stop. The output of the schedule is the current count of
20300
+ * recurrences.
20301
+ *
20302
+ * @category Constructors
20303
+ * @since 2.0.0
20304
+ */
20305
+ const recurs = recurs$1;
20306
+ /**
20187
20307
  * Returns a schedule that recurs continuously, with each repetition
20188
20308
  * spaced by the specified `duration` from the last run.
20189
20309
  *
@@ -191264,15 +191384,40 @@ const parseNameStatus = (out) => {
191264
191384
  return result;
191265
191385
  };
191266
191386
  /**
191387
+ * True when `error` is git's `index.lock` contention failure — another process
191388
+ * held the index lock when git tried to take it. gtd shares one worktree index
191389
+ * with every git-aware tool the reviewer runs (editor SCM, `gtd lsp`,
191390
+ * git-aware shell prompts): each refreshes its stat cache by WRITING the index
191391
+ * whenever a `git reset --mixed` in the review window wakes it, so gtd's own
191392
+ * index writes lose the `index.lock` race often on a large repo (where each
191393
+ * write takes long enough for the windows to overlap). The lock failure is a
191394
+ * pure "couldn't start" — git did nothing — so the losing command is safe to
191395
+ * retry verbatim (see `withIndexLockRetry`).
191396
+ */
191397
+ const isIndexLockError = (error) => /index\.lock[\s\S]*File exists|Another git process seems to be running/i.test(error.message);
191398
+ /**
191399
+ * Retry an index-writing git command through transient `index.lock` contention
191400
+ * with jittered exponential backoff (~10ms → ~640ms, capped at 6 retries), and
191401
+ * ONLY on that error — any other failure propagates on the first attempt. This
191402
+ * is the general defense for the concurrent reality `isIndexLockError`
191403
+ * describes; it covers every index writer (`git add -A`, `reset`, `restore`),
191404
+ * not just the review window's own steps.
191405
+ */
191406
+ const withIndexLockRetry = (eff) => retry(eff, {
191407
+ schedule: intersect(recurs(6), exponential(millis(10), 2)).pipe(jittered),
191408
+ while: (error) => isIndexLockError(error)
191409
+ });
191410
+ /**
191267
191411
  * Run a command and return its stdout — FAILING on a non-zero exit code with
191268
191412
  * the command line and stderr in the error message. `Command.string` alone
191269
191413
  * only collects stdout and silently ignores exit codes, which used to make
191270
191414
  * gtd report success on rejected commits (hooks, gpg), resolve Idle outside a
191271
191415
  * repository, and lose files whose quoted paths broke a swallowed `git add`.
191272
191416
  * Callers that expect a probe to fail (missing refs, empty repos) handle it
191273
- * with an explicit `catchAll`.
191417
+ * with an explicit `catchAll`. `index.lock` contention is retried transparently
191418
+ * (`withIndexLockRetry`) before any caller's `catchAll` sees it.
191274
191419
  */
191275
- const run = (root, ...args) => scoped$3(gen(function* () {
191420
+ const run = (root, ...args) => withIndexLockRetry(scoped$3(gen(function* () {
191276
191421
  const process = yield* (yield* CommandExecutor).start(make$1(...args).pipe(workingDirectory(root), stdout("pipe"), stderr("pipe")));
191277
191422
  const collect = (stream) => stream.pipe(decodeText(), mkString);
191278
191423
  const [stdout$2, stderr$2, exitCode] = yield* all([
@@ -191282,7 +191427,7 @@ const run = (root, ...args) => scoped$3(gen(function* () {
191282
191427
  ], { concurrency: "unbounded" });
191283
191428
  if (exitCode !== 0) return yield* fail$2(/* @__PURE__ */ new Error(`${args.join(" ")} failed (exit ${exitCode}): ${stderr$2.trim()}`));
191284
191429
  return stdout$2;
191285
- })).pipe(mapError$1((e) => e instanceof Error ? e : new Error(String(e))));
191430
+ })).pipe(mapError$1((e) => e instanceof Error ? e : new Error(String(e)))));
191286
191431
  const makeGitImpl = (executor, root) => {
191287
191432
  const exec = (...args) => run(root, ...args).pipe(provide$1(succeed$2(CommandExecutor, executor)));
191288
191433
  return {
@@ -191348,8 +191493,7 @@ const makeGitImpl = (executor, root) => {
191348
191493
  deleteRef: (ref) => exec("git", "update-ref", "-d", ref).pipe(asVoid),
191349
191494
  mixedResetTo: (ref) => exec("git", "reset", "--mixed", ref).pipe(asVoid),
191350
191495
  hardResetTo: (ref) => exec("git", "reset", "--hard", ref).pipe(asVoid),
191351
- restoreStagedFrom: (source, paths) => paths.length === 0 ? _void : exec("git", "restore", "--staged", `--source=${source}`, "--", ...paths).pipe(catchAll(() => _void)),
191352
- addIntentToAdd: () => exec("git", "add", "--intent-to-add", ".").pipe(asVoid)
191496
+ restoreStagedFrom: (source, paths) => paths.length === 0 ? _void : exec("git", "restore", "--staged", `--source=${source}`, "--", ...paths).pipe(catchAll(() => _void))
191353
191497
  };
191354
191498
  };
191355
191499
  const makeLiveEffect = gen(function* () {
@@ -192525,8 +192669,8 @@ const closeReviewWindow = gen(function* () {
192525
192669
  * is nothing to surface and the window stays closed.
192526
192670
  *
192527
192671
  * Ordering is crash-safe: base ref → head ref → mixed reset → `.gtd/` index
192528
- * pin → intent-to-add. A crash before the head-ref write leaves only a stale
192529
- * base ref (overwritten on the next open); a crash after it leaves HEAD ==
192672
+ * pin. A crash before the head-ref write leaves only a stale base ref
192673
+ * (overwritten on the next open); a crash after it leaves HEAD ==
192530
192674
  * review-head, which the next invocation's close restores as a no-op.
192531
192675
  */
192532
192676
  const openReviewWindow = gen(function* () {
@@ -192542,7 +192686,6 @@ const openReviewWindow = gen(function* () {
192542
192686
  yield* git.updateRef(REVIEW_HEAD_REF, headHash);
192543
192687
  yield* git.mixedResetTo(base);
192544
192688
  yield* git.restoreStagedFrom(REVIEW_HEAD_REF, [".gtd"]);
192545
- yield* git.addIntentToAdd();
192546
192689
  return { opened: true };
192547
192690
  });
192548
192691
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "8.2.1",
3
+ "version": "8.3.1",
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": {