@erclx/aitk
Advanced tools
| { | ||
| "name": "aitk", | ||
| "description": "Automated governance, versioning, and discovery tools for Claude Code.", | ||
| "version": "3.51.1", | ||
| "version": "3.52.0", | ||
| "author": { | ||
@@ -6,0 +6,0 @@ "name": "Eric Le", |
@@ -8,3 +8,8 @@ #!/usr/bin/env bash | ||
| # finishes goes idle and a worker that crashes vanishes, so a watch matching | ||
| # only the pull request list stays silent through the second. | ||
| # only the pull request list stays silent through the second. A third case is | ||
| # neither: a worker that stops on a question or a prompt neither finishes nor | ||
| # crashes, and sits reading "waiting" beside the two statuses that resolve on | ||
| # their own until something reports it, which is the WORKER-STOPPED line | ||
| # below, or WORKER-UNMEASURABLE for a "waiting" row too old to carry a stamp | ||
| # this reads. | ||
| # | ||
@@ -21,2 +26,24 @@ # `set -e` is deliberately not set. This runs for hours, and one transient `gh` | ||
| # Seconds a worker can sit in "waiting" before this reports it as stopped | ||
| # rather than folding it into the ordinary status-change lines below. "busy" | ||
| # and "idle" resolve on their own; "waiting" does not, so a dwell that keeps | ||
| # growing there is a session blocked on something outside itself. | ||
| # | ||
| # Measured against the live session registry: 341 usable records carried a | ||
| # status at all, and exactly one carried "waiting", too sparse a sample to fit | ||
| # a distribution. The number is picked from the two bounds the measurement can | ||
| # still name rather than from a round guess: well above INTERVAL, so a handful | ||
| # of passes confirm the row before it reports rather than one slow tool call | ||
| # tripping it, and well inside the ten-to-thirty-minute span a dispatched build | ||
| # ordinarily runs, so a real stall is caught with most of that window still | ||
| # open to act on it. | ||
| # | ||
| # That one record carried `waitingFor: "approve Bash"`, a permission prompt | ||
| # rather than a question, and the two clear on different schedules: a prompt | ||
| # resolves the moment a person approves it, where a question can sit | ||
| # legitimately while a controller finishes a turn. This number is defensible | ||
| # for the second and generous for the first, and the gap stays open rather | ||
| # than splitting the constant on a sample of one. | ||
| STALL_THRESHOLD_S=300 | ||
| # Resolving the main worktree root rather than this file's folder keeps a watch | ||
@@ -45,2 +72,7 @@ # started from a linked worktree reading the same repository as one started from | ||
| # and those are the ordinary shape whenever the operator is launching. | ||
| # | ||
| # The row goes out tab-separated and comes back read with IFS set to a tab. A | ||
| # name this client actually writes carries spaces, `Update session markdown | ||
| # and check runnable commands (3)` among them, and splitting on whitespace | ||
| # took that one apart into a wrong name, branch, status, and dwell. | ||
| read_workers() { | ||
@@ -52,3 +84,4 @@ aitk sessions list --json 2>/dev/null | tail -1 | jq -r \ | ||
| | select(.branch != null and .branch != $base) | ||
| | "\(.name) \(.branch) \(.status)" | ||
| | [.name, .branch, .status, (.statusDwellMs // -1 | tostring)] | ||
| | @tsv | ||
| ' 2>/dev/null | sort | ||
@@ -77,2 +110,11 @@ } | ||
| # One row per worker name currently reported, waiting past STALL_THRESHOLD_S | ||
| # or waiting with no stamp to measure, so a reader watching the log is told | ||
| # once rather than on every remaining pass. The prototype for that shape is | ||
| # the WORKER-GONE/WORKER pair above, tracked in the same state a status change | ||
| # is: a name drops out the pass it stops reading "waiting", which lets the | ||
| # same worker report a second stall later in its life rather than being | ||
| # marked forever by its first one. | ||
| declare -A stalled | ||
| while true; do | ||
@@ -91,3 +133,3 @@ pulls="$(read_pulls)" | ||
| names="$(printf '%s\n' "$workers" | awk 'NF {print $1}' | sort -u)" | ||
| names="$(printf '%s\n' "$workers" | awk -F'\t' 'NF {print $1}' | sort -u)" | ||
@@ -105,2 +147,31 @@ if [ "$pulls_seen" -eq 1 ] && [ "$pulls_read" -eq 0 ]; then | ||
| if [ "$workers_read" -eq 0 ]; then | ||
| while IFS=$'\t' read -r w_name w_branch w_status w_dwell_ms; do | ||
| [ -z "$w_name" ] && continue | ||
| if [ "$w_status" != "waiting" ]; then | ||
| unset "stalled[$w_name]" | ||
| continue | ||
| fi | ||
| if [ "$w_dwell_ms" = "-1" ]; then | ||
| if [ -z "${stalled[$w_name]:-}" ]; then | ||
| echo "WORKER-UNMEASURABLE $w_name $w_branch" | ||
| stalled[$w_name]=1 | ||
| fi | ||
| continue | ||
| fi | ||
| w_dwell_s=$((w_dwell_ms / 1000)) | ||
| if [ "$w_dwell_s" -ge "$STALL_THRESHOLD_S" ]; then | ||
| if [ -z "${stalled[$w_name]:-}" ]; then | ||
| echo "WORKER-STOPPED $w_name $w_branch ${w_dwell_s}s" | ||
| stalled[$w_name]=1 | ||
| fi | ||
| else | ||
| unset "stalled[$w_name]" | ||
| fi | ||
| done <<<"$workers" | ||
| fi | ||
| if [ "$pulls_read" -eq 0 ]; then | ||
@@ -107,0 +178,0 @@ prev_pulls="$pulls" |
@@ -108,2 +108,10 @@ --- | ||
| ## The status dwell | ||
| 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. | ||
| `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. | ||
| 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. | ||
| ## What the read depends on | ||
@@ -110,0 +118,0 @@ |
+1
-1
| { | ||
| "name": "@erclx/aitk", | ||
| "type": "module", | ||
| "version": "3.51.1", | ||
| "version": "3.52.0", | ||
| "description": "Infrastructure and quality tooling for developer workflows", | ||
@@ -6,0 +6,0 @@ "license": "MIT", |
@@ -127,2 +127,10 @@ import { resolve } from 'node:path' | ||
| '', | ||
| 'The JSON record also carries "statusUpdatedAt" (the stamp the client', | ||
| 'wrote beside "status", or null where its record carries none) and', | ||
| '"statusDwellMs" (the elapsed milliseconds since that stamp, falling', | ||
| 'back to the coarser "updatedAt" where the narrower one is absent,', | ||
| 'computed at read time and clamped at zero against clock skew). The', | ||
| 'framed listing renders the same dwell beside the status, at the', | ||
| 'coarsest unit that keeps it a whole number.', | ||
| '', | ||
| 'Examples:', | ||
@@ -353,3 +361,5 @@ ' aitk sessions list', | ||
| `unresolved: ${REASONS[session.unresolved ?? ''] ?? 'unknown'}` | ||
| return `${session.name} ${session.status} ${held}\n ${session.cwd}` | ||
| const dwell = formatDwell(session.statusDwellMs) | ||
| const status = dwell ? `${session.status} ${dwell}` : session.status | ||
| return `${session.name} ${status} ${held}\n ${session.cwd}` | ||
| }) | ||
@@ -360,2 +370,18 @@ .join('\n'), | ||
| /** | ||
| * Renders the dwell at the coarsest unit that keeps it a whole number, since a | ||
| * reader scanning a roster wants an age at a glance rather than a millisecond | ||
| * count. An absent dwell renders as nothing, folding a status carrying no | ||
| * stamp back to the bare status line the reader already knew. | ||
| */ | ||
| function formatDwell(ms: number | null): string { | ||
| if (ms === null) return '' | ||
| const seconds = Math.round(ms / 1000) | ||
| if (seconds < 60) return `${seconds}s` | ||
| const minutes = Math.round(seconds / 60) | ||
| if (minutes < 60) return `${minutes}m` | ||
| const hours = Math.round(minutes / 60) | ||
| return `${hours}h` | ||
| } | ||
| function reportClaim(claim: ClaimReport): void { | ||
@@ -362,0 +388,0 @@ logStep('Claim') |
@@ -24,2 +24,19 @@ import { readdirSync, readFileSync } from 'node:fs' | ||
| readonly status: string | undefined | ||
| /** | ||
| * The epoch millisecond the client last changed `status`. Measured against | ||
| * the live registry, 23 of 341 usable records carry it, and the one record | ||
| * that has ever carried `status: "waiting"` is not among them, so its | ||
| * absence tracks a client version rather than a record's age alone. | ||
| * Declared here rather than read opportunistically off the parsed object, | ||
| * since this file is what states what the domain reads and an undeclared | ||
| * field read anyway is the drift this domain exists downstream of. | ||
| */ | ||
| readonly statusUpdatedAt: number | undefined | ||
| /** | ||
| * The epoch millisecond the client last wrote the record at all, a coarser | ||
| * stamp than `statusUpdatedAt` that a client writes whether or not it also | ||
| * stamps the status change itself. Declared as the fallback dwell source | ||
| * for a record predating the narrower field, per the same reasoning above. | ||
| */ | ||
| readonly updatedAt: number | undefined | ||
| readonly startedAt: number | undefined | ||
@@ -26,0 +43,0 @@ /** |
@@ -27,2 +27,21 @@ import { basename } from 'node:path' | ||
| /** | ||
| * The stamp the record carries beside `status`, kept alongside the dwell it | ||
| * computes below for a caller comparing two sessions rather than reading the | ||
| * age of one. | ||
| */ | ||
| readonly statusUpdatedAt: string | null | ||
| /** | ||
| * Elapsed milliseconds since `statusUpdatedAt`, falling back to the coarser | ||
| * `updatedAt` when the record predates the narrower field, computed once at | ||
| * read time so three callers do not each convert the raw stamp and each get | ||
| * the clock-skew case wrong. The one record ever measured carrying | ||
| * `status: "waiting"` has exactly this shape: no `statusUpdatedAt`, an | ||
| * `updatedAt` beside it, so a caller reading this field alone would report | ||
| * the single real instance as unmeasured rather than as stalled. | ||
| * Clamped at zero rather than reported negative: a record stamped | ||
| * by a clock running ahead of this one is a skew to absorb, not a session | ||
| * that has not started waiting yet. | ||
| */ | ||
| readonly statusDwellMs: number | null | ||
| /** | ||
| * The shared git directory every worktree of one repository resolves to, | ||
@@ -56,2 +75,4 @@ * which is what identifies the repository a row belongs to. A branch name is | ||
| readonly locate?: (cwd: string) => Promise<Located> | ||
| /** The instant the dwell is computed against. Defaults to `Date.now`. */ | ||
| readonly now?: () => number | ||
| } | ||
@@ -129,2 +150,13 @@ | ||
| /** | ||
| * Falls back to `updatedAt` when `statusUpdatedAt` is absent, since the | ||
| * narrower field is the newer of the two and a record predating it still | ||
| * carries the coarser one. The one record measured with `status: "waiting"` | ||
| * takes exactly this path: no `statusUpdatedAt`, an `updatedAt` beside it. | ||
| */ | ||
| function dwellMs(record: SessionRecord, now: number): number | null { | ||
| const stamp = record.statusUpdatedAt ?? record.updatedAt | ||
| return stamp === undefined ? null : Math.max(0, now - stamp) | ||
| } | ||
| /** | ||
| * An absent field is rendered as an absence rather than as a value. | ||
@@ -137,3 +169,7 @@ * | ||
| */ | ||
| function present(record: SessionRecord, located: Located): ResolvedSession { | ||
| function present( | ||
| record: SessionRecord, | ||
| located: Located, | ||
| now: number, | ||
| ): ResolvedSession { | ||
| return { | ||
@@ -150,2 +186,7 @@ name: record.name, | ||
| : new Date(record.startedAt).toISOString(), | ||
| statusUpdatedAt: | ||
| record.statusUpdatedAt === undefined | ||
| ? null | ||
| : new Date(record.statusUpdatedAt).toISOString(), | ||
| statusDwellMs: dwellMs(record, now), | ||
| repository: located.repository, | ||
@@ -175,2 +216,3 @@ worktree: located.worktree, | ||
| const find = opts.locate ?? locate | ||
| const now = opts.now?.() ?? Date.now() | ||
| const registry = readRegistry(opts.dir) | ||
@@ -191,3 +233,3 @@ | ||
| const sessions = await Promise.all( | ||
| live.map(async (record) => present(record, await find(record.cwd))), | ||
| live.map(async (record) => present(record, await find(record.cwd), now)), | ||
| ) | ||
@@ -194,0 +236,0 @@ |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
3160139
0.28%37994
0.22%