@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-autoship/REQUIREMENT.md +1 -0
- package/claude/skills/claude-autoship/SKILL.md +2 -0
- package/claude/skills/claude-docs/REQUIREMENT.md +2 -1
- package/claude/skills/claude-docs/SKILL.md +4 -5
- package/claude/skills/claude-orchestrate/scripts/watch.sh +74 -3
- package/claude/skills/claude-review/SKILL.md +1 -1
- package/docs/agents/sessions.md +8 -0
- package/docs/ai-workflow.md +1 -1
- package/package.json +1 -1
- package/src/commands/sessions.ts +27 -1
- package/src/sessions/registry.ts +17 -0
- package/src/sessions/resolve.ts +44 -2
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
|
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.
|
|
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
|
|
package/docs/agents/sessions.md
CHANGED
|
@@ -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.
|
package/docs/ai-workflow.md
CHANGED
|
@@ -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
|
|
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
package/src/commands/sessions.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/sessions/registry.ts
CHANGED
|
@@ -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
|
package/src/sessions/resolve.ts
CHANGED
|
@@ -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(
|
|
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 }
|