toolroll 0.5.0 → 0.6.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.
@@ -15,10 +15,10 @@ export declare function detectPreparation(repo: string): {
15
15
  } | null;
16
16
  /** Browser installation may write only this known file inside its project. */
17
17
  export declare function previewProjectInstructions(repo: string): {
18
+ content?: never;
18
19
  ok: false;
19
20
  message: string;
20
21
  plan?: never;
21
- content?: never;
22
22
  fingerprint?: never;
23
23
  installed?: never;
24
24
  } | {
@@ -30,10 +30,10 @@ export declare function previewProjectInstructions(repo: string): {
30
30
  installed: boolean;
31
31
  };
32
32
  export declare function addProjectInstructions(repo: string, fingerprint: string): {
33
+ content?: never;
33
34
  ok: false;
34
35
  message: string;
35
36
  plan?: never;
36
- content?: never;
37
37
  fingerprint?: never;
38
38
  installed?: never;
39
39
  } | import("./skills.js").InstallResult;
@@ -81,6 +81,8 @@ export function previewProjectInstructions(repo) {
81
81
  const content = skillContent();
82
82
  const current = existsSync(plan.skillPath) ? readFileSync(plan.skillPath, "utf8") : null;
83
83
  const fingerprint = createHash("sha256").update(JSON.stringify({ repo, current, content })).digest("hex");
84
+ // Installed means the CURRENT instructions are in place: a stale copy, or one only in the folder from before the
85
+ // rename, still offers Review so applyInstall can bring it up to date (the first-run checklist counts either).
84
86
  return { ok: true, plan, content, fingerprint, installed: current === content };
85
87
  }
86
88
  export function addProjectInstructions(repo, fingerprint) {
package/dist/skills.d.ts CHANGED
@@ -33,6 +33,12 @@ export declare const CONTEXT_END = "<!-- standing-orders:end -->";
33
33
  export declare function skillContent(): string;
34
34
  /** The AGENTS.md block, marker-fenced so reinstalls replace only themselves. */
35
35
  export declare function contextBlock(): string;
36
+ /**
37
+ * Whether a repository has the Toolroll skill: a SKILL.md in the current
38
+ * folder, or in the one installs before the rename used. The setup guide
39
+ * and the console's first-run checklist both ask this, so they agree.
40
+ */
41
+ export declare function projectSkillInstalled(repo: string): boolean;
36
42
  export type InstallPlan = {
37
43
  skillPath: string;
38
44
  skillAction: "create" | "replace" | "refuse-foreign";
package/dist/skills.js CHANGED
@@ -72,6 +72,14 @@ in the plane: read \`skills get knowledge\`, search it with
72
72
  merge anything yourself.
73
73
  ${CONTEXT_END}`;
74
74
  }
75
+ /**
76
+ * Whether a repository has the Toolroll skill: a SKILL.md in the current
77
+ * folder, or in the one installs before the rename used. The setup guide
78
+ * and the console's first-run checklist both ask this, so they agree.
79
+ */
80
+ export function projectSkillInstalled(repo) {
81
+ return [SKILL_DIR, LEGACY_SKILL_DIR].some(dir => existsSync(join(repo, dir, SKILL_FILE)));
82
+ }
75
83
  /** What install would do, computed without writing anything. */
76
84
  export function planInstall(repo, writeContext) {
77
85
  const skillPath = join(repo, SKILL_DIR, SKILL_FILE);
package/dist/store.d.ts CHANGED
@@ -74,7 +74,7 @@ export type SealedLedgerEntry = LedgerEntry & {
74
74
  hash: string;
75
75
  } | null;
76
76
  };
77
- export declare const SCHEMA_VERSION = 107;
77
+ export declare const SCHEMA_VERSION = 109;
78
78
  export type ApiTokenRow = {
79
79
  id: string;
80
80
  account: string;
@@ -4820,7 +4820,7 @@ export declare class Store {
4820
4820
  * recovered-draft admission and the fallback admission's `recoveredFrom`:
4821
4821
  * the interrupted attempt whose draft a fresh run inherits is THIS
4822
4822
  * task's own builder or repair turn, left in this very worktree on this
4823
- * branch, either ended as `failed` for `interrupted` or still open under
4823
+ * branch, either ended as `failed` for `interrupted` (or `no-handoff`) or still open under
4824
4824
  * a lease nobody holds any more. A planner, scout, or reviewer; a
4825
4825
  * parked, built, or otherwise failed run; a foreign workspace; or an
4826
4826
  * attempt still being built under a live claim is no grant — the words
package/dist/store.js CHANGED
@@ -64,6 +64,7 @@ import { LEDGER_SCHEMA, LEDGER_TABLE, LEDGER_V54_COLUMNS, LEDGER_V54_TABLE, inst
64
64
  import { PLAN_AUTO_SCHEMA } from "./plan-auto.js";
65
65
  import { RECIPE_SCHEMA } from "./recipes.js";
66
66
  import { POLICY_SCHEMA, agentRefusal, approvalRefusal, attendedRefusal, policyParts, readPolicy, underCeiling, sessionCeilingRefusal } from "./policy.js";
67
+ import { PROVIDER_AUTH_SCHEMA } from "./provider-auth.js";
67
68
  import { SPEND_SCHEMA, billingOf, budgetStates, canPrice, claudeMachineBilling, countsToward, filersOf, monthOf, priceWork, seenBilling as seenBillingOf, spendItems, teammateFilers, usd } from "./spend.js";
68
69
  import { FOREVER, RETENTION_SCHEMA, periodWords } from "./retention.js";
69
70
  import { IN_RANGE, LEDGER_CHAIN_SCHEMA, safeWhole, sealLedger, verifyLedgerChain } from "./ledger-chain.js";
@@ -249,7 +250,9 @@ const REGISTERED_AT_FILING = "again, as it was filed";
249
250
  // v105 prices every run (reported, or tokens at the catalogue price) and keeps monthly budgets per project, person, teammate and installation.
250
251
  // v106 keeps the organisation policy, retention periods, backup settings and runs, check progress, and runner capacity changes.
251
252
  // v107 keeps a check's progress and result in one record (run_check absorbs check_progress).
252
- export const SCHEMA_VERSION = 107;
253
+ // v108 keeps sign-in pauses: one incident per provider whose sign-in stopped working (provider_auth_pause).
254
+ // v109 remembers which sign-in pause parked a task (task_ref.auth_wait_pause), so reviews and other runs outside the tick wait for it too.
255
+ export const SCHEMA_VERSION = 109;
253
256
  /** v102: a project's approval rules, and each person's approval of an exact scope (two are needed for protected work). */
254
257
  const APPROVAL_SCHEMA = `
255
258
  CREATE TABLE IF NOT EXISTS approval_policy (
@@ -3687,6 +3690,10 @@ function initializeStore(db, file) {
3687
3690
  db.exec(LEDGER_CHAIN_SCHEMA);
3688
3691
  db.exec(MONITORING_SCHEMA);
3689
3692
  db.exec(SPEND_SCHEMA);
3693
+ // Sign-in pauses: one row per incident of a provider's sign-in no longer working.
3694
+ db.exec(PROVIDER_AUTH_SCHEMA);
3695
+ // The sign-in pause the dispatch gate last left this task waiting on (null: none), for the work index.
3696
+ addColumn(db, "task_ref", "auth_wait_pause", "INTEGER REFERENCES provider_auth_pause(id)");
3690
3697
  db.exec(RETENTION_SCHEMA);
3691
3698
  db.exec(BACKUP_SCHEMA);
3692
3699
  // Sprint 8: the organisation policy (one row, or none: nothing restricted).
@@ -9785,7 +9792,7 @@ export class Store {
9785
9792
  const row = this.db
9786
9793
  .prepare(`SELECT id FROM run
9787
9794
  WHERE worktree = ?
9788
- AND (outcome IS NULL OR reason = 'interrupted' OR parent_run IS NOT NULL)
9795
+ AND (outcome IS NULL OR reason IN ('interrupted', 'no-handoff') OR parent_run IS NOT NULL)
9789
9796
  ORDER BY id DESC LIMIT 1`)
9790
9797
  .get(path);
9791
9798
  return row === undefined ? null : Number(row.id);
@@ -15614,7 +15621,7 @@ export class Store {
15614
15621
  * recovered-draft admission and the fallback admission's `recoveredFrom`:
15615
15622
  * the interrupted attempt whose draft a fresh run inherits is THIS
15616
15623
  * task's own builder or repair turn, left in this very worktree on this
15617
- * branch, either ended as `failed` for `interrupted` or still open under
15624
+ * branch, either ended as `failed` for `interrupted` (or `no-handoff`) or still open under
15618
15625
  * a lease nobody holds any more. A planner, scout, or reviewer; a
15619
15626
  * parked, built, or otherwise failed run; a foreign workspace; or an
15620
15627
  * attempt still being built under a live claim is no grant — the words
@@ -15627,7 +15634,9 @@ export class Store {
15627
15634
  return `run #${parent.id} is a ${parent.role} run — a recovered draft is a builder's or its repair turn's`;
15628
15635
  if (parent.worktree !== run.worktree || parent.branch !== run.branch)
15629
15636
  return `run #${parent.id} left its draft in ${parent.worktree ?? "no worktree"} on ${parent.branch ?? "no branch"}, not ${run.worktree} on ${run.branch}`;
15630
- const interrupted = parent.outcome === null || (parent.outcome === "failed" && parent.reason === "interrupted");
15637
+ // An attempt that stopped before its handoff (run 2085) left its work in
15638
+ // place for exactly this: the retry inherits it, like an interrupted one's.
15639
+ const interrupted = parent.outcome === null || (parent.outcome === "failed" && (parent.reason === "interrupted" || parent.reason === "no-handoff"));
15631
15640
  if (!interrupted)
15632
15641
  return `run #${parent.id} ended as ${parent.outcome}${parent.reason === null ? "" : ` (${parent.reason})`} — only an interrupted attempt's draft is recovered`;
15633
15642
  if (parent.outcome === null && this.liveClaimByLease(parent.leaseId, now) !== null)
@@ -53,6 +53,9 @@ export function composeSubscriptionMatePrompt(request) {
53
53
  request.system,
54
54
  "SUBSCRIPTION HARNESS PROTOCOL:",
55
55
  "Do not use any harness tools or inspect the computer. The only current state is DATA and TOOL RESULTS below.",
56
+ // The model tried host tools as its own tool calls, which the harness refuses, and sometimes then told the operator the
57
+ // project search "wasn't available" (gate run, 2026-09-30). Host tools are requested only through calls.
58
+ "Host tools are not tools you can call yourself here: calling one directly always fails. Request one only by listing it in calls of the JSON object you return. Every AVAILABLE HOST TOOL below is available this way; never say one is unavailable. For what a project's files or code say, request get_project_context.",
56
59
  "Return the required JSON object. To request a host tool, append {id, name, argumentsJson} to calls; argumentsJson is the JSON serialization of that tool's argument object.",
57
60
  "The host validates and runs those calls, then gives you another step. When finished, return a non-empty text and an empty calls array.",
58
61
  `AVAILABLE HOST TOOLS:\n${JSON.stringify(request.tools)}`,
@@ -2,6 +2,7 @@
2
2
  * not verify artifacts, inspect processes, or authorize task operations. */
3
3
  import { createHash } from 'node:crypto';
4
4
  import { readProjectAccess } from './project-access.js';
5
+ import { providerName, signInReason } from './provider-auth.js';
5
6
  import { scopeTermsProblem } from './store.js';
6
7
  export const WORK_INDEX_PAGE_LIMIT = 40;
7
8
  export const WORK_INDEX_MAX_LIMIT = 100;
@@ -17,7 +18,7 @@ const s = (row, key) => row[key] == null ? null : String(row[key]);
17
18
  const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
18
19
  SELECT t.id, t.title, t.state, t.created_at, t.updated_at, r.id ref_id, r.repo,
19
20
  r.revision_of, r.revision_brief_artifact, r.assigned_runner, r.plan,
20
- r.deliverable, r.coordinator_cid, r.capability_requirements, r.park_rate
21
+ r.deliverable, r.coordinator_cid, r.capability_requirements, r.park_rate, r.auth_wait_pause
21
22
  FROM task_ref r JOIN task t ON t.id = r.external_id
22
23
  WHERE r.backend = 'built-in' AND ((r.repo IS NULL AND $unplaced = 1)
23
24
  OR (r.repo IS NOT NULL AND ($all = 1 OR r.repo IN (SELECT value FROM json_each($repos)))))
@@ -82,6 +83,14 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
82
83
  (SELECT id FROM run WHERE task_ref=c.ref_id AND lease_id=c.live_lease AND outcome IS NULL ORDER BY id DESC LIMIT 1) live_run,
83
84
  h.owner_kind hold_kind,h.reason hold_reason,h.until hold_until,
84
85
  sc.task_id scope_id,sc.digest,sc.approved_at,sc.proposed_at,sc.profile_state,
86
+ -- The pause the dispatch gate itself left this task waiting on (the provider it resolved, pins,
87
+ -- configuration and planners included) first; the approved terms answer before any gate has looked.
88
+ EXISTS(SELECT 1 FROM provider_auth_pause p WHERE p.id=c.auth_wait_pause AND p.lifted_at IS NULL) gate_signed_out,
89
+ COALESCE((SELECT p.provider||':'||p.auth_mode FROM provider_auth_pause p WHERE p.id=c.auth_wait_pause AND p.lifted_at IS NULL),
90
+ (SELECT p.provider||':'||p.auth_mode FROM provider_auth_pause p WHERE p.lifted_at IS NULL AND p.provider IN (
91
+ SELECT json_extract(sc.approved_profile_json,'$.provider') WHERE json_valid(sc.approved_profile_json)
92
+ UNION ALL SELECT json_extract(leg.value,'$.provider') FROM json_each(CASE WHEN json_valid(sc.approved_route_json) THEN sc.approved_route_json ELSE '{}' END,'$.legs') leg
93
+ WHERE json_extract(leg.value,'$.phase')='build'))) signed_out,
85
94
  (sc.approved_at IS NOT NULL AND sc.approved_by IS NOT NULL AND sc.approved_digest=sc.digest) approved,
86
95
  (sc.approval_basis IS NOT 'mode' OR EXISTS(SELECT 1 FROM operating_mode om JOIN approver signer ON signer.name=om.signed_by
87
96
  WHERE om.repo=c.repo AND om.digest=sc.mode_digest AND om.revoked_at IS NULL AND om.absolute_expiry>$now
@@ -176,15 +185,16 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
176
185
  WHEN worker_count=0 THEN 'no-worker-registered'
177
186
  WHEN online_count=0 THEN 'no-worker-online'
178
187
  WHEN available_count=0 THEN 'worker-at-capacity'
188
+ WHEN signed_out IS NOT NULL AND (gate_signed_out OR NOT (plan='requested' AND NOT COALESCE(approved AND dispatch_approval,0))) THEN 'signed-out'
179
189
  WHEN (plan='requested' OR park_rate>0) AND (SELECT COUNT(*) FROM decision WHERE state<>'answered' AND answered_at IS NULL)>=5 THEN 'decision-queue'
180
190
  WHEN plan='requested' AND NOT COALESCE(approved AND dispatch_approval,0) THEN 'planning-ready'
181
191
  WHEN deliverable='report' THEN 'scouting-ready'
182
192
  ELSE 'queued' END code
183
193
  FROM checked f
184
194
  ), ranked AS MATERIALIZED (
185
- SELECT c.*,code NOT IN ('cancelled','complete','running','updating','retry-scheduled','waiting-dependency','worker-at-capacity','planning-ready','scouting-ready','queued') needs,
195
+ SELECT c.*,code NOT IN ('cancelled','complete','running','updating','retry-scheduled','waiting-dependency','worker-at-capacity','signed-out','planning-ready','scouting-ready','queued') needs,
186
196
  CASE WHEN code='complete' THEN 3 WHEN code='cancelled' THEN 4 WHEN code='running' THEN 1
187
- WHEN code IN ('updating','retry-scheduled','waiting-dependency','worker-at-capacity','planning-ready','scouting-ready','queued') THEN 2 ELSE 0 END rank,
197
+ WHEN code IN ('updating','retry-scheduled','waiting-dependency','worker-at-capacity','signed-out','planning-ready','scouting-ready','queued') THEN 2 ELSE 0 END rank,
188
198
  CASE WHEN code='complete' THEN completed.at ELSE family_updated END sort_at,
189
199
  completed.actor checked_actor,completed.at checked_at,completed.outcome checked_digest
190
200
  FROM classified c LEFT JOIN action_ledger completed ON completed.id=c.checked_id WHERE ($state IS NULL OR c.state=$state) AND ($leadId IS NULL OR EXISTS(SELECT 1 FROM team_task_owner own WHERE own.task_ref=c.root_ref AND own.lead=$leadId))
@@ -283,7 +293,7 @@ export function workIndexPage(store, now, access, options = {}) {
283
293
  'earlier_id',earlier_id,'broken',broken,'live_run',live_run,'result_id',page_result_id,'result_outcome',(SELECT outcome FROM run WHERE id=page_result_id),
284
294
  'publication_url',(SELECT pr_url FROM publication WHERE run=page_result_id),'code',code,'needs',needs,'family_running',family_running,'rank',rank,'sort_at',sort_at,
285
295
  'checked_actor',checked_actor,'checked_at',checked_at,'checked_digest',checked_digest,'question_id',question_id,
286
- 'dependency_id',dependency_id,'dependency_state',dependency_state,'custody_unresolved',custody_unresolved,'hold_kind',hold_kind,'hold_reason',substr(hold_reason,1,300),'stop_run',stop_run,'unfinished',unfinished,
296
+ 'dependency_id',dependency_id,'dependency_state',dependency_state,'custody_unresolved',custody_unresolved,'hold_kind',hold_kind,'hold_reason',substr(hold_reason,1,300),'stop_run',stop_run,'unfinished',unfinished,'signed_out',signed_out,
287
297
  'question_run',(SELECT run FROM decision WHERE id=page.question_id),
288
298
  'question_task',(SELECT r.external_id FROM decision d JOIN run ON run.id=d.run JOIN task_ref r ON r.id=run.task_ref WHERE d.id=page.question_id)) FROM page`)
289
299
  .all({ ...parameters(now, access, options), $cursorRoot: cursor?.root ?? 0, $cursorRank: cursor?.rank ?? 0, $cursorAt: cursor?.at ?? '', $limit: limit + 1, $recent: new Date(now.getTime() - 86_400_000).toISOString() });
@@ -336,6 +346,7 @@ function itemOf(row, principal) {
336
346
  'no-worker-registered': ['Needs your decision', 'No connected builder includes this project.', 'start-worker', 'Check connection'],
337
347
  'no-worker-online': ['Needs your decision', 'The builder has stopped checking in.', 'start-worker', 'Check connection'],
338
348
  'worker-at-capacity': ['Waiting for worker capacity', 'This task starts when an eligible worker has capacity.', null, ''],
349
+ 'signed-out': ['Waiting for a sign-in', 'Its agent needs you to sign in again; it starts on its own once that works.', null, ''],
339
350
  'retry-scheduled': ['Retry scheduled', 'The attempt is backing off before retrying.', null, ''],
340
351
  updating: ['Waiting for app update', 'New work resumes after the update.', null, ''],
341
352
  'planning-ready': ['Planner ready', 'A connected worker can draft the plan.', null, ''],
@@ -343,6 +354,11 @@ function itemOf(row, principal) {
343
354
  queued: ['Ready to run', 'A connected worker can claim this task.', null, ''],
344
355
  };
345
356
  let [label, detail, actionCode, actionLabel] = words[code];
357
+ if (code === 'signed-out') {
358
+ const [provider = '', authMode = 'subscription'] = (s(row, 'signed_out') ?? '').split(':');
359
+ label = signInReason({ provider: provider, authMode: authMode === 'api-key' ? 'api-key' : 'subscription' });
360
+ detail = `It starts again on its own once ${providerName(provider)} works.`;
361
+ }
346
362
  if (code === 'running' && n(row, 'version_count') > 1)
347
363
  label = 'Revising';
348
364
  if (code === 'terminal-dependency' && s(row, 'dependency_id') !== null)
@@ -95,6 +95,19 @@ export declare function worktreeProcessOccupancy(path: string): {
95
95
  } | {
96
96
  held: false;
97
97
  };
98
+ /**
99
+ * A checkout's uncommitted work as one patch — tracked changes against HEAD,
100
+ * then each untracked file against nothing (the lease note aside) — written
101
+ * to `file`. Nothing in the checkout is touched. Every road that is about to
102
+ * reset or clean a tree saves this first.
103
+ */
104
+ export declare function saveWorkPatch(runner: Runner, path: string, file: string, header: string): Promise<{
105
+ ok: true;
106
+ file: string;
107
+ } | {
108
+ ok: false;
109
+ message: string;
110
+ }>;
98
111
  export declare class WorktreePool {
99
112
  private readonly store;
100
113
  private readonly options;
package/dist/worktree.js CHANGED
@@ -25,7 +25,7 @@
25
25
  import { createHash, randomBytes } from "node:crypto";
26
26
  import { existsSync, readFileSync, writeFileSync, rmSync, realpathSync, mkdirSync, readdirSync } from "node:fs";
27
27
  import { hostname } from "node:os";
28
- import { basename, join } from "node:path";
28
+ import { basename, dirname, join } from "node:path";
29
29
  import { run } from "./exec.js";
30
30
  import { currentBootId, provenDeadByBootChange } from "./boot-identity.js";
31
31
  import { HANDOFF_PREFIX, MAILBOX_SUFFIX, readMailbox } from "./evidence.js";
@@ -134,6 +134,40 @@ export function worktreeProcessOccupancy(path) {
134
134
  return code === "ESRCH" ? { held: false } : { held: true, by: pid };
135
135
  }
136
136
  }
137
+ /**
138
+ * A checkout's uncommitted work as one patch — tracked changes against HEAD,
139
+ * then each untracked file against nothing (the lease note aside) — written
140
+ * to `file`. Nothing in the checkout is touched. Every road that is about to
141
+ * reset or clean a tree saves this first.
142
+ */
143
+ export async function saveWorkPatch(runner, path, file, header) {
144
+ const status = await runner(GIT, [...READ_ONLY, "status", "--porcelain", "--untracked-files=all"], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
145
+ if (status.code !== 0)
146
+ return { ok: false, message: `${path} could not be inspected before its work was saved` };
147
+ const untracked = status.stdout
148
+ .split("\n")
149
+ .filter(line => line.startsWith("?? ") && !line.trimEnd().endsWith(MARKER))
150
+ .map(line => line.slice(3).trim());
151
+ const tracked = await runner(GIT, [...READ_ONLY, "diff", "--binary", "HEAD"], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
152
+ if (tracked.code !== 0)
153
+ return { ok: false, message: `${path}: git diff failed while saving its work (${firstLine(tracked.stderr)})` };
154
+ const parts = [tracked.stdout];
155
+ for (const one of untracked) {
156
+ // --no-index exits 1 when the sides differ; that is the expected answer.
157
+ const diff = await runner(GIT, [...READ_ONLY, "diff", "--binary", "--no-index", "--", "/dev/null", one], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
158
+ if (diff.code > 1)
159
+ return { ok: false, message: `${path}: ${one} could not be captured (${firstLine(diff.stderr)})` };
160
+ parts.push(diff.stdout);
161
+ }
162
+ try {
163
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
164
+ writeFileSync(file, header + parts.join(""), { mode: 0o600 });
165
+ }
166
+ catch (error) {
167
+ return { ok: false, message: `${path}: its work could not be written to ${file} (${error instanceof Error ? error.message : String(error)})` };
168
+ }
169
+ return { ok: true, file };
170
+ }
137
171
  export class WorktreePool {
138
172
  store;
139
173
  options;
@@ -271,7 +305,7 @@ export class WorktreePool {
271
305
  }
272
306
  const previousRun = this.store.latestRunInWorktree(path);
273
307
  const recoveryRun = this.store.latestRecoverableRunInWorktree(path);
274
- const recovery = recoveryRun === null ? null : this.recoverableDraft(path, recoveryRun);
308
+ const recovery = recoveryRun === null ? null : this.recoverableDraft(path, recoveryRun, previousRun);
275
309
  const kept = await this.keepLeftover(path, request.reclaim.evidenceRoot, request.now);
276
310
  if (!kept.ok)
277
311
  return { ok: false, reason: "git", message: kept.message };
@@ -354,7 +388,7 @@ export class WorktreePool {
354
388
  * the old result. It classifies the recovery context for the next freshly
355
389
  * fenced attempt, which must review and prove the inherited work itself.
356
390
  */
357
- recoverableDraft(path, runId) {
391
+ recoverableDraft(path, runId, latestRun) {
358
392
  const prior = this.store.getRun(runId);
359
393
  if (prior === null)
360
394
  return null;
@@ -368,6 +402,23 @@ export class WorktreePool {
368
402
  return { runId: source.id, kind: "partial" };
369
403
  }
370
404
  }
405
+ // An attempt that stopped before its handoff (run 2085) left work it
406
+ // never got to hand off: kept in place for the next attempt, never reset
407
+ // — until a later attempt fails some other way. That failure may have
408
+ // broken the tree, and the work is already saved (its own patch, and the
409
+ // leftover patch the caller writes first), so the tree can be reset. A
410
+ // crash or a sign-in that stopped working says nothing about the tree.
411
+ // A resumed or repair turn is judged as part of the attempt it belongs to.
412
+ const attemptOf = (one) => one !== null && one.role === "repair" && one.parentRun !== null ? this.store.getRun(one.parentRun) ?? one : one;
413
+ const brokeTree = (one) => one !== null && one.outcome === "failed" && one.reason !== "no-handoff" && one.reason !== "interrupted" && one.reason !== "auth-expired";
414
+ const attempt = attemptOf(prior);
415
+ const later = latestRun !== null && latestRun > runId ? attemptOf(this.store.getRun(latestRun)) : null;
416
+ const unhanded = attempt.outcome === "failed" && attempt.reason === "no-handoff"
417
+ ? attempt
418
+ : attempt.parentRun === null ? null : this.store.getRun(attempt.parentRun);
419
+ if (unhanded !== null && unhanded.outcome === "failed" && unhanded.reason === "no-handoff") {
420
+ return brokeTree(attempt) || brokeTree(later) ? null : { runId: unhanded.id, kind: "partial" };
421
+ }
371
422
  if (prior.outcome !== null && (prior.outcome !== "failed" || prior.reason !== "interrupted"))
372
423
  return null;
373
424
  let names;
@@ -395,35 +446,10 @@ export class WorktreePool {
395
446
  * run is on record). Nothing is deleted here.
396
447
  */
397
448
  async keepLeftover(path, evidenceRoot, now) {
398
- const status = await this.runner(GIT, [...READ_ONLY, "status", "--porcelain", "--untracked-files=all"], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
399
- if (status.code !== 0)
400
- return { ok: false, message: `${path} could not be inspected before reclaiming it` };
401
- const untracked = status.stdout
402
- .split("\n")
403
- .filter(line => line.startsWith("?? ") && !line.trimEnd().endsWith(MARKER))
404
- .map(line => line.slice(3).trim());
405
- const tracked = await this.runner(GIT, [...READ_ONLY, "diff", "--binary", "HEAD"], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
406
- if (tracked.code !== 0)
407
- return { ok: false, message: `${path}: git diff failed while reclaiming (${firstLine(tracked.stderr)})` };
408
- const parts = [tracked.stdout];
409
- for (const file of untracked) {
410
- // --no-index exits 1 when the sides differ; that is the expected answer.
411
- const one = await this.runner(GIT, [...READ_ONLY, "diff", "--binary", "--no-index", "--", "/dev/null", file], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
412
- if (one.code > 1)
413
- return { ok: false, message: `${path}: ${file} could not be captured (${firstLine(one.stderr)})` };
414
- parts.push(one.stdout);
415
- }
416
449
  const lastRun = this.store.latestRunInWorktree(path);
417
450
  const dir = lastRun === null ? join(evidenceRoot, "leftover") : join(evidenceRoot, String(lastRun));
418
451
  const file = join(dir, lastRun === null ? `${now.toISOString().replace(/[:.]/g, "-")}.patch` : "leftover.patch");
419
- try {
420
- mkdirSync(dir, { recursive: true, mode: 0o700 });
421
- writeFileSync(file, `# leftover from ${path}\n# kept ${now.toISOString()} before the tree was reset for the next attempt\n` + parts.join(""), { mode: 0o600 });
422
- }
423
- catch (error) {
424
- return { ok: false, message: `${path}: the leftover could not be written to ${file} (${error instanceof Error ? error.message : String(error)})` };
425
- }
426
- return { ok: true, file };
452
+ return saveWorkPatch(this.runner, path, file, `# leftover from ${path}\n# kept ${now.toISOString()} before the tree was reset for the next attempt\n`);
427
453
  }
428
454
  /** Back to HEAD, untracked gone, our lease note kept; proven clean after. */
429
455
  async resetTree(path) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"