lib/stats/claimed-order.mjs reads <coord>/<mailbox>/orders/claimed/ the way repo-mailbox's claim verb writes it (read, never called): the stem is the id, the .claim sidecar gives claimed-at, every .md counts toward "several". Mailboxes come from two independent git calls (toplevel basename, the claim script's rule; and the main repo behind a linked worktree), with GIT_DIR / GIT_WORK_TREE dropped and a 5 s timeout. A project_dir outside cwd cannot steer the lookup. Never throws, never guesses, starts nothing. Valgt: identity = file stem, not frontmatter, fordi the claim script moves <id>.md and never reads the frontmatter; a quoted order-id would otherwise never join. claimed-order.test.mjs 20/20. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
126 lines
6 KiB
JavaScript
126 lines
6 KiB
JavaScript
// lib/stats/claimed-order.mjs
|
|
// Which coord-queue order is this Voyage run working on? Read from the queue
|
|
// itself, never typed by the model and never guessed.
|
|
//
|
|
// Why this exists: the yardstick's `use` number joins a new-work order to a
|
|
// Voyage run. A key declared on the order before any run (a `slug:`) is not
|
|
// proof a run happened, and no order carried one. The run itself has to say
|
|
// which order it ran, so /trekbrief's stats record carries the id this module
|
|
// finds (lib/stats/stats-append.mjs adds it; the model never copies it).
|
|
//
|
|
// The contract it mirrors is repo-mailbox's claim verb (coord-order-claim.sh,
|
|
// 0.37.0), read here, never called:
|
|
// - coord root ${CLAUDE_COORD_DIR:-$HOME/.claude/coord}
|
|
// - mailbox basename of `git rev-parse --show-toplevel`; `_*` is reserved
|
|
// - claim `mv orders/<id>.md orders/claimed/<id>.md`, so the id is the
|
|
// file stem, plus a best-effort sidecar `claimed/<id>.claim`
|
|
// holding `claimed-at: <ISO>`
|
|
// - close coord-order-done.sh deletes the `.claim`, so the claim time
|
|
// survives only in the record this module feeds.
|
|
// A worktree's toplevel basename is the worktree's name, so the main repo's
|
|
// name (the parent of the common git dir) is searched too.
|
|
//
|
|
// Exactly one claimed order -> { order_id, order_claimed_at, order_reason: null }.
|
|
// Anything else -> order_id null (or the id with `no-claim-time`) and a reason:
|
|
// mailbox-unresolved · coord-missing · coord-unreadable · none-claimed ·
|
|
// several-claimed:N · to-mismatch · no-claim-time. It only reads, never throws,
|
|
// and starts nothing. Zero npm dependencies.
|
|
|
|
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
import { homedir } from 'node:os';
|
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
import { execFileSync } from 'node:child_process';
|
|
|
|
const GIT_TIMEOUT_MS = 5000;
|
|
const unattributed = (order_reason) => ({ order_id: null, order_claimed_at: null, order_reason });
|
|
|
|
/** The coord root the claim script uses: CLAUDE_COORD_DIR, else $HOME/.claude/coord. */
|
|
export function resolveCoordDir(env = process.env) {
|
|
if (env.CLAUDE_COORD_DIR) return env.CLAUDE_COORD_DIR;
|
|
const home = env.HOME && env.HOME.length > 0 ? env.HOME : homedir();
|
|
return join(home, '.claude', 'coord');
|
|
}
|
|
|
|
function gitLine(dir, args, env) {
|
|
try {
|
|
return execFileSync('git', ['-C', dir, 'rev-parse', ...args], {
|
|
encoding: 'utf8', timeout: GIT_TIMEOUT_MS, env, stdio: ['ignore', 'pipe', 'ignore'],
|
|
}).trim();
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The mailbox names `dir` can be filed under: the toplevel's basename (the
|
|
* claim script's rule) and, in a linked worktree, the main repo's name. Two
|
|
* independent git calls, so one failing never loses the other. An inherited
|
|
* GIT_DIR / GIT_WORK_TREE would point git at another repo, so both are dropped.
|
|
*/
|
|
export function resolveMailboxes(dir, { env = {} } = {}) {
|
|
const childEnv = { ...process.env, ...env };
|
|
for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE']) delete childEnv[key];
|
|
const names = [];
|
|
const top = gitLine(dir, ['--show-toplevel'], childEnv);
|
|
if (top) names.push(basename(top));
|
|
const common = gitLine(dir, ['--path-format=absolute', '--git-common-dir'], childEnv);
|
|
// Only `<repo>/.git` names a repo; a submodule's `.git/modules/<x>` does not.
|
|
if (common && basename(common) === '.git') names.push(basename(dirname(common)));
|
|
return [...new Set(names)];
|
|
}
|
|
|
|
const isDir = (p) => existsSync(p) && statSync(p).isDirectory();
|
|
|
|
/** The one claimed order across `mailboxes`, or null and the reason it is not one. */
|
|
export function findClaimedOrder({ coordDir, mailboxes }) {
|
|
try {
|
|
if (!isDir(coordDir)) return unattributed('coord-missing');
|
|
const names = (mailboxes ?? []).filter((m) => typeof m === 'string' && m !== '' && !m.startsWith('_'));
|
|
if (names.length === 0) return unattributed('mailbox-unresolved');
|
|
const found = [];
|
|
for (const mailbox of names) {
|
|
const dir = join(coordDir, mailbox, 'orders', 'claimed');
|
|
if (!isDir(dir)) continue;
|
|
for (const name of readdirSync(dir).sort()) {
|
|
if (name.endsWith('.md')) found.push({ mailbox, dir, id: name.slice(0, -3) });
|
|
}
|
|
}
|
|
if (found.length === 0) return unattributed('none-claimed');
|
|
if (found.length > 1) return unattributed(`several-claimed:${found.length}`);
|
|
const [{ mailbox, dir, id }] = found;
|
|
const text = readFileSync(join(dir, `${id}.md`), 'utf8');
|
|
const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? '';
|
|
const to = fm.match(/^to:[ \t]*(.*?)[ \t]*$/m)?.[1];
|
|
if (to !== undefined && to !== mailbox) return unattributed('to-mismatch');
|
|
const claimPath = join(dir, `${id}.claim`);
|
|
const at = existsSync(claimPath)
|
|
? readFileSync(claimPath, 'utf8').match(/^claimed-at:[ \t]*(\S+)[ \t]*$/m)?.[1]
|
|
: undefined;
|
|
if (!at) return { order_id: id, order_claimed_at: null, order_reason: 'no-claim-time' };
|
|
return { order_id: id, order_claimed_at: at, order_reason: null };
|
|
} catch {
|
|
return unattributed('coord-unreadable');
|
|
}
|
|
}
|
|
|
|
// The directory to resolve the mailbox from: `projectDir` when it is a directory
|
|
// inside `cwd` (a brief's project dir), else `cwd`. A path that walks out of cwd
|
|
// cannot move the lookup to another repo.
|
|
function baseDir(cwd, projectDir) {
|
|
if (typeof projectDir !== 'string' || projectDir === '') return cwd;
|
|
try {
|
|
const abs = resolve(cwd, projectDir);
|
|
if (!isDir(abs)) return cwd;
|
|
const rel = relative(realpathSync(cwd), realpathSync(abs));
|
|
return rel.startsWith('..') || isAbsolute(rel) ? cwd : abs;
|
|
} catch {
|
|
return cwd;
|
|
}
|
|
}
|
|
|
|
/** The attribution a /trekbrief record carries: always the three keys. */
|
|
export function attributeOrder({ cwd = process.cwd(), env = process.env, projectDir } = {}) {
|
|
const mailboxes = resolveMailboxes(baseDir(cwd, projectDir), { env });
|
|
if (mailboxes.length === 0) return unattributed('mailbox-unresolved');
|
|
return findClaimedOrder({ coordDir: resolveCoordDir(env), mailboxes });
|
|
}
|