toolroll 0.9.7 → 0.9.9

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.
Files changed (97) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/dist/assignment-adapters.d.ts +1 -0
  3. package/dist/assignment-adapters.js +2 -0
  4. package/dist/assignment-brief.d.ts +2 -0
  5. package/dist/assignment-brief.js +2 -1
  6. package/dist/assignment-presentation.js +3 -1
  7. package/dist/assignment-status.d.ts +1 -1
  8. package/dist/assignment-ui.js +5 -0
  9. package/dist/assignment.d.ts +15 -0
  10. package/dist/assignment.js +56 -9
  11. package/dist/browser/workspace.css +1 -1
  12. package/dist/browser/workspace.js +18 -18
  13. package/dist/browser-crew.js +4 -0
  14. package/dist/browser-workspace.d.ts +40 -0
  15. package/dist/build-review.d.ts +38 -0
  16. package/dist/build-review.js +341 -0
  17. package/dist/builder.d.ts +2 -0
  18. package/dist/builder.js +114 -8
  19. package/dist/chat-controls.d.ts +1 -1
  20. package/dist/chat-controls.js +1 -1
  21. package/dist/check-levels-ui.d.ts +5 -0
  22. package/dist/check-levels-ui.js +16 -2
  23. package/dist/check-progress.js +4 -3
  24. package/dist/checkout-cleanup.d.ts +40 -2
  25. package/dist/checkout-cleanup.js +129 -9
  26. package/dist/cli.js +2 -0
  27. package/dist/desktop-update.d.ts +52 -0
  28. package/dist/desktop-update.js +147 -5
  29. package/dist/dispatch.js +2 -1
  30. package/dist/dispose.d.ts +4 -3
  31. package/dist/dispose.js +12 -4
  32. package/dist/evidence.d.ts +6 -0
  33. package/dist/evidence.js +8 -0
  34. package/dist/exec.d.ts +5 -0
  35. package/dist/exec.js +30 -4
  36. package/dist/first-run.d.ts +1 -1
  37. package/dist/first-run.js +8 -6
  38. package/dist/invoke.js +5 -0
  39. package/dist/lead-context.d.ts +1 -1
  40. package/dist/lead-context.js +4 -2
  41. package/dist/lead-status.d.ts +10 -1
  42. package/dist/lead-status.js +21 -24
  43. package/dist/modes.d.ts +3 -1
  44. package/dist/modes.js +3 -1
  45. package/dist/needs-you.d.ts +55 -0
  46. package/dist/needs-you.js +83 -0
  47. package/dist/operate.d.ts +2 -0
  48. package/dist/operate.js +231 -36
  49. package/dist/plane-review.js +4 -0
  50. package/dist/process-custody.js +8 -0
  51. package/dist/process-recovery-eligibility.d.ts +1 -0
  52. package/dist/project-learning.d.ts +3 -3
  53. package/dist/project-learning.js +28 -17
  54. package/dist/proof.d.ts +3 -1
  55. package/dist/proof.js +4 -2
  56. package/dist/provider.js +16 -0
  57. package/dist/result-completion.d.ts +4 -0
  58. package/dist/result-completion.js +11 -0
  59. package/dist/retention-ui.d.ts +3 -1
  60. package/dist/retention-ui.js +4 -4
  61. package/dist/retention.d.ts +13 -6
  62. package/dist/retention.js +50 -12
  63. package/dist/review-context.d.ts +3 -0
  64. package/dist/review-context.js +45 -5
  65. package/dist/review-switch.d.ts +41 -0
  66. package/dist/review-switch.js +88 -0
  67. package/dist/reviewer.d.ts +24 -2
  68. package/dist/reviewer.js +49 -1
  69. package/dist/run-closeout.d.ts +89 -0
  70. package/dist/run-closeout.js +302 -0
  71. package/dist/serve.d.ts +15 -1
  72. package/dist/serve.js +158 -18
  73. package/dist/setup-guide.d.ts +2 -2
  74. package/dist/shared-deps.d.ts +94 -0
  75. package/dist/shared-deps.js +365 -0
  76. package/dist/storage.js +2 -0
  77. package/dist/store.d.ts +72 -1
  78. package/dist/store.js +258 -31
  79. package/dist/surface.js +5 -1
  80. package/dist/task-status.d.ts +22 -5
  81. package/dist/task-status.js +45 -24
  82. package/dist/telegram-progress.js +2 -1
  83. package/dist/telegram-status.js +3 -3
  84. package/dist/test-temp.d.ts +19 -0
  85. package/dist/test-temp.js +98 -0
  86. package/dist/toolroll-update-ui.js +7 -2
  87. package/dist/toolroll-update.d.ts +21 -1
  88. package/dist/toolroll-update.js +135 -5
  89. package/dist/work-index.d.ts +11 -0
  90. package/dist/work-index.js +103 -16
  91. package/dist/work-summary.d.ts +3 -1
  92. package/dist/work-summary.js +2 -1
  93. package/dist/workspace-ui.d.ts +3 -1
  94. package/dist/workspace-ui.js +10 -9
  95. package/dist/worktree.d.ts +16 -0
  96. package/dist/worktree.js +47 -0
  97. package/package.json +1 -1
@@ -44,12 +44,21 @@ CREATE TRIGGER IF NOT EXISTS learning_capture_no_delete BEFORE DELETE ON learnin
44
44
  export const learningSha = (text) => createHash('sha256').update(text).digest('hex');
45
45
  const text = (x, max) => typeof x === 'string' && x.trim().length > 0 && Buffer.byteLength(x) <= max && !/[\x00-\x1f\x7f]/.test(x);
46
46
  const pathOk = (x) => text(x, 300) && !x.startsWith('/') && !x.includes('\\') && !/^[A-Za-z]:/.test(x) && x.split('/').every(p => p !== '.' && p !== '..' && p !== '') && !/[\*?\[\]:]/.test(x);
47
- const git = (repo, args) => execFileSync('git', ['--no-optional-locks', '-C', repo, ...args], { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }).trimEnd();
47
+ /** One read per distinct command within a single selection pass (`seen`), so a pass over many lessons reads the repository once. */
48
+ const git = (repo, args, seen) => {
49
+ const key = JSON.stringify([repo, args]);
50
+ const known = seen?.get(key);
51
+ if (known !== undefined)
52
+ return known;
53
+ const out = execFileSync('git', ['--no-optional-locks', '-C', repo, ...args], { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }).trimEnd();
54
+ seen?.set(key, out);
55
+ return out;
56
+ };
48
57
  /** Path plus the physical shared Git database: replacing a repository does not inherit its lessons. */
49
- export function learningIdentity(repo) {
58
+ export function learningIdentity(repo, seen) {
50
59
  if (canonicalProject(repo) !== repo)
51
60
  throw new Error('Project identity changed.');
52
- const common = realpathSync(resolve(repo, git(repo, ['rev-parse', '--git-common-dir'])));
61
+ const common = realpathSync(resolve(repo, git(repo, ['rev-parse', '--git-common-dir'], seen)));
53
62
  const st = statSync(common);
54
63
  return learningSha(JSON.stringify([repo, common, st.dev, st.ino, st.birthtimeMs]));
55
64
  }
@@ -59,10 +68,10 @@ function environmentFingerprint(store, repo) {
59
68
  }
60
69
  const configPath = (p) => /(^|\/)(AGENTS\.md|package(-lock)?\.json|[^/]*lock[^/]*|[^/]*config[^/]*|Cargo\.toml|go\.mod|pyproject\.toml|\.tool-versions|\.nvmrc)$/.test(p);
61
70
  /** Compare the applicable code and configuration, not model confidence or a claimed passing command. */
62
- export function learningFingerprint(repo, head, paths) {
71
+ export function learningFingerprint(repo, head, paths, seen) {
63
72
  if (!/^[a-f0-9]{40,64}$/.test(head))
64
73
  throw new Error('No exact source revision.');
65
- const entries = git(repo, ['ls-tree', '-r', head]).split('\n').filter(line => {
74
+ const entries = git(repo, ['ls-tree', '-r', head], seen).split('\n').filter(line => {
66
75
  const p = line.slice(line.indexOf('\t') + 1);
67
76
  return paths.includes(p) || configPath(p);
68
77
  });
@@ -198,7 +207,7 @@ function verifyEvidence(store, root, source, evidence, catalog) {
198
207
  // missing work, so newer reviews cannot starve an older recovery.
199
208
  const recoveryCursors = new WeakMap();
200
209
  /** Validate after core commit; both suggestions commit or neither. */
201
- export function recoverLearning(store, root, repo, now = new Date()) {
210
+ export function recoverLearning(store, root, repo, now = new Date(), seen = new Map()) {
202
211
  let projects = recoveryCursors.get(store);
203
212
  if (!projects)
204
213
  recoveryCursors.set(store, projects = new Map());
@@ -225,7 +234,7 @@ export function recoverLearning(store, root, repo, now = new Date()) {
225
234
  if (!source || !reviewer || source.taskRef !== reviewer.taskRef)
226
235
  continue;
227
236
  const snapshot = reviewSnapshot(store, reviewer.id);
228
- if (!snapshot || snapshot['identity'] !== learningIdentity(repo))
237
+ if (!snapshot || snapshot['identity'] !== learningIdentity(repo, seen))
229
238
  continue;
230
239
  const response = store.artifactsFor(reviewer.id).find(a => a.kind === 'structured-output' && a.captureStatus === 'ok');
231
240
  const read = response && readVerifiedArtifact(root, response);
@@ -260,7 +269,7 @@ export function recoverLearning(store, root, repo, now = new Date()) {
260
269
  if (!run || !reviewed || reviewed.outcome !== 'no-change' || reviewed.taskRef !== run.taskRef || reviewed.role !== 'reviewer' || reviewedSource(store, reviewer) !== source || store.refForId(run.taskRef)?.repo !== repo)
261
270
  throw new Error('Learning review provenance is unavailable.');
262
271
  const candidates = parseLearning(JSON.parse(String(row['payload'])));
263
- const identity = learningIdentity(repo);
272
+ const identity = learningIdentity(repo, seen);
264
273
  if (identity !== row['identity'])
265
274
  throw new Error('Learning project identity changed.');
266
275
  const head = run.headRevision ?? run.baseRevision ?? '';
@@ -273,7 +282,7 @@ export function recoverLearning(store, root, repo, now = new Date()) {
273
282
  verifyEvidence(store, root, source, c.evidence, JSON.parse(String(row['catalog'])));
274
283
  if (c.paths.some(p => !patch.split('\n').some(l => l === `+++ b/${p}` || l === `--- a/${p}`)))
275
284
  throw new Error('Learning applicability must name reviewed files.');
276
- const payload = JSON.stringify({ ...c, environment: String(row['environment']), fingerprint: learningFingerprint(repo, head, c.paths), platform: process.platform, head });
285
+ const payload = JSON.stringify({ ...c, environment: String(row['environment']), fingerprint: learningFingerprint(repo, head, c.paths, seen), platform: process.platform, head });
277
286
  const finding = learningSha(JSON.stringify(c));
278
287
  store.handle.prepare("INSERT OR IGNORE INTO project_lesson(repo,identity,source,reviewer,finding,payload,sha,status) VALUES (?,?,?,?,?,?,?,'proposed')").run(repo, identity, source, reviewer, finding, payload, learningSha(payload));
279
288
  const id = Number(store.handle.prepare('SELECT id FROM project_lesson WHERE source=? AND finding=?').get(source, finding)['id']);
@@ -302,15 +311,15 @@ function lessonOf(row) {
302
311
  parseLearning([payload]);
303
312
  return { id: Number(row['id']), repo: String(row['repo']), source: Number(row['source']), reviewer: Number(row['reviewer']), status: String(row['status']), version: Number(row['version']), payload, sha: String(row['sha']), identity: String(row['identity']), adoptedBy: row['adopted_by'] == null ? null : String(row['adopted_by']) };
304
313
  }
305
- function supported(store, root, lesson) {
306
- if (lesson.identity !== learningIdentity(lesson.repo))
314
+ function supported(store, root, lesson, seen) {
315
+ if (lesson.identity !== learningIdentity(lesson.repo, seen))
307
316
  throw new Error('Project identity changed.');
308
317
  const capture = store.handle.prepare('SELECT catalog FROM learning_capture WHERE source=? AND reviewer=? AND repo=?').get(lesson.source, lesson.reviewer, lesson.repo);
309
318
  if (!capture || reviewedSource(store, lesson.reviewer) !== lesson.source)
310
319
  throw new Error('Learning source is unavailable.');
311
320
  verifyEvidence(store, root, lesson.source, lesson.payload.evidence, JSON.parse(String(capture['catalog'])));
312
321
  const run = store.getRun(lesson.source);
313
- if ((run?.headRevision ?? run?.baseRevision) !== lesson.payload.head || learningFingerprint(lesson.repo, lesson.payload.head, lesson.payload.paths) !== lesson.payload.fingerprint)
322
+ if ((run?.headRevision ?? run?.baseRevision) !== lesson.payload.head || learningFingerprint(lesson.repo, lesson.payload.head, lesson.payload.paths, seen) !== lesson.payload.fingerprint)
314
323
  throw new Error('Learning source changed.');
315
324
  }
316
325
  export function learningView(store, root, repo, actor, before = 0) {
@@ -377,15 +386,17 @@ export function learningContext(store, root, runId, phase, now = new Date(), val
377
386
  const run = store.getRun(runId), ref = run && store.refForId(run.taskRef), repo = ref?.repo;
378
387
  if (!run || !repo)
379
388
  return '';
389
+ // One read of the repository per command for this whole pass, recovery included.
390
+ const seen = new Map();
380
391
  try {
381
- recoverLearning(store, root, repo, now);
392
+ recoverLearning(store, root, repo, now, seen);
382
393
  return store.transact(() => {
383
394
  if (!store.schemaCurrent() || run.outcome !== null || !['planner', 'builder', 'repair', 'reviewer'].includes(run.role))
384
395
  return '';
385
396
  const runner = store.getRunner(run.runner)?.runner;
386
397
  if (!runner || runner.retiredAt !== null || !runner.repos.includes(repo) || (phase === 'review') !== (run.role === 'reviewer') || (phase === 'plan') !== (run.role === 'planner'))
387
398
  return '';
388
- const identity = learningIdentity(repo);
399
+ const identity = learningIdentity(repo, seen);
389
400
  const existing = store.handle.prepare('SELECT * FROM learning_snapshot WHERE run=?').get(runId);
390
401
  if (existing) {
391
402
  if (existing['repo'] !== repo || existing['identity'] !== identity || learningSha(String(existing['payload'])) !== existing['sha'])
@@ -409,11 +420,11 @@ export function learningContext(store, root, runId, phase, now = new Date(), val
409
420
  const l = lessonOf(row);
410
421
  if (!store.handle.prepare("SELECT 1 FROM learning_event WHERE lesson=? AND action='adopt' AND actor=?").get(l.id, l.adoptedBy ?? '') || l.source === source?.id || l.payload.kind !== 'project' || !l.adoptedBy || !store.accountCanAccess(l.adoptedBy, repo) || store.accountOf(l.adoptedBy)?.role !== 'approver' || !l.payload.phases.includes(phase) || l.payload.platform !== process.platform || (!unscopedPlan && !l.payload.paths.some(p => paths.some(t => p === t || p.startsWith(t.replace(/\/$/, '') + '/')))))
411
422
  continue;
412
- supported(store, root, l);
413
- if (l.payload.environment !== environmentFingerprint(store, repo) || learningFingerprint(repo, head, l.payload.paths) !== l.payload.fingerprint)
423
+ supported(store, root, l, seen);
424
+ if (l.payload.environment !== environmentFingerprint(store, repo) || learningFingerprint(repo, head, l.payload.paths, seen) !== l.payload.fingerprint)
414
425
  continue;
415
426
  const cwd = run.worktree ?? repo;
416
- const dirty = git(cwd, ['status', '--porcelain', '--untracked-files=all']).split('\n').some(line => { const p = line.slice(3); return configPath(p) || l.payload.paths.includes(p); });
427
+ const dirty = git(cwd, ['status', '--porcelain', '--untracked-files=all'], seen).split('\n').some(line => { const p = line.slice(3); return configPath(p) || l.payload.paths.includes(p); });
417
428
  if (!dirty)
418
429
  eligible.push(l);
419
430
  }
package/dist/proof.d.ts CHANGED
@@ -266,7 +266,7 @@ export type VerifyCommandFacts = {
266
266
  configured: true;
267
267
  ran: false;
268
268
  attemptFailed: true;
269
- failure?: "spawn-failed" | "timed-out" | "dependency-missing" | "setup-stale" | "setup-failed" | "tracked-files-changed" | "setup-changed-files" | "checkout-moved" | "cleanliness-unavailable" | "dependency-still-missing" | "retry-spawn-failed" | "retry-timed-out" | "custody-lost";
269
+ failure?: "spawn-failed" | "timed-out" | "dependency-missing" | "setup-stale" | "setup-failed" | "own-install-failed" | "tracked-files-changed" | "setup-changed-files" | "checkout-moved" | "cleanliness-unavailable" | "dependency-still-missing" | "retry-spawn-failed" | "retry-timed-out" | "custody-lost";
270
270
  } | {
271
271
  configured: true;
272
272
  ran: true;
@@ -390,6 +390,8 @@ export type CriterionCoverage = {
390
390
  * context: eligible only with the same text, verified ancestry, and
391
391
  * byte-identical relevant code. Never a judgement on this run. */
392
392
  priorSupport: "eligible" | "invalid" | "none";
393
+ /** The change's images and other binary files in one line, when it has any. */
394
+ assets?: string;
393
395
  };
394
396
  /** v40: the three words an evidence reviewer can say about one signed
395
397
  * criterion, and nothing else — `cannot-tell` is a CORRECT answer whenever
package/dist/proof.js CHANGED
@@ -722,7 +722,7 @@ function assessCapturedEvidence(input) {
722
722
  id: criterion.id, statement: criterion.statement, requiredEvidence: criterion.evidence,
723
723
  assessment: { evidenceState, detail }, state: evidenceState === "pass" ? "missing" : evidenceState,
724
724
  detail: [...detail, `criterion "${criterion.id}" awaits independent goal assessment`], answered, review: null,
725
- ...(coverage === undefined ? {} : { coverage: { state: coverage.state, inherited: coverage.inherited, items: [...coverage.items], gaps: [...coverage.gaps], priorSupport: coverage.priorSupport } }),
725
+ ...(coverage === undefined ? {} : { coverage: { state: coverage.state, inherited: coverage.inherited, items: [...coverage.items], gaps: [...coverage.gaps], priorSupport: coverage.priorSupport, ...(coverage.assets === undefined ? {} : { assets: coverage.assets }) } }),
726
726
  };
727
727
  });
728
728
  return { machineVerdict, verdict: machineVerdict === "refuted" ? "refuted" : "short", matrix,
@@ -761,6 +761,8 @@ function verificationFailureWords(failure) {
761
761
  return "automatic recovery stopped because the project setup or check changed";
762
762
  case "setup-failed":
763
763
  return "the approved setup command failed during automatic recovery";
764
+ case "own-install-failed":
765
+ return "the checkout's own dependency install failed after its package.json or lockfile changed";
764
766
  case "tracked-files-changed":
765
767
  return "automatic recovery stopped because tracked files no longer matched the built result";
766
768
  case "setup-changed-files":
@@ -828,7 +830,7 @@ export function adjudicate(input) {
828
830
  // must show, never a silent "judged from the patch".
829
831
  const coverage = found === undefined
830
832
  ? { state: "gap", inherited: false, items: [], gaps: ["the sealed review context carries no coverage entry for this criterion"], priorSupport: "none" }
831
- : { state: found.state, inherited: found.inherited, items: [...found.items], gaps: [...found.gaps], priorSupport: found.priorSupport };
833
+ : { state: found.state, inherited: found.inherited, items: [...found.items], gaps: [...found.gaps], priorSupport: found.priorSupport, ...(found.assets === undefined ? {} : { assets: found.assets }) };
832
834
  return { ...row, coverage };
833
835
  });
834
836
  if (!input.proofArtifactPresent) {
package/dist/provider.js CHANGED
@@ -169,6 +169,22 @@ const CLAUDE_REVIEW_JSON_SCHEMA = {
169
169
  additionalProperties: false,
170
170
  },
171
171
  },
172
+ // The automatic build review's reply (build-review.ts). Shape only;
173
+ // parseBuildFindings is the validator.
174
+ findings: {
175
+ type: "array",
176
+ items: {
177
+ type: "object",
178
+ properties: {
179
+ severity: { type: "string", enum: ["HIGH", "MEDIUM", "LOW"] },
180
+ file: { type: "string" },
181
+ line: { type: "integer", minimum: 1 },
182
+ scenario: { type: "string" },
183
+ },
184
+ required: ["severity", "file", "line", "scenario"],
185
+ additionalProperties: false,
186
+ },
187
+ },
172
188
  // The read request rides the same structured reply channel and
173
189
  // session. The machine validates the exact allowlist/hash/range
174
190
  // before supplying bytes; this only shapes the request.
@@ -6,6 +6,10 @@
6
6
  import type { Store } from "./store.js";
7
7
  /** The ledger action a person's (or lead's) completion of a result writes. */
8
8
  export declare const COMPLETION_ACTION = "assignment handoff checked";
9
+ /** Whether a task's family is marked complete. Completion is recorded on the
10
+ * family's root against its current version's latest result, so the root, a
11
+ * superseded revision and the current version all read Complete together. */
12
+ export declare function familyCompleted(store: Store, taskId: string): boolean;
9
13
  /** The files a build changed, renames on both sides; null when the inventory can't be read whole. */
10
14
  export declare function changedFilesOf(store: Store, runId: number, root: string): string[] | null;
11
15
  /** Everything a result's branch carries: this build's changes and every build of every task it revises (a
@@ -1,6 +1,17 @@
1
1
  import { readVerifiedArtifact } from "./evidence.js";
2
2
  /** The ledger action a person's (or lead's) completion of a result writes. */
3
3
  export const COMPLETION_ACTION = "assignment handoff checked";
4
+ /** Whether a task's family is marked complete. Completion is recorded on the
5
+ * family's root against its current version's latest result, so the root, a
6
+ * superseded revision and the current version all read Complete together. */
7
+ export function familyCompleted(store, taskId) {
8
+ const family = store.taskFamilyOf(taskId, null, true);
9
+ return store.handle.prepare(`SELECT 1 AS hit FROM action_ledger INDEXED BY work_completion
10
+ WHERE task_id IN (?, ?) AND action = ? AND source = 'work'
11
+ AND run_id = (SELECT run.id FROM run INDEXED BY work_result WHERE run.task_ref = ?
12
+ AND run.finished_at IS NOT NULL AND run.role IN ('builder','scout') ORDER BY run.id DESC LIMIT 1) LIMIT 1`)
13
+ .get(taskId, family?.root.id ?? taskId, COMPLETION_ACTION, family?.current.refId ?? store.lookupRef(taskId)?.id ?? -1) !== undefined;
14
+ }
4
15
  /** The files a build changed, renames on both sides; null when the inventory can't be read whole. */
5
16
  export function changedFilesOf(store, runId, root) {
6
17
  const stat = store.artifactsFor(runId).find(one => one.kind === "diff-stat");
@@ -1,7 +1,9 @@
1
- import { type RetentionCount, type RetentionPeriods } from "./retention.js";
1
+ import { type RetentionCount, type RetentionKind, type RetentionPeriods } from "./retention.js";
2
2
  export declare const RETENTION_CSS: string;
3
+ /** `chosen`: the kinds someone chose a period for; the rest keep their default. */
3
4
  export type RetentionView = {
4
5
  periods: RetentionPeriods;
6
+ chosen: readonly RetentionKind[];
5
7
  next: RetentionCount[];
6
8
  lastSweep: string | null;
7
9
  csrf: string;
@@ -4,7 +4,7 @@
4
4
  * take a step-up.
5
5
  */
6
6
  import { bytesWords } from "./storage.js";
7
- import { PERIOD_CHOICES, RETENTION_KINDS, countWords, periodWords } from "./retention.js";
7
+ import { RETENTION_KINDS, countWords, periodChoices, periodLabel } from "./retention.js";
8
8
  const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
9
9
  export const RETENTION_CSS = `.retention{max-width:720px;min-width:0}.retention-next{margin:0 0 16px}` +
10
10
  `.retention form{display:grid;gap:0}.retention .kind{display:grid;grid-template-columns:minmax(0,1fr) 11rem;gap:4px 16px;align-items:center;padding:12px 0;border-bottom:1px solid var(--border)}` +
@@ -26,8 +26,8 @@ export function retentionHtml(view, notice) {
26
26
  const note = notice.problem ? `<p class="problem" role="alert">${e(notice.problem)}</p>` : notice.said ? `<p role="status">${e(notice.said)}</p>` : "";
27
27
  const rows = RETENTION_KINDS.map(({ kind, label, detail }) => {
28
28
  const current = view.periods[kind];
29
- const choices = PERIOD_CHOICES.includes(current) ? PERIOD_CHOICES : [...PERIOD_CHOICES.slice(0, -1), current, null].sort((a, b) => (a ?? Infinity) - (b ?? Infinity));
30
- const options = choices.map(days => `<option value="${days === null ? "forever" : days}"${days === current ? " selected" : ""}>${days === null ? "Forever" : `${periodWords(days)[0].toUpperCase()}${periodWords(days).slice(1)}`}</option>`).join("");
29
+ const chosen = view.chosen.includes(kind);
30
+ const options = periodChoices(kind, current).map(days => `<option value="${days === null ? "forever" : days}"${days === current ? " selected" : ""}>${e(periodLabel(kind, days, chosen || days !== current))}</option>`).join("");
31
31
  return `<div class="kind" data-kind="${kind}"><label class="name" for="keep-${kind}">${e(label)}</label><p class="meta">${e(detail)}</p><select id="keep-${kind}" name="${kind}">${options}</select></div>`;
32
32
  }).join("");
33
33
  const last = view.lastSweep === null ? "" : ` Last sweep ${e(view.lastSweep.slice(0, 10))}.`;
@@ -35,5 +35,5 @@ export function retentionHtml(view, notice) {
35
35
  `<form method="post" action="/settings/retention"><input type="hidden" name="csrf" value="${e(view.csrf)}">${rows}` +
36
36
  `<label class="step-up">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>` +
37
37
  `<button type="submit">Save</button></form>` +
38
- `<p class="meta">Never removed: the action ledger, and anything a task still needs (unfinished tasks, results not yet completed, anything on hold).</p></article>`;
38
+ `<p class="meta">Never removed: the action ledger, and anything a task still needs (unfinished tasks, results Ready for review, anything on hold).</p></article>`;
39
39
  }
@@ -1,5 +1,8 @@
1
- import type { Store } from "./store.js";
2
- export declare const RETENTION_SCHEMA = "\nCREATE TABLE IF NOT EXISTS retention_setting (\n kind TEXT PRIMARY KEY CHECK (kind IN ('evidence', 'checkouts', 'chat', 'notifications')),\n -- NULL is forever; a row only exists once someone chose.\n days INTEGER CHECK (days IS NULL OR (days >= 7 AND days <= 3650)),\n updated_by TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n";
1
+ import type { Database, Store } from "./store.js";
2
+ export declare const RETENTION_SCHEMA: string;
3
+ /** A file from before 1-day evidence keeps `days >= 7` in its CHECK: rebuild the table with the wider one, keeping
4
+ * every row, in one transaction. A file already widened is untouched. */
5
+ export declare function widenRetentionSchema(db: Database): void;
3
6
  export type RetentionKind = "evidence" | "checkouts" | "chat" | "notifications";
4
7
  export type RetentionPeriods = Record<RetentionKind, number | null>;
5
8
  export declare const RETENTION_KINDS: readonly {
@@ -7,19 +10,23 @@ export declare const RETENTION_KINDS: readonly {
7
10
  label: string;
8
11
  detail: string;
9
12
  }[];
10
- /** The periods the page offers; the command line takes any number of days from 7 to 3650. */
11
- export declare const PERIOD_CHOICES: readonly (number | null)[];
12
- export declare const MIN_DAYS = 7;
13
+ export declare const PERIOD_CHOICES: Readonly<Record<RetentionKind, readonly (number | null)[]>>;
14
+ export declare const MIN_DAYS = 1;
13
15
  export declare const MAX_DAYS = 3650;
14
16
  /** A sweep is due once a day. */
15
17
  export declare const SWEEP_EVERY_MS = 86400000;
16
18
  /** At most this many runs, messages, notifications or records go per kind per sweep; the next sweep takes the rest. */
17
19
  export declare const SWEEP_BATCH = 2000;
18
- export declare const FOREVER: RetentionPeriods;
20
+ /** What each kind keeps until someone chooses: evidence 28 days, everything else forever. */
21
+ export declare const DEFAULT_PERIODS: RetentionPeriods;
19
22
  export declare function isRetentionKind(value: string): value is RetentionKind;
20
23
  /** "forever", or a number of days ("90", "90d"), weeks ("12w") or years ("1y"); undefined when it's none of those or out of range. */
21
24
  export declare function parsePeriod(text: string): number | null | undefined;
22
25
  export declare function periodWords(days: number | null): string;
26
+ /** What the page offers for `kind`, with `current` among them (in order) when it was set some other way. */
27
+ export declare function periodChoices(kind: RetentionKind, current: number | null): readonly (number | null)[];
28
+ /** A period as the page and `retention show` say it: "28 days (default)" when nobody chose, "30 days (custom)" when it's not one the page offers. */
29
+ export declare function periodLabel(kind: RetentionKind, days: number | null, chosen: boolean): string;
23
30
  export type RetentionCount = {
24
31
  kind: RetentionKind;
25
32
  days: number | null;
package/dist/retention.js CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Retention (v105): how long Toolroll keeps run evidence and logs,
3
- * finished checkouts' records, chat messages and notifications. Each kind
4
- * keeps everything ("forever") until an instance operator chooses a period;
5
- * a daily sweep then deletes what is older than it and writes one action
6
- * ledger entry saying what went and about how much space it freed.
3
+ * finished checkouts' records, chat messages and notifications. Until an
4
+ * instance operator chooses a period, evidence is kept 28 days and every
5
+ * other kind forever; a daily sweep deletes what is older than its period
6
+ * and writes one action ledger entry saying what went and about how much
7
+ * space it freed.
7
8
  *
8
9
  * Never deleted, whatever the settings: the action ledger (it is append-only),
9
10
  * and anything a task still needs. A task still needs its things until it is
@@ -21,24 +22,47 @@ import { join } from "node:path";
21
22
  import { RETENTION_NOTE } from "./evidence.js";
22
23
  import { COMPLETION_ACTION } from "./result-completion.js";
23
24
  import { bytesWords, treeBytes } from "./storage.js";
24
- export const RETENTION_SCHEMA = `
25
- CREATE TABLE IF NOT EXISTS retention_setting (
25
+ const RETENTION_TABLE = (name) => `
26
+ CREATE TABLE IF NOT EXISTS ${name} (
26
27
  kind TEXT PRIMARY KEY CHECK (kind IN ('evidence', 'checkouts', 'chat', 'notifications')),
27
28
  -- NULL is forever; a row only exists once someone chose.
28
- days INTEGER CHECK (days IS NULL OR (days >= 7 AND days <= 3650)),
29
+ days INTEGER CHECK (days IS NULL OR (days >= 1 AND days <= 3650)),
29
30
  updated_by TEXT NOT NULL,
30
31
  updated_at TEXT NOT NULL
31
32
  );
32
33
  `;
34
+ export const RETENTION_SCHEMA = RETENTION_TABLE("retention_setting");
35
+ /** A file from before 1-day evidence keeps `days >= 7` in its CHECK: rebuild the table with the wider one, keeping
36
+ * every row, in one transaction. A file already widened is untouched. */
37
+ export function widenRetentionSchema(db) {
38
+ const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'retention_setting'").get();
39
+ if (row === undefined || !/days\s*>=\s*7\b/.test(String(row["sql"])))
40
+ return;
41
+ db.exec("BEGIN IMMEDIATE");
42
+ try {
43
+ db.exec(RETENTION_TABLE("retention_setting_next"));
44
+ db.exec("INSERT INTO retention_setting_next (kind, days, updated_by, updated_at) SELECT kind, days, updated_by, updated_at FROM retention_setting");
45
+ db.exec("DROP TABLE retention_setting");
46
+ db.exec("ALTER TABLE retention_setting_next RENAME TO retention_setting");
47
+ db.exec("COMMIT");
48
+ }
49
+ catch (error) {
50
+ db.exec("ROLLBACK");
51
+ throw error;
52
+ }
53
+ }
33
54
  export const RETENTION_KINDS = [
34
55
  { kind: "evidence", label: "Run evidence and logs", detail: "Diffs, check logs, screenshots and reports saved for each run" },
35
56
  { kind: "checkouts", label: "Finished checkout records", detail: "Records of build checkouts that were let go and are no longer on disk" },
36
57
  { kind: "chat", label: "Chat messages", detail: "Messages in lead and team chats" },
37
58
  { kind: "notifications", label: "Notifications", detail: "Delivered or dismissed notifications" },
38
59
  ];
39
- /** The periods the page offers; the command line takes any number of days from 7 to 3650. */
40
- export const PERIOD_CHOICES = [30, 90, 180, 365, 730, null];
41
- export const MIN_DAYS = 7;
60
+ /** The periods the page offers for each kind; the command line takes any number of days from 1 to 3650. */
61
+ const LONG_CHOICES = [30, 90, 180, 365, 730, null];
62
+ export const PERIOD_CHOICES = {
63
+ evidence: [1, 7, 14, 28, null], checkouts: LONG_CHOICES, chat: LONG_CHOICES, notifications: LONG_CHOICES,
64
+ };
65
+ export const MIN_DAYS = 1;
42
66
  export const MAX_DAYS = 3650;
43
67
  const DAY_MS = 86_400_000;
44
68
  /** A sweep is due once a day. */
@@ -46,7 +70,8 @@ export const SWEEP_EVERY_MS = DAY_MS;
46
70
  /** At most this many runs, messages, notifications or records go per kind per sweep; the next sweep takes the rest. */
47
71
  export const SWEEP_BATCH = 2000;
48
72
  const SWEEP_CURSOR = "retention:last-sweep";
49
- export const FOREVER = { evidence: null, checkouts: null, chat: null, notifications: null };
73
+ /** What each kind keeps until someone chooses: evidence 28 days, everything else forever. */
74
+ export const DEFAULT_PERIODS = { evidence: 28, checkouts: null, chat: null, notifications: null };
50
75
  export function isRetentionKind(value) {
51
76
  return RETENTION_KINDS.some(one => one.kind === value);
52
77
  }
@@ -67,7 +92,20 @@ export function periodWords(days) {
67
92
  return "forever";
68
93
  if (days % 365 === 0)
69
94
  return days === 365 ? "1 year" : `${days / 365} years`;
70
- return `${days} days`;
95
+ return days === 1 ? "1 day" : `${days} days`;
96
+ }
97
+ /** What the page offers for `kind`, with `current` among them (in order) when it was set some other way. */
98
+ export function periodChoices(kind, current) {
99
+ const offered = PERIOD_CHOICES[kind];
100
+ return offered.includes(current) ? offered : [...offered, current].sort((a, b) => (a ?? Infinity) - (b ?? Infinity));
101
+ }
102
+ /** A period as the page and `retention show` say it: "28 days (default)" when nobody chose, "30 days (custom)" when it's not one the page offers. */
103
+ export function periodLabel(kind, days, chosen) {
104
+ const words = periodWords(days);
105
+ const label = `${words[0].toUpperCase()}${words.slice(1)}`;
106
+ if (!chosen)
107
+ return `${label} (default)`;
108
+ return PERIOD_CHOICES[kind].includes(days) ? label : `${label} (custom)`;
71
109
  }
72
110
  /** Tasks that no longer need anything kept: cancelled, or done with their result marked complete; not on hold, not
73
111
  * running, not a release candidate, and no revision below it (at any depth) still unfinished — cancelled or completed,
@@ -121,6 +121,9 @@ export type CriterionContextCoverage = {
121
121
  items: string[];
122
122
  gaps: string[];
123
123
  priorSupport: "eligible" | "invalid" | "none";
124
+ /** One line for the change's images and other binary files, which are
125
+ * never read as text and so are neither items nor gaps. */
126
+ assets?: string;
124
127
  };
125
128
  export type ReviewContextInventory = {
126
129
  schema: 1 | 2 | 3;
@@ -43,7 +43,7 @@ import { isVerificationReceipt } from "./verification-evidence.js";
43
43
  */
44
44
  import { createHash } from "node:crypto";
45
45
  import { hasForbiddenControls } from "./decision.js";
46
- import { EVIDENCE_CAPS, readVerifiedArtifact, redactSecretLines, scanForSecrets, storeEvidence } from "./evidence.js";
46
+ import { EVIDENCE_CAPS, isBinaryAssetPath, isImagePath, readVerifiedArtifact, redactSecretLines, scanForSecrets, storeEvidence } from "./evidence.js";
47
47
  import { parseProof } from "./proof.js";
48
48
  /** Explicit bounds. Content bytes are UTF-8 of the stored (possibly
49
49
  * redacted) text; the aggregate is the sum over every stored item. */
@@ -420,7 +420,8 @@ export function parseReviewContext(raw) {
420
420
  const c = one;
421
421
  if (!str(c["id"], 64) || coverageIds.has(c["id"]) || (c["state"] !== "patch" && c["state"] !== "context" && c["state"] !== "gap") ||
422
422
  typeof c["inherited"] !== "boolean" || !strList(c["paths"], 200) || !strList(c["items"], r["schema"] === 3 ? contentLimits.items : 64) || !strList(c["gaps"], r["schema"] === 3 ? 400 : 64) ||
423
- (c["priorSupport"] !== "eligible" && c["priorSupport"] !== "invalid" && c["priorSupport"] !== "none")) {
423
+ (c["priorSupport"] !== "eligible" && c["priorSupport"] !== "invalid" && c["priorSupport"] !== "none") ||
424
+ (c["assets"] !== undefined && !str(c["assets"], 300))) {
424
425
  return { ok: false, problem: `coverage ${index}: malformed` };
425
426
  }
426
427
  if (c["items"].some(id => !ids.has(id) || !items.find(item => item.id === id)?.criteria.includes(String(c["id"]))))
@@ -431,6 +432,7 @@ export function parseReviewContext(raw) {
431
432
  coverage.push({
432
433
  id: c["id"], state: c["state"], inherited: c["inherited"], paths: [...c["paths"]],
433
434
  items: [...c["items"]], gaps: [...c["gaps"]], priorSupport: c["priorSupport"],
435
+ ...(c["assets"] === undefined ? {} : { assets: c["assets"] }),
434
436
  });
435
437
  }
436
438
  return {
@@ -499,6 +501,32 @@ async function treeEntries(git, cwd, revision, paths) {
499
501
  }
500
502
  return entries;
501
503
  }
504
+ /** "12 images, 3.4 MB, in the run's evidence": every binary file at the head
505
+ * in one line. Files deleted by the change are not counted. */
506
+ async function assetSummary(git, cwd, head, assets) {
507
+ const sizes = new Map();
508
+ const paths = [...assets].filter(safeContextPath);
509
+ for (let at = 0; at < paths.length; at += 500) {
510
+ const listed = await treeEntries(git, cwd, head, paths.slice(at, at + 500));
511
+ for (const [path, entry] of listed ?? [])
512
+ if (regular(entry))
513
+ sizes.set(path, entry.size);
514
+ }
515
+ if (sizes.size === 0)
516
+ return null;
517
+ const images = [...sizes.keys()].filter(isImagePath).length;
518
+ const others = sizes.size - images;
519
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
520
+ const counted = [...(images > 0 ? [plural(images, "image")] : []), ...(others > 0 ? [plural(others, images > 0 ? "other binary file" : "binary file")] : [])].join(", ");
521
+ return `${counted}, ${assetBytesWords([...sizes.values()].reduce((sum, one) => sum + one, 0))}, in the run's evidence`;
522
+ }
523
+ function assetBytesWords(bytes) {
524
+ if (bytes >= 1024 * 1024)
525
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
526
+ if (bytes >= 1024)
527
+ return `${Math.round(bytes / 1024)} KB`;
528
+ return `${bytes} bytes`;
529
+ }
502
530
  function regular(entry) {
503
531
  return entry !== undefined && entry.type === "blob" && ["100644", "100755"].includes(entry.mode) && SHA1.test(entry.object) && Number.isSafeInteger(entry.size) && entry.size >= 0;
504
532
  }
@@ -822,11 +850,22 @@ export async function deriveReviewContext(store, git, args, lineage) {
822
850
  gapAll("capture-failed", "the committed source paths in the approved scope could not be listed");
823
851
  }
824
852
  }
853
+ // Files named as images or other binaries are never text context: they are
854
+ // counted in one summary line, never listed as gaps or held against the
855
+ // bounds. A text-named file that turns out not to be text stays a gap.
856
+ const assets = new Set();
857
+ const textOnly = (paths) => paths.filter(path => {
858
+ if (!isBinaryAssetPath(path))
859
+ return true;
860
+ assets.add(path);
861
+ return false;
862
+ });
863
+ generalPaths.splice(0, generalPaths.length, ...textOnly(generalPaths));
825
864
  const specificFor = new Map();
826
865
  for (const criterion of sourceProof?.criteria ?? []) {
827
866
  const cited = [...new Set(criterion.evidence.filter(one => one.kind === "changed-path").map(one => one.ref))].filter(safeContextPath).sort();
828
867
  const inherited = parentContext?.coverage.find(one => one.id === criterion.id)?.paths ?? [];
829
- const combined = [...new Set([...cited, ...inherited])].filter(safeContextPath).sort();
868
+ const combined = textOnly([...new Set([...cited, ...inherited])].filter(safeContextPath).sort());
830
869
  if (combined.length > 0)
831
870
  specificFor.set(criterion.id, combined);
832
871
  }
@@ -852,7 +891,7 @@ export async function deriveReviewContext(store, git, args, lineage) {
852
891
  // A proof's path citations are relevance hints, not permission to omit other
853
892
  // changed files. Deliver new revision files and tests in full as supplemental
854
893
  // context without inventing criterion ownership or inheriting a judgement.
855
- for (const path of args.patchPaths) {
894
+ for (const path of textOnly([...args.patchPaths])) {
856
895
  if (safeContextPath(path) && !relevance.has(path))
857
896
  relevance.set(path, { criteria: new Set(), why: new Set(["candidate-changed-path"]) });
858
897
  }
@@ -965,6 +1004,7 @@ export async function deriveReviewContext(store, git, args, lineage) {
965
1004
  }
966
1005
  }
967
1006
  inventory.items = [...itemsByPath.values()];
1007
+ const assetLine = await assetSummary(git, args.worktree, args.head, assets);
968
1008
  // 6. Prior review provenance — context, proved per criterion, never a verdict.
969
1009
  const sourceDiff = sourceArtifacts.find(one => one.kind === "terminal-diff") ?? null;
970
1010
  const sourceProofArtifact = sourceArtifacts.find(one => one.kind === "proof") ?? null;
@@ -1039,5 +1079,5 @@ export async function deriveReviewContext(store, git, args, lineage) {
1039
1079
  const gapsFor = state === "gap" && relevantGaps.length === 0 ? [plan.paths.length === 0 ? "no relevant source paths are known" : "inherited context is incomplete"] : relevantGaps;
1040
1080
  return { id: plan.id, state, inherited: plan.inherited && lineage.sourceRun !== args.runId, paths: plan.paths, items, gaps: gapsFor, priorSupport };
1041
1081
  });
1042
- return finish(coverage);
1082
+ return finish(assetLine === null ? coverage : coverage.map(one => ({ ...one, assets: assetLine })));
1043
1083
  }
@@ -0,0 +1,41 @@
1
+ /** The per-project automatic review switch and the one review each finished
2
+ * build gets (build-review.ts runs it): tables, and the read-only views every
3
+ * surface shows. No schema version bump. Kept free of heavy imports so the
4
+ * status readers can use it without an import cycle. */
5
+ import type { Store } from "./store.js";
6
+ export type FindingSeverity = "HIGH" | "MEDIUM" | "LOW";
7
+ /** One automatic-review finding: how bad, where, and how it fails. */
8
+ export type BuildFinding = {
9
+ severity: FindingSeverity;
10
+ file: string;
11
+ line: number;
12
+ scenario: string;
13
+ };
14
+ export declare const REVIEW_SCHEMA = "\n-- A project's explicit review switch. No row: on while the project is under a hands-off mode, off otherwise.\nCREATE TABLE IF NOT EXISTS review_switch (\n repo TEXT PRIMARY KEY,\n enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),\n changed_by TEXT NOT NULL,\n changed_at TEXT NOT NULL\n);\n-- One row per reviewed build run: pending until its one reviewer run settles it.\nCREATE TABLE IF NOT EXISTS build_review (\n run INTEGER PRIMARY KEY,\n task_id TEXT NOT NULL,\n repo TEXT NOT NULL,\n state TEXT NOT NULL CHECK (state IN ('pending', 'reviewed', 'not-reviewed')),\n request INTEGER,\n reviewer_run INTEGER,\n findings_json TEXT,\n reason TEXT,\n sent_back_as TEXT,\n queued_at TEXT NOT NULL,\n finished_at TEXT\n);\nCREATE INDEX IF NOT EXISTS build_review_pending ON build_review (repo) WHERE state = 'pending';\nCREATE INDEX IF NOT EXISTS build_review_sent_back ON build_review (sent_back_as) WHERE sent_back_as IS NOT NULL;\n";
15
+ export type ReviewSwitch = {
16
+ on: boolean;
17
+ source: "project" | "hands-off" | "default";
18
+ changedBy: string | null;
19
+ changedAt: string | null;
20
+ };
21
+ /** How the policy log words a switch state. */
22
+ export declare function reviewSwitchWords(state: ReviewSwitch): string;
23
+ export type BuildReviewView = {
24
+ run: number;
25
+ state: "pending" | "reviewed" | "not-reviewed";
26
+ reviewerRun: number | null;
27
+ /** Why it was not reviewed, in words. */
28
+ reason: string | null;
29
+ /** HIGH findings; they sent the task back when `sentBackAs` is set. */
30
+ high: BuildFinding[];
31
+ /** MEDIUM and LOW findings: suggested follow-ups, never blocking. */
32
+ followUps: BuildFinding[];
33
+ sentBackAs: string | null;
34
+ finishedAt: string | null;
35
+ };
36
+ export declare function buildReviewOf(store: Pick<Store, "handle">, runId: number): BuildReviewView | null;
37
+ export declare const findingWords: (finding: BuildFinding) => string;
38
+ /** The review's standing in one line, or null while nothing was asked. */
39
+ export declare function buildReviewHeadline(view: BuildReviewView | null): string | null;
40
+ /** Plain lines for `task show` and `assignment show`. */
41
+ export declare function buildReviewLines(view: BuildReviewView | null): string[];