@erclx/aitk 3.51.0 โ†’ 3.52.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "3.51.0",
4
+ "version": "3.52.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -17,6 +17,7 @@ Review is the step that varies most. It gets skipped on a diff that needed one,
17
17
  - Give every step a stop condition, and leave the code on the branch and the receipts on disk at each one
18
18
  - Classify the changed-file list by path as well as by extension, so informational prose skips a code review with no signal on it and executable prose still reaches one
19
19
  - Split findings by origin, stopping on a critical or should-fix one the branch inherited and repairing one this run caused
20
+ - Own the review receipt's lifetime, since this chain writes it, cites it in its own closing block, and is the only body that can read whether the step keeping it is still using it
20
21
  - Delegate the ship sequence to `git-ship` rather than restating it, and name only what this chain adds to it
21
22
  - Open the pull request as a draft before the continuous integration watch begins, since a pull request marked after it is mergeable for the length of the run
22
23
  - Name the recovery for the stop it took, since the value of stopping is that the user knows where to resume
@@ -152,6 +152,8 @@ Read origin as causation rather than authorship. Staleness this run induced in a
152
152
 
153
153
  Bound the repair at one pass, the way Step 3 bounds verify. When that re-read shows the finding still standing, stop: `โŒ A self-introduced finding survived one fix pass. See .claude/review/branch/review-<slug>.md. Fix and run /git-ship.`
154
154
 
155
+ This chain owns the receipt's lifetime, which is what makes the Output block's citation resolve on a run that reaches it. `claude-docs` used to delete the current slug's receipt while running under Step 7 below, so the closing line named a file the same run had already removed. That sweep now reaches only reports whose branch is gone, which collects this one a branch later rather than during the run that wrote it. The cost is one receipt per live branch left in `.claude/review/branch/`, bounded by the branch count rather than by the lifetime of the checkout.
156
+
155
157
  ## Step 7: ship
156
158
 
157
159
  Invoke `aitk:git-ship`. That body owns the sequence, being the verify gate, memory capture, both doc syncs, staging, the commit grouping, the branch rename, the pull request, the CI watch, and the scoped memory review, along with the reason each step sits where it does. This step used to restate that list and the two drifted apart with nothing comparing them, so read the order there and never here.
@@ -13,7 +13,7 @@ A decision's verification anchor has the same shape of gap in the other directio
13
13
 
14
14
  A handoff file has the same shape of gap as an unmarked outcome. A page a learning workspace produced and an operator already picked a destination for sits in gitignored scratch until something lands it, and the session that produced it is gone by the time a branch exists to carry the write. Left unfolded it reads as promoted while the destination holds nothing.
15
15
 
16
- The receipt half of that sweep was missing entirely. A review receipt is deleted per shipped branch and a memory-review receipt was skipped, because the exclusion protecting the checklist and audit prefixes caught a third by accident. Nothing but an operator asking collected it, and a collection that waits on someone remembering to ask is one a folder outgrows, so the skipped population grew per shipped branch and never shrank until it was most of the folder.
16
+ The receipt half of that sweep was missing entirely. A review receipt was deleted per shipped branch and a memory-review receipt was skipped, because the exclusion protecting the checklist and audit prefixes caught a third by accident. Nothing but an operator asking collected it, and a collection that waits on someone remembering to ask is one a folder outgrows, so the skipped population grew per shipped branch and never shrank until it was most of the folder.
17
17
 
18
18
  The trigger side carries a gap of its own. "Sync the docs" names either corpus to the person saying it, so a description leaving its corpus to the opening clause alone competes with its public-facing sibling on nothing the routing field states, and the planning surface the request was about goes untouched.
19
19
 
@@ -31,6 +31,7 @@ The trigger side carries a gap of its own. "Sync the docs" names either corpus t
31
31
  - Scan every memory-review receipt rather than the one matching this slug, since the skill that writes them runs after this one in the ship chain and a slug is unique per feature
32
32
  - Collect a memory-review receipt whose items are all decided, folding each skip into its memory entry first, since a declined item is recorded nowhere else and a promoted one is already in git
33
33
  - Leave a memory-review receipt holding a pending item, and report the count. A branch shipping is not an operator deciding what the receipt proposed.
34
+ - Leave the current branch's review receipt alone, since the chain that wrote it cites it in its own closing line and this skill cannot read whether that citation is still live
34
35
  - Land each block of a promotion handoff at the destination its heading names, then delete the file so a later run does not fold it twice
35
36
  - Take a promotion destination as already decided, since the operator confirmed it where the page was produced
36
37
 
@@ -245,15 +245,15 @@ Write the retarget as a markdown link, `Plan: [feature-<slug>](../plans/archive/
245
245
 
246
246
  ### Reviews
247
247
 
248
- Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. Fall back to `latest` on an empty result.
248
+ Leave the current branch's review receipt where it is. `claude-autoship` Step 6 keeps minor findings in `.claude/review/branch/review-<slug>.md` and its closing block hands the reader that path, so deleting it here removes the file the chain that invoked this skill is still citing. Seven runs recorded that collision across two days before a sandbox fixture asserted the receipt and could pass only on a run the chain stopped early.
249
249
 
250
- If `.claude/review/branch/review-<slug>.md` exists, delete it. `claude-review` writes with this convention.
250
+ The body that writes a receipt owns its lifetime. This skill sweeps on behalf of whatever called it and has no way to read whether a file is still in use, where the chain that wrote this one cites it in its own output and knows. What reaps it is the branch sweep below, one branch later, once the branch it names is gone.
251
251
 
252
- Then sweep the branch reports this session never opened. List `.claude/review/branch/review-*.md`, run the same slug transform over every name `git branch --format='%(refname:short)'` prints, and delete a report whose slug matches none of them. Take the names from that format rather than from `git branch --list`, which marks the current branch with `* ` and a branch checked out in another worktree with `+ `, so a transform reading the marked lines as written turns a live branch into a slug nothing matches and sweeps a report a sibling worktree is still working from. A branch report is read once, by the session addressing it, and the durable record of what a review found is the comment `claude-pr-review` posts on the pull request, so a report outliving its branch is holding nothing. Skipping this leaves them accumulating for the life of the checkout, since a slug is unique per feature and no later branch ever looks for one.
252
+ Sweep the branch reports this session never opened. List `.claude/review/branch/review-*.md`, run the slug transform in `${CLAUDE_SKILL_DIR}/../../standards/slug.md` over every name `git branch --format='%(refname:short)'` prints, and delete a report whose slug matches none of them. Take the names from that format rather than from `git branch --list`, which marks the current branch with `* ` and a branch checked out in another worktree with `+ `, so a transform reading the marked lines as written turns a live branch into a slug nothing matches and sweeps a report a sibling worktree is still working from. A branch report is read once, by the session addressing it, and the durable record of what a review found is the comment `claude-pr-review` posts on the pull request, so a report outliving its branch is holding nothing. Skipping this leaves them accumulating for the life of the checkout, since a slug is unique per feature and no later branch ever looks for one.
253
253
 
254
254
  What that removes is a local-only review on a branch deleted before it opened a pull request. `claude-review` says so where a reader meets the report, and the sweep runs anyway rather than keeping every report against the one case, since nothing else ever clears them.
255
255
 
256
- Memory receipts sweep board-wide, like plans above and unlike the current slug's review report. Scan every `.claude/review/memory/memory-review-*.md`, not only the one matching this slug. `claude-memory-review` writes its receipt after this skill has run in every ship chain, so a sweep keyed on the current slug looks for a file that does not exist yet, and no later branch looks for it either because a slug is unique per feature. Scanning the folder is what makes the sweep fire at all.
256
+ Memory receipts sweep board-wide, like both halves of this step above them. Scan every `.claude/review/memory/memory-review-*.md`, not only the one matching this slug. `claude-memory-review` writes its receipt after this skill has run in every ship chain, so a sweep keyed on the current slug looks for a file that does not exist yet, and no later branch looks for it either because a slug is unique per feature. Scanning the folder is what makes the sweep fire at all.
257
257
 
258
258
  For each receipt, count the H2 items still marked ๐Ÿ“ pending:
259
259
 
@@ -268,7 +268,6 @@ Output one line per file swept:
268
268
 
269
269
  - `๐Ÿ“ฆ Archived: <path>` for a plan moved into `.claude/plans/archive/`
270
270
  - `โญ Kept: <path>, still cited by <task-file>` for a plan another live task shares
271
- - `๐Ÿงน Deleted: <path>` for a swept branch report
272
271
  - `๐Ÿงน Deleted: <path>, branch gone` for a branch report whose branch no longer exists
273
272
  - `๐Ÿงน Deleted: <path>, folded <n> skips` for a swept memory receipt
274
273
  - `โญ Kept: <path>, <n> items pending` for a memory receipt still holding decisions
@@ -5,7 +5,12 @@
5
5
  # holds. This answers "what just changed" on a loop of its own, and it exists
6
6
  # because the two readings a dispatch needs are not one reading: a worker that
7
7
  # finishes goes idle and a worker that crashes vanishes, so a watch matching
8
- # only the pull request list stays silent through the second.
8
+ # only the pull request list stays silent through the second. A third case is
9
+ # neither: a worker that stops on a question or a prompt neither finishes nor
10
+ # crashes, and sits reading "waiting" beside the two statuses that resolve on
11
+ # their own until something reports it, which is the WORKER-STOPPED line
12
+ # below, or WORKER-UNMEASURABLE for a "waiting" row too old to carry a stamp
13
+ # this reads.
9
14
  #
10
15
  # `set -e` is deliberately not set. This runs for hours, and one transient `gh`
11
16
  # failure aborting the loop is a silent stop rather than a reported one. Every
@@ -17,6 +22,28 @@ set -uo pipefail
17
22
  # in a project whose workers run long.
18
23
  INTERVAL=60
19
24
 
25
+ # Seconds a worker can sit in "waiting" before this reports it as stopped
26
+ # rather than folding it into the ordinary status-change lines below. "busy"
27
+ # and "idle" resolve on their own; "waiting" does not, so a dwell that keeps
28
+ # growing there is a session blocked on something outside itself.
29
+ #
30
+ # Measured against the live session registry: 341 usable records carried a
31
+ # status at all, and exactly one carried "waiting", too sparse a sample to fit
32
+ # a distribution. The number is picked from the two bounds the measurement can
33
+ # still name rather than from a round guess: well above INTERVAL, so a handful
34
+ # of passes confirm the row before it reports rather than one slow tool call
35
+ # tripping it, and well inside the ten-to-thirty-minute span a dispatched build
36
+ # ordinarily runs, so a real stall is caught with most of that window still
37
+ # open to act on it.
38
+ #
39
+ # That one record carried `waitingFor: "approve Bash"`, a permission prompt
40
+ # rather than a question, and the two clear on different schedules: a prompt
41
+ # resolves the moment a person approves it, where a question can sit
42
+ # legitimately while a controller finishes a turn. This number is defensible
43
+ # for the second and generous for the first, and the gap stays open rather
44
+ # than splitting the constant on a sample of one.
45
+ STALL_THRESHOLD_S=300
46
+
20
47
  # Resolving the main worktree root rather than this file's folder keeps a watch
21
48
  # started from a linked worktree reading the same repository as one started from
22
49
  # main. The repository field on a roster row is that root's `.git`.
@@ -40,13 +67,19 @@ BASE_BRANCH="${BASE_REF#origin/}"
40
67
  # worker, whoever launched it. The prototype matched the `orchestrator-` prefix
41
68
  # instead, which reads a dispatched worker and misses every hand-launched one,
42
69
  # and those are the ordinary shape whenever the operator is launching.
70
+ #
71
+ # The row goes out tab-separated and comes back read with IFS set to a tab. A
72
+ # name this client actually writes carries spaces, `Update session markdown
73
+ # and check runnable commands (3)` among them, and splitting on whitespace
74
+ # took that one apart into a wrong name, branch, status, and dwell.
43
75
  read_workers() {
44
76
  aitk sessions list --json 2>/dev/null | tail -1 | jq -r \
45
77
  --arg repository "$REPOSITORY" --arg base "$BASE_BRANCH" '
46
78
  .sessions[]?
47
79
  | select(.repository == $repository)
48
80
  | select(.branch != null and .branch != $base)
49
- | "\(.name) \(.branch) \(.status)"
81
+ | [.name, .branch, .status, (.statusDwellMs // -1 | tostring)]
82
+ | @tsv
50
83
  ' 2>/dev/null | sort
51
84
  }
52
85
 
@@ -70,6 +103,15 @@ prev_names=""
70
103
  pulls_seen=0
71
104
  workers_seen=0
72
105
 
106
+ # One row per worker name currently reported, waiting past STALL_THRESHOLD_S
107
+ # or waiting with no stamp to measure, so a reader watching the log is told
108
+ # once rather than on every remaining pass. The prototype for that shape is
109
+ # the WORKER-GONE/WORKER pair above, tracked in the same state a status change
110
+ # is: a name drops out the pass it stops reading "waiting", which lets the
111
+ # same worker report a second stall later in its life rather than being
112
+ # marked forever by its first one.
113
+ declare -A stalled
114
+
73
115
  while true; do
74
116
  pulls="$(read_pulls)"
75
117
  pulls_read=$?
@@ -83,7 +125,7 @@ while true; do
83
125
  echo "watch: the session roster failed to load, so no worker is classified this pass"
84
126
  fi
85
127
 
86
- names="$(printf '%s\n' "$workers" | awk 'NF {print $1}' | sort -u)"
128
+ names="$(printf '%s\n' "$workers" | awk -F'\t' 'NF {print $1}' | sort -u)"
87
129
 
88
130
  if [ "$pulls_seen" -eq 1 ] && [ "$pulls_read" -eq 0 ]; then
89
131
  comm -13 <(printf '%s\n' "$prev_pulls") <(printf '%s\n' "$pulls") | grep . || true
@@ -96,6 +138,35 @@ while true; do
96
138
  grep . | sed 's/^/WORKER-GONE /' || true
97
139
  fi
98
140
 
141
+ if [ "$workers_read" -eq 0 ]; then
142
+ while IFS=$'\t' read -r w_name w_branch w_status w_dwell_ms; do
143
+ [ -z "$w_name" ] && continue
144
+
145
+ if [ "$w_status" != "waiting" ]; then
146
+ unset "stalled[$w_name]"
147
+ continue
148
+ fi
149
+
150
+ if [ "$w_dwell_ms" = "-1" ]; then
151
+ if [ -z "${stalled[$w_name]:-}" ]; then
152
+ echo "WORKER-UNMEASURABLE $w_name $w_branch"
153
+ stalled[$w_name]=1
154
+ fi
155
+ continue
156
+ fi
157
+
158
+ w_dwell_s=$((w_dwell_ms / 1000))
159
+ if [ "$w_dwell_s" -ge "$STALL_THRESHOLD_S" ]; then
160
+ if [ -z "${stalled[$w_name]:-}" ]; then
161
+ echo "WORKER-STOPPED $w_name $w_branch ${w_dwell_s}s"
162
+ stalled[$w_name]=1
163
+ fi
164
+ else
165
+ unset "stalled[$w_name]"
166
+ fi
167
+ done <<<"$workers"
168
+ fi
169
+
99
170
  if [ "$pulls_read" -eq 0 ]; then
100
171
  prev_pulls="$pulls"
101
172
  pulls_seen=1
@@ -128,7 +128,7 @@ If there are no findings, write `โœ… No findings.` to the file with a timestamp.
128
128
 
129
129
  The `.claude/review/` directory is gitignored. Do not stage or commit the file.
130
130
 
131
- The report is disposable. `claude-docs` deletes it at ship, and sweeps any branch report whose branch no longer exists, because the durable record of what a review found is the comment `claude-pr-review` posts on the pull request. A review run on a branch that never opens one leaves nothing behind once that branch is gone, so fold anything worth keeping into the pull request body or a task finding while the report is still on disk.
131
+ The report is disposable. It outlives the ship chain that reads it, and `claude-docs` sweeps it once the branch it names is gone, because the durable record of what a review found is the comment `claude-pr-review` posts on the pull request. A review run on a branch that never opens one leaves nothing behind once that branch is gone, so fold anything worth keeping into the pull request body or a task finding while the report is still on disk.
132
132
 
133
133
  ### Chat output
134
134
 
@@ -105,6 +105,14 @@ Every report states how liveness was decided, on a pass as well as a failure.
105
105
 
106
106
  The registry holds one record per session and is never pruned, so it accumulates thousands of entries. On the `unverified` path a stale record whose pid has been reused reads as live, which is why the field is reported rather than assumed.
107
107
 
108
+ ## The status dwell
109
+
110
+ Every row carries `statusUpdatedAt`, the stamp a client writes beside `status` at the moment it last changed, and `statusDwellMs`, the elapsed milliseconds since that stamp. Measured over the live registry, 23 of 341 usable records carry `statusUpdatedAt`, so `null` is the ordinary answer rather than an edge case, and the absence tracks a client version rather than a record's age alone: the one record ever measured carrying `status: "waiting"` is among the 318 without it.
111
+
112
+ `statusDwellMs` falls back to the coarser `updatedAt` stamp when `statusUpdatedAt` is absent, so it is `null` only where a record carries neither. `statusUpdatedAt` itself is never backfilled from the fallback and stays `null` in that case, since it names the exact stamp rather than an estimate. A stamp ahead of the reading clock clamps the dwell to zero rather than reporting a negative one.
113
+
114
+ The dwell is what separates a status that resolves on its own from one that does not. `busy` and `idle` transition without help, so a long dwell there is ordinary. `waiting` does not: a session in that state is blocked on something outside itself, and a dwell that keeps growing past the ordinary span of a prompt is a session stalled rather than paused. `aitk sessions list` renders the dwell beside the status at the coarsest unit that keeps it a whole number, and the JSON record carries both fields on every row.
115
+
108
116
  ## What the read depends on
109
117
 
110
118
  The records live under the Claude Code configuration directory, which the verb resolves from `CLAUDE_CONFIG_DIR` and falls back to `~/.claude`. Their location, their filenames, and the fields inside them are a client implementation detail rather than a published interface, so a client change can move them. The verb reports an absent registry as a refusal rather than as a machine running no sessions, which is what surfaces the move instead of burying it in an empty roster.
@@ -103,7 +103,7 @@ A person points it at a private repository once and both verbs refuse until they
103
103
 
104
104
  A plan that ships is archived, never deleted. `aitk:claude-docs` moves it to `.claude/plans/archive/` and retargets the task file's `Plan:` line at the new location, so a completed task still leads to the reasoning behind it. An archive sits inside the record folder it archives rather than beside it, so one ignore entry and one backed-folder entry cover a record and everything it has retired. The folder is gitignored, which is why a deleted plan had no recovery path. A plan cited by more than one task stays put until the last of them closes, since moving it early would strand every other pointer.
105
105
 
106
- A branch review report takes the other route and is swept rather than archived. `claude-review` writes it to `.claude/review/branch/`, the session addressing it reads it once, and the durable record of what a review found is the comment `claude-pr-review` posts on the pull request, so `claude-docs` deletes the report at ship and any report whose branch is gone. What that loses is a local-only review on a branch that never opened a pull request, which is why the report says so where a reader meets it.
106
+ A branch review report takes the other route and is swept rather than archived. `claude-review` writes it to `.claude/review/branch/`, the session addressing it reads it once, and the durable record of what a review found is the comment `claude-pr-review` posts on the pull request, so `claude-docs` deletes any report whose branch is gone. The body that writes a report owns how long it lives, which leaves the shipping branch's own report on disk through the run that cites it and collects it a branch later. What that loses is a local-only review on a branch that never opened a pull request, which is why the report says so where a reader meets it.
107
107
 
108
108
  The plans sweep reads the whole board rather than the tasks the session touched. It is the one place the skill reaches past its own rule against editing a task file the session did not change, because a task that closed while an earlier run missed its archive is exactly what the sweep exists to clear. Reaching it is safe: the archive moves the plan and retargets the pointer in the same pass, so an untouched task ends up with a working link rather than a broken one.
109
109
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.51.0",
4
+ "version": "3.52.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -124,6 +124,14 @@ export function register(program: Command): void {
124
124
  'cannot rule out a pid handed to an unrelated process, so a roster',
125
125
  'reported that way is a candidate list rather than an identity.',
126
126
  '',
127
+ 'The JSON record also carries "statusUpdatedAt" (the stamp the client',
128
+ 'wrote beside "status", or null where its record carries none) and',
129
+ '"statusDwellMs" (the elapsed milliseconds since that stamp, falling',
130
+ 'back to the coarser "updatedAt" where the narrower one is absent,',
131
+ 'computed at read time and clamped at zero against clock skew). The',
132
+ 'framed listing renders the same dwell beside the status, at the',
133
+ 'coarsest unit that keeps it a whole number.',
134
+ '',
127
135
  'Examples:',
128
136
  ' aitk sessions list',
129
137
  ' aitk sessions list --json',
@@ -349,12 +357,30 @@ function reportSessions(
349
357
  const held =
350
358
  session.branch ??
351
359
  `unresolved: ${REASONS[session.unresolved ?? ''] ?? 'unknown'}`
352
- return `${session.name} ${session.status} ${held}\n ${session.cwd}`
360
+ const dwell = formatDwell(session.statusDwellMs)
361
+ const status = dwell ? `${session.status} ${dwell}` : session.status
362
+ return `${session.name} ${status} ${held}\n ${session.cwd}`
353
363
  })
354
364
  .join('\n'),
355
365
  )
356
366
  }
357
367
 
368
+ /**
369
+ * Renders the dwell at the coarsest unit that keeps it a whole number, since a
370
+ * reader scanning a roster wants an age at a glance rather than a millisecond
371
+ * count. An absent dwell renders as nothing, folding a status carrying no
372
+ * stamp back to the bare status line the reader already knew.
373
+ */
374
+ function formatDwell(ms: number | null): string {
375
+ if (ms === null) return ''
376
+ const seconds = Math.round(ms / 1000)
377
+ if (seconds < 60) return `${seconds}s`
378
+ const minutes = Math.round(seconds / 60)
379
+ if (minutes < 60) return `${minutes}m`
380
+ const hours = Math.round(minutes / 60)
381
+ return `${hours}h`
382
+ }
383
+
358
384
  function reportClaim(claim: ClaimReport): void {
359
385
  logStep('Claim')
360
386
 
@@ -21,6 +21,23 @@ export interface SessionRecord {
21
21
  readonly sessionId: string | undefined
22
22
  readonly kind: string | undefined
23
23
  readonly status: string | undefined
24
+ /**
25
+ * The epoch millisecond the client last changed `status`. Measured against
26
+ * the live registry, 23 of 341 usable records carry it, and the one record
27
+ * that has ever carried `status: "waiting"` is not among them, so its
28
+ * absence tracks a client version rather than a record's age alone.
29
+ * Declared here rather than read opportunistically off the parsed object,
30
+ * since this file is what states what the domain reads and an undeclared
31
+ * field read anyway is the drift this domain exists downstream of.
32
+ */
33
+ readonly statusUpdatedAt: number | undefined
34
+ /**
35
+ * The epoch millisecond the client last wrote the record at all, a coarser
36
+ * stamp than `statusUpdatedAt` that a client writes whether or not it also
37
+ * stamps the status change itself. Declared as the fallback dwell source
38
+ * for a record predating the narrower field, per the same reasoning above.
39
+ */
40
+ readonly updatedAt: number | undefined
24
41
  readonly startedAt: number | undefined
25
42
  /**
26
43
  * The process start time the client stamped at launch, compared against the
@@ -23,6 +23,25 @@ export interface ResolvedSession {
23
23
  readonly kind: string
24
24
  readonly status: string
25
25
  readonly startedAt: string | null
26
+ /**
27
+ * The stamp the record carries beside `status`, kept alongside the dwell it
28
+ * computes below for a caller comparing two sessions rather than reading the
29
+ * age of one.
30
+ */
31
+ readonly statusUpdatedAt: string | null
32
+ /**
33
+ * Elapsed milliseconds since `statusUpdatedAt`, falling back to the coarser
34
+ * `updatedAt` when the record predates the narrower field, computed once at
35
+ * read time so three callers do not each convert the raw stamp and each get
36
+ * the clock-skew case wrong. The one record ever measured carrying
37
+ * `status: "waiting"` has exactly this shape: no `statusUpdatedAt`, an
38
+ * `updatedAt` beside it, so a caller reading this field alone would report
39
+ * the single real instance as unmeasured rather than as stalled.
40
+ * Clamped at zero rather than reported negative: a record stamped
41
+ * by a clock running ahead of this one is a skew to absorb, not a session
42
+ * that has not started waiting yet.
43
+ */
44
+ readonly statusDwellMs: number | null
26
45
  /**
27
46
  * The shared git directory every worktree of one repository resolves to,
28
47
  * which is what identifies the repository a row belongs to. A branch name is
@@ -52,6 +71,8 @@ export interface ResolveOptions {
52
71
  readonly dir?: string
53
72
  readonly probes?: LivenessProbes
54
73
  readonly locate?: (cwd: string) => Promise<Located>
74
+ /** The instant the dwell is computed against. Defaults to `Date.now`. */
75
+ readonly now?: () => number
55
76
  }
56
77
 
57
78
  export interface Located {
@@ -123,6 +144,17 @@ export async function repositoryOf(cwd: string): Promise<string | null> {
123
144
  return resolved.length > 0 ? resolved : null
124
145
  }
125
146
 
147
+ /**
148
+ * Falls back to `updatedAt` when `statusUpdatedAt` is absent, since the
149
+ * narrower field is the newer of the two and a record predating it still
150
+ * carries the coarser one. The one record measured with `status: "waiting"`
151
+ * takes exactly this path: no `statusUpdatedAt`, an `updatedAt` beside it.
152
+ */
153
+ function dwellMs(record: SessionRecord, now: number): number | null {
154
+ const stamp = record.statusUpdatedAt ?? record.updatedAt
155
+ return stamp === undefined ? null : Math.max(0, now - stamp)
156
+ }
157
+
126
158
  /**
127
159
  * An absent field is rendered as an absence rather than as a value.
128
160
  *
@@ -131,7 +163,11 @@ export async function repositoryOf(cwd: string): Promise<string | null> {
131
163
  * for data. Null says the record did not carry it, which is the same
132
164
  * distinction the registry draws between an absent folder and an empty one.
133
165
  */
134
- function present(record: SessionRecord, located: Located): ResolvedSession {
166
+ function present(
167
+ record: SessionRecord,
168
+ located: Located,
169
+ now: number,
170
+ ): ResolvedSession {
135
171
  return {
136
172
  name: record.name,
137
173
  pid: record.pid,
@@ -143,6 +179,11 @@ function present(record: SessionRecord, located: Located): ResolvedSession {
143
179
  record.startedAt === undefined
144
180
  ? null
145
181
  : new Date(record.startedAt).toISOString(),
182
+ statusUpdatedAt:
183
+ record.statusUpdatedAt === undefined
184
+ ? null
185
+ : new Date(record.statusUpdatedAt).toISOString(),
186
+ statusDwellMs: dwellMs(record, now),
146
187
  repository: located.repository,
147
188
  worktree: located.worktree,
148
189
  branch: located.branch,
@@ -167,6 +208,7 @@ export async function resolveSessions(
167
208
  ): Promise<SessionReport> {
168
209
  const probes = opts.probes ?? SYSTEM_PROBES
169
210
  const find = opts.locate ?? locate
211
+ const now = opts.now?.() ?? Date.now()
170
212
  const registry = readRegistry(opts.dir)
171
213
 
172
214
  if (registry.kind === 'absent') return { kind: 'absent', dir: registry.dir }
@@ -182,7 +224,7 @@ export async function resolveSessions(
182
224
  }
183
225
 
184
226
  const sessions = await Promise.all(
185
- live.map(async (record) => present(record, await find(record.cwd))),
227
+ live.map(async (record) => present(record, await find(record.cwd), now)),
186
228
  )
187
229
 
188
230
  return { kind: 'resolved', dir: registry.dir, confidence, sessions }