@retasc/cli 1.50.0 → 1.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/CHANGELOG.md CHANGED
@@ -6,6 +6,28 @@ release commits and the issues they reference.
6
6
 
7
7
  Dates are the npm publish date. Each entry names the RTSC issue behind it.
8
8
 
9
+ ## 1.52.0 (2026-09-14)
10
+
11
+ - TODO: describe this release, and name the RTSC issue behind it.
12
+
13
+ ## 1.51.0 (2026-09-13)
14
+
15
+ - **RTSC-962** — the proxy now reports git worktrees nobody claimed, so Retasc can see
16
+ work an agent started without saying so. Retasc fenced the END of work and nothing at
17
+ the START: `done` rejects on a claim mismatch, `review` demands a handoff, but the
18
+ server never sees an agent edit a file — so an agent could build thousands of lines
19
+ against an issue the queue still showed as free, and a second agent would read it as
20
+ untouched and duplicate or decompose it.
21
+ The server still reads no git. This process does: a `rtsc-NN/<slug>` worktree with no
22
+ live claim behind it is reported, and the issue gets a **provisional hold** — visible
23
+ on `get_issue`, withheld from `next_issue` / `next_batch`, lapsing after an hour.
24
+ A hold is not a lease and never refuses an explicit `claim_issue`, so the agent that
25
+ forgot to claim always gets its own work back.
26
+ Only ACTIVE worktrees count (uncommitted changes, or a commit in the last day), so an
27
+ abandoned directory cannot park an issue; git runs asynchronously and off the critical
28
+ path, so the census never delays a tool call; and each unclaimed worktree is announced
29
+ once, not on every call. A server without the tool simply produces no holds.
30
+
9
31
  ## 1.50.0 (2026-09-11)
10
32
 
11
33
  - **RTSC-901** — `retasc plan`: which subscription pays for each of your agents, listed
@@ -513,7 +513,6 @@ export async function completeWorkspaceSetup(args) {
513
513
  : (await api.mintKey({
514
514
  orgId,
515
515
  projectId: projectId,
516
- agentName: opts.agent,
517
516
  runtime: opts.runtime ?? "claude-code",
518
517
  // RTSC-859 — bind is the folder door, so this is a workspace key, and the row
519
518
  // says so rather than leaving it to the server's default.
@@ -4,8 +4,10 @@ import { ask, isInteractive, select } from "../lib/prompt.js";
4
4
  // for that: an agent name or a plan label carrying \x1b[ can move the cursor, clear
5
5
  // the line and rewrite the rows above it. That matters most HERE, because this
6
6
  // command prints a list and then asks a question the reader answers by number, and
7
- // `mintKey` stores `agentName` and `runtime` verbatim — so any member of the org can
8
- // choose what lands on an owner's terminal.
7
+ // `mintKey` stores `runtime` verbatim — so any member of the org can choose what lands
8
+ // on an owner's terminal. Since RTSC-958 the NAME is no longer one of those values: it
9
+ // is generated server-side. `clean` still covers it, because every name typed before
10
+ // that change is still stored, and `runtime` is caller-supplied either way.
9
11
  import { clean } from "../lib/text.js";
10
12
  // Nothing about plans is compiled in here — not the list, and not the two terminal
11
13
  // answers either. `cli/src/lib/outcome.ts` records why the CLI cannot import
package/dist/index.js CHANGED
@@ -146,7 +146,6 @@ program
146
146
  .option("--org-id <id>", "Use an existing org id instead of creating one")
147
147
  .requiredOption("--project <name>", "Project name")
148
148
  .requiredOption("--prefix <PREFIX>", "Project prefix, e.g. XEN")
149
- .option("--agent <name>", "Agent member name (default: auto, \"{you}'s {runtime}\")")
150
149
  .option("--runtime <runtime>", "Label for this agent in the Dash (claude-code | codex | grok | …). Does NOT choose where MCP config is written — `retasc setup` wires every harness on the machine.", "claude-code")
151
150
  .option("-y, --yes", "Don't prompt to confirm replacing an existing binding")
152
151
  .option("--no-install", "Don't install `retasc` on this machine; wire the pinned npx launcher instead")
@@ -190,7 +189,6 @@ program
190
189
  opts: {
191
190
  project: opts.project,
192
191
  prefix: opts.prefix,
193
- agent: opts.agent,
194
192
  runtime: opts.runtime,
195
193
  install: opts.install,
196
194
  yes: opts.yes,
@@ -214,7 +212,6 @@ program
214
212
  .option("--project-id <id>", "Use an existing project id")
215
213
  .option("--project <name>", "Create a new project with this name (with --prefix)")
216
214
  .option("--prefix <PREFIX>", "Prefix for a new project, e.g. ACME")
217
- .option("--agent <name>", "Agent member name (default: auto)")
218
215
  .option("--runtime <runtime>", "Label for this agent in the Dash (claude-code | codex | grok | …). Does NOT choose where MCP config is written — `retasc setup` wires every harness on the machine.", "claude-code")
219
216
  .option("-y, --yes", "Don't prompt to confirm replacing an existing binding")
220
217
  // RTSC-523 — decline the global install without a TTY. `bind`/`join` ask when a human
@@ -371,7 +368,6 @@ key
371
368
  .command("mint")
372
369
  .requiredOption("--org-id <id>")
373
370
  .requiredOption("--project-id <id>")
374
- .option("--agent <name>", "Agent member name (default: auto, \"{you}'s {runtime}\")")
375
371
  .option("--runtime <runtime>", "Label for this agent in the Dash (claude-code | codex | grok | …). Does NOT choose where MCP config is written — `retasc setup` wires every harness on the machine.", "claude-code")
376
372
  .option("--name <label>", "Key label")
377
373
  // RTSC-859 — say at mint that this key will never have a local runner. Without it
@@ -394,7 +390,6 @@ key
394
390
  const res = (await api.mintKey({
395
391
  orgId: opts.orgId,
396
392
  projectId: opts.projectId,
397
- agentName: opts.agent,
398
393
  runtime: opts.runtime,
399
394
  keyName: opts.name,
400
395
  // RTSC-859 — said only when the caller actually said something. `--hosted` is
@@ -553,7 +548,6 @@ program
553
548
  .argument("<link>", "The invite link you were given (or just the rtscinv_… code)")
554
549
  .option("--no-bind", "Redeem only — don't set this folder up")
555
550
  .option("--project-id <id>", "Which project to bind to (skips the picker)")
556
- .option("--agent <name>", "Agent member name (default: auto)")
557
551
  .option("--runtime <runtime>", "Label for this agent in the Dash (claude-code | codex | grok | …). Does NOT choose where MCP config is written — `retasc setup` wires every harness on the machine.", "claude-code")
558
552
  // RTSC-477 — name the way back. `--yes` skips the identity question and must never answer
559
553
  // it, so the flag that causes the gap is the right place to say how to close it.
@@ -0,0 +1,172 @@
1
+ // RTSC-962 — the worktree census, as a PURE decision.
2
+ //
3
+ // The proxy watches for git worktrees on `rtsc-NN/<slug>` branches that no claim this
4
+ // session took covers, and reports them so the server can record a provisional hold.
5
+ // That closes the one transition Retasc could never see: an agent starting work. (The
6
+ // end of work is fenced — `done` rejects on CLAIM_MISMATCH — and the start was not, so
7
+ // an agent could build ~2,000 lines against an issue the queue showed as free. RTSC-910.)
8
+ //
9
+ // Everything decidable without I/O lives here, so every branch is unit-testable: which
10
+ // trees count, which are ignored, and what the payload looks like. The proxy supplies
11
+ // the three facts only the filesystem knows (the porcelain output, whether a tree is
12
+ // dirty, its last commit time) and does nothing else.
13
+ //
14
+ // REPORT-ONLY, in both directions. Nothing here claims, blocks, or rewrites an agent's
15
+ // request; and a report that fails is swallowed by the caller, never surfaced as a
16
+ // failure of the tool call it rode alongside.
17
+ import { issueIdFromBranch, parseWorktreePorcelain } from "./tidy.js";
18
+ /**
19
+ * How recently a worktree must have been touched to count as ACTIVE.
20
+ *
21
+ * This is what stops an abandoned worktree parking an issue. A tree nobody has
22
+ * committed in for a day and that has no uncommitted work in it is finished business —
23
+ * somebody forgot to run `retasc tidy` — and reporting it would withhold an issue from
24
+ * dispatch on the strength of a directory. A day is deliberately generous: a real agent
25
+ * working across a weekend has uncommitted changes, which satisfies the other arm.
26
+ */
27
+ export const ACTIVE_WINDOW_MS = 24 * 60 * 60 * 1000;
28
+ /**
29
+ * Is this tree worth reporting? Dirty OR committed within the window.
30
+ *
31
+ * The two arms cover the two shapes real in-flight work takes: edits not yet committed
32
+ * (the RTSC-910 case, for most of its life) and commits not yet merged.
33
+ */
34
+ export function isActive(state, now) {
35
+ if (state.dirty)
36
+ return true;
37
+ return state.lastCommitAt !== undefined && now - state.lastCommitAt < ACTIVE_WINDOW_MS;
38
+ }
39
+ /**
40
+ * The `rtsc-NN/<slug>` worktrees in this porcelain output, as {path, branch} pairs.
41
+ *
42
+ * Detached trees are dropped (`branch` is null — there is no issue to name) and so is
43
+ * any branch off the convention. The prefix match is `issueIdFromBranch`, the SAME
44
+ * parser `retasc tidy` uses to decide what it may delete and the mirror of the server's
45
+ * `branchForIssue` — so a branch this reports a hold for is exactly a branch a claim
46
+ * would have handed out.
47
+ *
48
+ * The MAIN checkout is included when it is itself on such a branch: an agent working in
49
+ * the shared checkout (the thing the claim contract tells it not to do) still leaves a
50
+ * branch to find, and that is the case most worth catching.
51
+ */
52
+ export function candidateTrees(porcelain) {
53
+ const out = [];
54
+ for (const w of parseWorktreePorcelain(porcelain)) {
55
+ if (!w.branch)
56
+ continue; // detached, or bare
57
+ const identifier = issueIdFromBranch(w.branch);
58
+ if (!identifier)
59
+ continue;
60
+ out.push({ path: w.path, branch: w.branch, identifier });
61
+ }
62
+ return out;
63
+ }
64
+ /**
65
+ * The payload to send, from the candidate trees plus what git said about each.
66
+ *
67
+ * Two exclusions, both deliberate:
68
+ * - INACTIVE trees (see `isActive`) — an abandoned directory must not park an issue.
69
+ * - Issues THIS session already holds a lease on. It claimed them; the server knows;
70
+ * a hold would be a worse copy of a fact it already has, and `report_worktrees`
71
+ * would ignore it anyway. Skipping here keeps the payload honest rather than
72
+ * relying on the server to discard most of it.
73
+ *
74
+ * Returns [] when there is nothing to say, which the caller uses to skip the call
75
+ * entirely — a proxy in a repo with no agent worktrees makes no requests at all.
76
+ */
77
+ export function buildReport(opts) {
78
+ const heldSet = new Set(opts.held);
79
+ const reports = [];
80
+ const seen = new Set();
81
+ for (const t of opts.trees) {
82
+ if (heldSet.has(t.identifier))
83
+ continue; // we claimed it — nothing to report
84
+ if (seen.has(t.identifier))
85
+ continue; // one hold per issue, whatever the tree count
86
+ const st = opts.state.get(t.path);
87
+ if (!st)
88
+ continue; // git couldn't read the tree — say nothing rather than guess
89
+ if (!isActive(st, opts.now))
90
+ continue;
91
+ seen.add(t.identifier);
92
+ reports.push({
93
+ identifier: t.identifier,
94
+ branch: t.branch,
95
+ dirty: st.dirty,
96
+ ...(st.lastCommitAt !== undefined ? { lastCommitAt: st.lastCommitAt } : {}),
97
+ });
98
+ }
99
+ return reports;
100
+ }
101
+ /**
102
+ * Which holds are worth saying out loud right now, and the bookkeeping that keeps it to
103
+ * ONCE PER HOLD — mutating `announced` in place.
104
+ *
105
+ * Two rules, and the first one is the whole point:
106
+ *
107
+ * - ANNOUNCE EACH HOLD ONCE. The instinct is to repeat the line until the agent acts,
108
+ * and it is wrong: it is the failure the server-side riders are written against
109
+ * (RTSC-463 — "a paragraph repeated on every claim becomes wallpaper"). A hold stands
110
+ * for an hour, in which an agent can make a hundred tool calls. A line on all of them
111
+ * is not obeyed more, it is skipped — and it teaches the agent to skip the next one.
112
+ * The case that actually matters, a reader about to act on the issue, is caught again
113
+ * server-side by the `get_issue` rider, which has the context to say something useful.
114
+ * - FORGET a hold that has gone (claimed, closed, lapsed), so a genuinely new sighting
115
+ * of the same issue later speaks up again.
116
+ *
117
+ * Pure but for `announced`, which is the caller's own set — so the ordering rule that
118
+ * matters (never mark a hold announced unless the line actually reached the response)
119
+ * stays enforceable by the caller, and testable here.
120
+ */
121
+ export function holdsToAnnounce(active, announced, held = []) {
122
+ for (const id of [...announced])
123
+ if (!active.has(id))
124
+ announced.delete(id);
125
+ // Never announce a hold for an issue this session now HOLDS. The scan that confirmed
126
+ // the hold runs before the claim, so without this the very response to
127
+ // `claim_issue RTSC-N` carries "nobody holds RTSC-N, run `claim_issue RTSC-N`" — a
128
+ // line that contradicts the result it is stapled to, and which burns the single
129
+ // announcement this hold ever gets.
130
+ const heldSet = new Set(held);
131
+ return [...active.keys()].filter((id) => !announced.has(id) && !heldSet.has(id));
132
+ }
133
+ /**
134
+ * The line appended to the agent's own response when the server confirms a hold this
135
+ * proxy reported (RTSC-962).
136
+ *
137
+ * It rides the RESPONSE rather than a tool description for the reason RTSC-463 gives: a
138
+ * description is fetched once at `tools/list`, so it never reaches a session already
139
+ * running, and this has to land adjacent to the moment it is about.
140
+ *
141
+ * SHORT, and it names the branch, because the reader's next action is to look at that
142
+ * branch. Fires only while the hold stands — it clears the moment somebody claims.
143
+ */
144
+ export function unclaimedNotice(holds, byIssue) {
145
+ if (holds.length === 0)
146
+ return "";
147
+ const parts = holds.map((id) => `\`${byIssue.get(id) ?? id}\` (${id})`);
148
+ const which = parts.length === 1 ? parts[0] : parts.join(", ");
149
+ const verb = parts.length === 1 ? "exists" : "exist";
150
+ const ids = holds.join(", ");
151
+ return (`⚑ Unclaimed work: a worktree on ${which} ${verb} on this machine and nobody holds ` +
152
+ `${ids}. If it is yours, run \`claim_issue ${holds[0]}\` now; if not, leave it — do not ` +
153
+ `start a second branch for it.`);
154
+ }
155
+ /** The JSON-RPC `report_worktrees` call the proxy sends out-of-band. */
156
+ export function reportRequest(rpcId, worktrees) {
157
+ return {
158
+ jsonrpc: "2.0",
159
+ id: rpcId,
160
+ method: "tools/call",
161
+ params: { name: "report_worktrees", arguments: { worktrees } },
162
+ };
163
+ }
164
+ /** The issue ids the server confirmed it holds, from a `report_worktrees` result. */
165
+ export function holdsFrom(result) {
166
+ if (!result || typeof result !== "object")
167
+ return [];
168
+ const holds = result.holds;
169
+ if (!Array.isArray(holds))
170
+ return [];
171
+ return holds.filter((h) => typeof h === "string");
172
+ }
package/dist/proxy.js CHANGED
@@ -17,6 +17,7 @@ import { mintSessionKey, appendFallbackNotice, recordSession, nameWorkspace, RPC
17
17
  import { readHookRecord, clearHookRecord, modelFromTranscript } from "./lib/sessionHook.js";
18
18
  import { VERSION } from "./version.js";
19
19
  import { attachRoot, isLocalAttachCall, mergeAttachTool, readAttachFile, resolveAttachPath, uploadFailureMessage, uploadUrlWith, } from "./lib/attachFile.js";
20
+ import { buildReport, candidateTrees, holdsFrom, holdsToAnnounce, reportRequest, unclaimedNotice, } from "./lib/worktreeReport.js";
20
21
  import { MAX_FETCH_BYTES, downloadFailureMessage, existingDownload, isLocalFetchCall, mergeFetchTool, resolveDownloadTarget, writeDownloadedFile, } from "./lib/fetchFile.js";
21
22
  // RTSC-92/98: resolve the workspace key via the SHARED resolver, so the proxy and
22
23
  // the direct commands (claim/tidy/done) can never diverge. The proxy carries its
@@ -42,6 +43,74 @@ const UNBOUND_MESSAGE = `This folder (${UNBOUND ?? process.cwd()}) is not bound
42
43
  const HEARTBEAT_MS = Number(process.env.RETASC_HEARTBEAT_MS) || 10 * 60 * 1000;
43
44
  const leases = new Map();
44
45
  let hbSeq = -1; // out-of-band heartbeat ids are negative — never collide with the harness's
46
+ // RTSC-962 — the worktree census. The server cannot read git and never will; this
47
+ // process can, and a `rtsc-NN/<slug>` worktree is an agent saying in the filesystem
48
+ // "I am working on RTSC-NN". Reporting it lets the queue SEE work somebody started
49
+ // without claiming, which is the one transition Retasc had no gate for.
50
+ //
51
+ // Throttled: `git worktree list` is milliseconds, but a `git status` per tree is not,
52
+ // on a large repo — and this is triggered by the agent's tool calls, which can arrive
53
+ // several a second.
54
+ const WORKTREE_SCAN_MS = Number(process.env.RETASC_WORKTREE_SCAN_MS) || 30_000;
55
+ let lastWorktreeScan = 0;
56
+ // Issue → branch for the holds the server last confirmed, so the notice can name the
57
+ // branch. Rebuilt from each scan: a hold that has been claimed (or has lapsed) drops
58
+ // off the server's list and must stop being announced.
59
+ let activeHolds = new Map();
60
+ // Holds already announced to the agent. ONCE PER HOLD, not once per call.
61
+ //
62
+ // The temptation is to repeat the line until the agent acts, and it is the wrong
63
+ // instinct — it is the exact failure the server-side riders are written against
64
+ // (RTSC-463: "a paragraph repeated on every claim becomes wallpaper"). A hold stands for
65
+ // an hour, during which an agent can easily make a hundred tool calls; a line on every
66
+ // one of them does not get more obeyed, it gets skipped, and it teaches the agent to
67
+ // skip the NEXT one too. Saying it once is what keeps it readable — and the case that
68
+ // actually matters, a reader about to act on the issue, is caught again server-side by
69
+ // the `get_issue` rider, which has the full context to say something useful.
70
+ //
71
+ // Cleared when a hold goes away, so a genuinely new sighting of the same issue later
72
+ // speaks up again.
73
+ const announcedHolds = new Set();
74
+ // One scan at a time. The throttle alone is not enough: a scan that outruns the window
75
+ // (a slow disk, a huge repo) would otherwise overlap with the next one and multiply the
76
+ // git processes it was meant to bound.
77
+ let scanning = false;
78
+ /**
79
+ * One git command, ASYNC, returning stdout — or null if git failed or could not be run.
80
+ *
81
+ * Deliberately not `spawnSync`, which the rest of this file uses for its one-shot
82
+ * startup probe. This runs every 30 seconds for the life of the session, and the proxy
83
+ * is a stdio relay: a synchronous child blocks the event loop, so every message in
84
+ * flight — the agent's own tool calls included — waits for git. Never rejects; a census
85
+ * that cannot read the disk says nothing rather than failing anything.
86
+ */
87
+ function git(args, cwd) {
88
+ return new Promise((resolveOut) => {
89
+ let out = "";
90
+ let settled = false;
91
+ const done = (v) => {
92
+ if (settled)
93
+ return;
94
+ settled = true;
95
+ clearTimeout(timer);
96
+ resolveOut(v);
97
+ };
98
+ const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] });
99
+ // A git that hangs (a credential prompt, a network remote, a stuck lock) must not
100
+ // wedge the census — same reasoning as REAP_TIMEOUT_MS, shorter because these are
101
+ // local, read-only commands that should take milliseconds.
102
+ const timer = setTimeout(() => {
103
+ child.kill("SIGKILL");
104
+ done(null);
105
+ }, 5_000);
106
+ timer.unref?.();
107
+ child.stdout?.on("data", (d) => {
108
+ out += String(d);
109
+ });
110
+ child.on("error", () => done(null));
111
+ child.on("close", (code) => done(code === 0 ? out : null));
112
+ });
113
+ }
45
114
  // RTSC-646: the credential-death warning fires ONCE with its full explanation. A
46
115
  // revoked/rotated key fails every heartbeat of every lease, so repeating the whole
47
116
  // paragraph on each tick (every 10 min, per lease) would bury the first one.
@@ -716,6 +785,24 @@ async function handleLine(line) {
716
785
  log(`warning: ${warning}`);
717
786
  applyObservation(leases, obs);
718
787
  appendFallbackNotice(msg.params?.name, resp, sessionKeyFallback);
788
+ // RTSC-962 — attach what the LAST scan found, then start the next one WITHOUT
789
+ // awaiting it.
790
+ //
791
+ // The order matters and it is the same rule RTSC-181 states for the reap: git must
792
+ // never delay the response the agent is blocked on. Awaiting the scan would put two
793
+ // `git` invocations per worktree AND a full network round-trip on the critical path
794
+ // of one tool call in every thirty seconds — hundreds of milliseconds on a large
795
+ // repo, paid by the agent, forever. So the notice is attached from the previous
796
+ // scan's result and the fresh scan runs in the background.
797
+ //
798
+ // The cost of that is one call of latency on FIRST detection — a few seconds — and
799
+ // it buys a proxy that is never in the way. A hold lasts an hour; being told about
800
+ // it one call later changes nothing.
801
+ //
802
+ // `scanWorktrees` is started after `applyObservation` so a claim made by this very
803
+ // call is already in `leases` and its own worktree is not reported as unclaimed.
804
+ appendUnclaimedNotice(resp);
805
+ void scanWorktrees().catch((e) => log(`worktree scan: ${String(e?.message ?? e)}`));
719
806
  }
720
807
  // Relay the response (requests have an id; notifications don't).
721
808
  if (resp != null && msg.id !== undefined) {
@@ -726,6 +813,109 @@ async function handleLine(line) {
726
813
  if (reapId)
727
814
  reapClosedIssue(reapId);
728
815
  }
816
+ /**
817
+ * RTSC-962 — look for unclaimed `rtsc-NN/<slug>` worktrees on this machine and report
818
+ * them, so the server can record a provisional hold.
819
+ *
820
+ * Every decision here is in `lib/worktreeReport.ts` and unit-tested; this function is
821
+ * only the git I/O and the network call. It is a NO-OP outside a git repo, and it never
822
+ * throws: a census that fails must not fail — or even alter — the agent's tool call.
823
+ *
824
+ * Report-only. It never claims (client-side lease bookkeeping would lock the real worker
825
+ * out of `checkpoint`/`done`), never blocks, and never rewrites the request.
826
+ */
827
+ async function scanWorktrees() {
828
+ if (!MAIN_CHECKOUT)
829
+ return; // not in a git repo — nothing local to see
830
+ if (scanning)
831
+ return; // a scan is already in flight — never pile them up
832
+ const now = Date.now();
833
+ if (now - lastWorktreeScan < WORKTREE_SCAN_MS)
834
+ return;
835
+ lastWorktreeScan = now;
836
+ scanning = true;
837
+ try {
838
+ await runScan(now);
839
+ }
840
+ finally {
841
+ scanning = false;
842
+ }
843
+ }
844
+ async function runScan(now) {
845
+ const list = await git(["worktree", "list", "--porcelain"], MAIN_CHECKOUT);
846
+ if (list === null)
847
+ return;
848
+ const trees = candidateTrees(list);
849
+ if (trees.length === 0) {
850
+ activeHolds = new Map();
851
+ return;
852
+ }
853
+ // One `status` + one `log` per candidate. Bounded by the number of agent worktrees on
854
+ // the machine (a handful), and only for branches that already match the convention —
855
+ // never a walk of the repo.
856
+ const state = new Map();
857
+ for (const t of trees) {
858
+ const st = await git(["status", "--porcelain"], t.path);
859
+ if (st === null)
860
+ continue; // the tree is gone or unreadable — say nothing
861
+ const log = await git(["log", "-1", "--format=%ct"], t.path);
862
+ const secs = log === null ? NaN : Number(log.trim());
863
+ state.set(t.path, {
864
+ dirty: st.trim().length > 0,
865
+ ...(Number.isFinite(secs) && secs > 0 ? { lastCommitAt: secs * 1000 } : {}),
866
+ });
867
+ }
868
+ const worktrees = buildReport({ trees, state, held: leases.keys(), now });
869
+ if (worktrees.length === 0) {
870
+ activeHolds = new Map();
871
+ return;
872
+ }
873
+ try {
874
+ const result = toolResult(await postRemote(reportRequest(hbSeq--, worktrees)), "report_worktrees");
875
+ const holds = holdsFrom(result);
876
+ // Rebuilt from THIS scan, never merged into the last one: an issue that dropped off
877
+ // the server's list was claimed, closed, or lapsed, and must stop being announced.
878
+ const byIssue = new Map(worktrees.map((w) => [w.identifier, w.branch]));
879
+ activeHolds = new Map(holds.map((id) => [id, byIssue.get(id) ?? id]));
880
+ }
881
+ catch (e) {
882
+ // An older deployment has no such tool, and a network blip is a network blip.
883
+ // Either way the agent's call is unaffected — that is the whole contract.
884
+ log(`report_worktrees: ${String(e?.message ?? e)}`);
885
+ }
886
+ }
887
+ /**
888
+ * RTSC-962 — attach the ⚑ line to the response the agent is about to read, in its OWN
889
+ * content block.
890
+ *
891
+ * Same mechanics and the same reason as `appendFallbackNotice`: content[0].text must
892
+ * stay machine-parseable JSON (RTSC-142), and anything unexpected about the shape leaves
893
+ * the response untouched — a malformed notice must never break the protocol stream it
894
+ * rides on. Silent on an error response: an agent reading a refusal has a more urgent
895
+ * problem than an unclaimed worktree.
896
+ */
897
+ function appendUnclaimedNotice(resp) {
898
+ // Prunes holds that have gone AND returns only the ones not yet announced. Run
899
+ // unconditionally (not behind an `activeHolds.size` guard) so the pruning happens even
900
+ // when the last scan came back empty — otherwise a hold that lapsed and was later seen
901
+ // again would stay marked as announced and never be mentioned a second time.
902
+ const fresh = holdsToAnnounce(activeHolds, announcedHolds, leases.keys());
903
+ if (fresh.length === 0)
904
+ return;
905
+ const result = resp?.result;
906
+ if (result?.isError)
907
+ return;
908
+ if (!Array.isArray(result?.content))
909
+ return;
910
+ const text = unclaimedNotice(fresh, activeHolds);
911
+ if (!text)
912
+ return;
913
+ result.content.push({ type: "text", text });
914
+ // Marked only once the line is actually ON the response — an early return above must
915
+ // not burn the one announcement this hold gets.
916
+ for (const id of fresh)
917
+ announcedHolds.add(id);
918
+ }
729
919
  async function heartbeatAll() {
730
920
  for (const [issueId, token] of [...leases]) {
731
921
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.50.0",
3
+ "version": "1.52.0",
4
4
  "description": "Retasc CLI — the issue tracker AI agents pull work from. Sign in with GitHub or Google, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
5
5
  "type": "module",
6
6
  "bin": {