toolroll 0.9.92 → 0.9.93

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 (65) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/assignment-status.d.ts +1 -1
  3. package/dist/assignment-ui.js +2 -2
  4. package/dist/assignment.js +11 -3
  5. package/dist/browser/workspace.css +1 -1
  6. package/dist/browser/workspace.js +30 -30
  7. package/dist/browser-crew.js +2 -2
  8. package/dist/browser-workspace.d.ts +3 -5
  9. package/dist/builder.js +21 -2
  10. package/dist/check-facts.d.ts +124 -0
  11. package/dist/check-facts.js +272 -0
  12. package/dist/check-report.d.ts +5 -0
  13. package/dist/check-report.js +4 -3
  14. package/dist/cli.js +5 -0
  15. package/dist/contracts/console-api.d.ts +2 -2
  16. package/dist/contracts/console-api.js +2 -2
  17. package/dist/contracts/release-check.d.ts +25 -1
  18. package/dist/contracts/release-check.js +19 -0
  19. package/dist/desktop-update.d.ts +2 -2
  20. package/dist/desktop-update.js +13 -9
  21. package/dist/eval-hooks.d.ts +55 -0
  22. package/dist/eval-hooks.js +227 -4
  23. package/dist/flaky.d.ts +17 -0
  24. package/dist/flaky.js +9 -0
  25. package/dist/needs-you.d.ts +7 -7
  26. package/dist/needs-you.js +12 -10
  27. package/dist/operate.js +138 -18
  28. package/dist/project-delete.js +5 -1
  29. package/dist/project-knowledge.d.ts +6 -6
  30. package/dist/push.d.ts +10 -3
  31. package/dist/push.js +22 -6
  32. package/dist/release-adapters.js +11 -1
  33. package/dist/release-approval.d.ts +191 -0
  34. package/dist/release-approval.js +382 -0
  35. package/dist/release-auto.js +13 -4
  36. package/dist/release-card.d.ts +73 -4
  37. package/dist/release-card.js +168 -23
  38. package/dist/release-cli.js +9 -1
  39. package/dist/release.d.ts +30 -0
  40. package/dist/release.js +42 -3
  41. package/dist/result-acts.d.ts +3 -3
  42. package/dist/result-acts.js +1 -1
  43. package/dist/server/guards.js +1 -0
  44. package/dist/server/pages-assets.js +1 -1
  45. package/dist/server/render-release.d.ts +17 -0
  46. package/dist/server/render-release.js +84 -0
  47. package/dist/server/render-results.d.ts +0 -2
  48. package/dist/server/render-results.js +4 -6
  49. package/dist/server/render-tasks.js +3 -3
  50. package/dist/server/route-table.d.ts +1 -1
  51. package/dist/server/route-table.js +7 -1
  52. package/dist/server/tasks.d.ts +15 -0
  53. package/dist/server/tasks.js +102 -20
  54. package/dist/setup-guide.d.ts +2 -2
  55. package/dist/stop-proof.d.ts +67 -0
  56. package/dist/stop-proof.js +293 -0
  57. package/dist/store.d.ts +70 -31
  58. package/dist/store.js +335 -95
  59. package/dist/surface.js +1 -1
  60. package/dist/task-outcome-cli.js +19 -0
  61. package/dist/toolroll-update-ui.js +2 -2
  62. package/dist/toolroll-update.js +1 -0
  63. package/dist/work-index.js +7 -9
  64. package/dist/work-summary.d.ts +2 -2
  65. package/package.json +1 -1
@@ -233,7 +233,9 @@ export declare const obligationSnapshotSchema: z.ZodObject<{
233
233
  }, z.core.$strict>>;
234
234
  }, z.core.$strict>;
235
235
  export type ObligationSnapshot = z.infer<typeof obligationSnapshotSchema>;
236
- /** What the plane writes on the check's stdin before closing it. */
236
+ /** What the plane writes on the check's stdin before closing it. `learn` (docs/plans/learning-loops.md) is advisory and
237
+ * apart from everything else: exact case identities the check is asked to say it ran, on their own line
238
+ * (LEARN_PREFIX), never in the report and never part of what the check owes. */
237
239
  export type CheckHandshake = {
238
240
  nonce: string;
239
241
  mode: "full" | "targeted";
@@ -241,7 +243,29 @@ export type CheckHandshake = {
241
243
  watch: string[];
242
244
  owed?: CheckPart[];
243
245
  ciSubstitute?: boolean;
246
+ learn?: string[];
244
247
  };
248
+ /** The advisory learning line a check prints before its report: `TOOLROLL-LEARN <json>`, at most 64 KiB, holding only
249
+ * the requested identities that ran. Never part of the report: buildReport, reportLine and fullCheckProblem don't read
250
+ * it, and a missing, malformed or oversized one loses learning evidence only. */
251
+ export declare const LEARN_PREFIX = "TOOLROLL-LEARN ";
252
+ export declare const LEARN_MAX_BYTES: number;
253
+ export declare const LEARN_MAX_IDENTITIES = 200;
254
+ export declare const isExactCase: (identity: unknown) => identity is string;
255
+ /** What the plane asks a check to report for learning: exact cases only, first seen first, at most 200. */
256
+ export declare function learnRequest(identities: readonly unknown[]): string[];
257
+ export declare const learnLineSchema: z.ZodObject<{
258
+ nonce: z.ZodString;
259
+ results: z.ZodArray<z.ZodObject<{
260
+ identity: z.ZodString;
261
+ state: z.ZodEnum<{
262
+ failed: "failed";
263
+ passed: "passed";
264
+ skipped: "skipped";
265
+ }>;
266
+ }, z.core.$strict>>;
267
+ }, z.core.$strict>;
268
+ export type LearnLine = z.infer<typeof learnLineSchema>;
245
269
  /** A unit test: its file and full name. */
246
270
  export declare const unitIdentity: (file: string, testName: string) => string;
247
271
  /** A unit file. */
@@ -146,6 +146,25 @@ export const obligationSnapshotSchema = versioned(1, {
146
146
  base: z.int().min(1),
147
147
  earlier: z.array(z.strictObject({ run: z.int().min(1), reportDigest: z.string().max(64).nullable(), failed: z.array(z.string().min(1).max(2600)).max(5000) })).max(200),
148
148
  });
149
+ // ------------------------------------------------------------------ advisory learning lines (docs/plans/learning-loops.md)
150
+ /** The advisory learning line a check prints before its report: `TOOLROLL-LEARN <json>`, at most 64 KiB, holding only
151
+ * the requested identities that ran. Never part of the report: buildReport, reportLine and fullCheckProblem don't read
152
+ * it, and a missing, malformed or oversized one loses learning evidence only. */
153
+ export const LEARN_PREFIX = "TOOLROLL-LEARN ";
154
+ export const LEARN_MAX_BYTES = 64 * 1024;
155
+ export const LEARN_MAX_IDENTITIES = 200;
156
+ /** An exact case: a unit test (`unit:<file>::<name>`) or a journey (`journey:<script>:<kind>:<group>::<name>`), never a
157
+ * file, group, part or version; one line, no control characters, at most 2600 characters. */
158
+ const EXACT_CASE = /^(?:unit:[^\u0000-\u001f\\:][^\u0000-\u001f\\]*?::[^\u0000-\u001f]+|journey:(?:app|flows):(?:scripted|real):[A-Za-z0-9._/-]{1,200}::[^\u0000-\u001f]+)$/;
159
+ export const isExactCase = (identity) => typeof identity === "string" && identity.length <= 2600 && EXACT_CASE.test(identity);
160
+ /** What the plane asks a check to report for learning: exact cases only, first seen first, at most 200. */
161
+ export function learnRequest(identities) {
162
+ return [...new Set(identities.filter(isExactCase))].slice(0, LEARN_MAX_IDENTITIES);
163
+ }
164
+ export const learnLineSchema = z.strictObject({
165
+ nonce: hex64,
166
+ results: z.array(z.strictObject({ identity: z.string().min(1).max(2600), state: z.enum(["passed", "failed", "skipped"]) })).max(LEARN_MAX_IDENTITIES),
167
+ });
149
168
  // ------------------------------------------------------------------ identities
150
169
  /** A unit test: its file and full name. */
151
170
  export const unitIdentity = (file, testName) => `unit:${file}::${testName}`;
@@ -74,8 +74,8 @@ export declare function desktopUpdateWaitingOf(databaseFile: string, inTheWay: (
74
74
  /** The same settling reconcile the background worker runs: a finished run's leftover record of a process that never
75
75
  * started settles once the run's process groups are proven gone. A record nothing can prove either way stays. */
76
76
  export declare function settleLeftoverRecords(db: DatabaseSync, now: Date): number;
77
- /** Store.stopQuiescenceProblem's reasons, said for a person: no internal terms, and `toolroll run settle` only where it
78
- * can clear the record (it refuses while anything of the run is alive). */
77
+ /** Store.stopQuiescenceProblem's reasons, said for a person: no internal terms. Nobody vouches that a run stopped:
78
+ * Toolroll proves it itself (stop-proof.ts), and `toolroll run settle` only has it look again now. */
79
79
  export declare function plainLingering(fallbackRun: number, problem: string): LingeringRun;
80
80
  export declare function previewDesktopUpdate(stateDir: string, installed: string, candidate: string, label: string, hooks?: UpdateHooks): Promise<{
81
81
  old: DesktopBundle;
@@ -193,11 +193,11 @@ export function lingeringWork(db) {
193
193
  const lingering = lingeringRun(db);
194
194
  return lingering && lingeringWords(lingering);
195
195
  }
196
- export const lingeringWords = (l) => l.action ? `${l.on}. If nothing of it is running, run: ${l.action}` : `${l.on}.`;
196
+ export const lingeringWords = (l) => l.action ? `${l.on}. To have Toolroll check again, run: ${l.action}` : `${l.on}.`;
197
197
  /** What an update says when it stops waiting on `l`: what is in the way, the command, and that new work resumed. */
198
198
  export const stoppedWaitingWords = (l) => `Stopped waiting after 2 minutes. ${l.on}. ${clearWords(l)} New work resumed; nothing was changed.`;
199
199
  /** The next step once an update has stopped on `l`. */
200
- export const clearWords = (l) => l.action ? `If nothing of it is running, run ${l.action}, then update again.` : "Update again once it has stopped.";
200
+ export const clearWords = (l) => l.action ? `To have Toolroll check again, run ${l.action}, then update again.` : "Update again once it has stopped.";
201
201
  export function updateWaitingWords(o) {
202
202
  const { app, version, stopped, lingering: l } = o;
203
203
  const update = `${app ? "App update" : "Update"} to ${version}`;
@@ -206,7 +206,7 @@ export function updateWaitingWords(o) {
206
206
  const words = `${update} is waiting for ${on}. New work is paused.`;
207
207
  return { app, version, stopped: false, run: null, on, action: null, words, short: words };
208
208
  }
209
- const next = l.action ? ` If nothing of it is running, run ${l.action}${stopped ? ", then update again" : ""}.` : stopped ? " Update again once it has stopped." : "";
209
+ const next = l.action ? ` To have Toolroll check again, run ${l.action}${stopped ? ", then update again" : ""}.` : stopped ? " Update again once it has stopped." : "";
210
210
  return { app, version, stopped, run: l.run, on: l.on, action: l.action,
211
211
  words: `${update} ${stopped ? "stopped waiting" : "is waiting"}: ${l.on}.${next}`,
212
212
  short: `${update} ${stopped ? `stopped: run #${l.run} is in the way` : `is waiting on run #${l.run}`}.${next}` };
@@ -284,18 +284,22 @@ export function desktopUpdateWaitingOf(databaseFile, inTheWay, stateDirs = [dirn
284
284
  export function settleLeftoverRecords(db, now) {
285
285
  return new Store(db).settleUnspawnedWitnesses(now);
286
286
  }
287
- /** Store.stopQuiescenceProblem's reasons, said for a person: no internal terms, and `toolroll run settle` only where it
288
- * can clear the record (it refuses while anything of the run is alive). */
287
+ /** Store.stopQuiescenceProblem's reasons, said for a person: no internal terms. Nobody vouches that a run stopped:
288
+ * Toolroll proves it itself (stop-proof.ts), and `toolroll run settle` only has it look again now. */
289
289
  export function plainLingering(fallbackRun, problem) {
290
290
  const run = Number(/run #(\d+)/.exec(problem)?.[1] ?? fallbackRun);
291
- const settle = `toolroll run settle ${run} --why "it is not running"`;
291
+ const settle = `toolroll run settle ${run}`;
292
292
  const pid = /process (\d+)/.exec(problem)?.[1];
293
293
  if (/incomplete spawn witness|before its process witness was recorded/.test(problem))
294
- return { run, on: `Run #${run} finished, but Toolroll has no process ID for one of its processes, so it can't confirm that process ended`, action: settle };
295
- if (/belongs to another host|another host/.test(problem))
294
+ return { run, on: `Run #${run} finished, and Toolroll is making sure none of its processes is still running`, action: settle };
295
+ if (/belongs to another host|another host|another computer/.test(problem))
296
296
  return { run, on: `Run #${run} finished on another computer, and Toolroll can only confirm its processes ended there`, action: `${settle} (on that computer)` };
297
+ if (/can't be proven stopped: .* is using its folder/.test(problem))
298
+ return { run, on: `Run #${run} finished, but something Toolroll didn't start is using its folder`, action: settle };
299
+ if (/can't be checked on this computer/.test(problem))
300
+ return { run, on: `Run #${run} finished, but Toolroll can't check this computer's processes`, action: settle };
297
301
  if (/incomplete native containment custody|cannot be proven empty/.test(problem))
298
- return { run, on: `Run #${run} finished, but Toolroll can't confirm its sandbox is empty`, action: settle };
302
+ return { run, on: `Run #${run} finished, but Toolroll can't confirm its sandbox is empty`, action: null };
299
303
  if (/still has members/.test(problem))
300
304
  return { run, on: `Run #${run} finished, but processes are still running in its sandbox`, action: null };
301
305
  if (/workspace is still held/.test(problem))
@@ -1,3 +1,4 @@
1
+ import type ts from "typescript-parser";
1
2
  /** One required call: in this file, inside this top-level function, a call to this name, the name bound as `from`
2
3
  * says (a module specifier its un-renamed named import comes from; `self`: the file's own top-level function; `local`:
3
4
  * declared inside `inside`). `args`: the call's arguments, as text, must stay as the base has them. */
@@ -9,12 +10,17 @@ export type EvalHook = {
9
10
  args?: true;
10
11
  why: string;
11
12
  };
13
+ /** `fn` may also be `outer>inner` (docs/plans/learning-loops.md, Protection 3): a `const inner = (…) => …` (or function
14
+ * expression) or `function inner`, bound once in the file and never reassigned, inside the top-level function `outer`
15
+ * and in no other function between them. `strict`: a base that holds the file but can't resolve the pin is a problem,
16
+ * never skipped (a base without the file at all is a project that isn't Toolroll, and holds none). */
12
17
  /** One function whose body (its text, as the candidate has it) must stay as the base has it: a top-level function, or
13
18
  * `Class#method`, a method of a top-level class. */
14
19
  export type EvalPinned = {
15
20
  file: string;
16
21
  fn: string;
17
22
  why: string;
23
+ strict?: true;
18
24
  };
19
25
  export declare const EVAL_HOOKS: readonly EvalHook[];
20
26
  export declare const EVAL_PINNED: readonly EvalPinned[];
@@ -37,3 +43,52 @@ export declare const EVAL_HOOK_FILES: readonly string[];
37
43
  * files the verified diff says exist at the candidate, so no bytes is a failed read, never a deletion.
38
44
  */
39
45
  export declare function storeRewriteProblem(paths: readonly string[], candidate: ((file: string) => Buffer | null) | null, base: ((file: string) => Buffer | null) | null, pinned?: readonly string[]): string | null;
46
+ /** The calls that record a check's facts: at disposition, and as the check's command starts. */
47
+ export declare const LEARNING_HOOKS: readonly EvalHook[];
48
+ /** The bodies a release can't change by itself, every one strict: what a run is told from project memory, how a memory
49
+ * proposal and a decision are written, and where a check's conditions are captured. */
50
+ export declare const LEARNING_PINNED: readonly EvalPinned[];
51
+ /** Every file the learning manifest reads. */
52
+ export declare const LEARNING_HOOK_FILES: readonly string[];
53
+ /** Each protected table and its only writer modules (the v149 migration in store.ts aside). The decision tables'
54
+ * existing writers stay theirs: project-memory.ts records and retires, project-delete.ts deletes a whole project. */
55
+ export declare const LEARNING_WRITERS: Readonly<Record<string, readonly string[]>>;
56
+ /** One SQL-shaped construct in a file: its static text (a placeholder `${}` where an expression sits), the protected
57
+ * tables it writes, and the top-level declaration it's in ("" for none). `unreadable`: a write whose table name is
58
+ * split around an expression; `bare`: a string that is only a protected table's name (SQL assembled elsewhere). */
59
+ type LearningConstruct = {
60
+ text: string;
61
+ tables: string[];
62
+ inside: string;
63
+ unreadable: boolean;
64
+ bare: boolean;
65
+ };
66
+ /**
67
+ * Every construct in the file that could write a protected table: each string literal, template (its static parts)
68
+ * and `+` concatenation (its literal operands), whatever it's passed to — `prepare`, `exec`, a renamed `prepare`, a
69
+ * helper. A write verb next to a protected table's name, a table name split around an expression next to a write
70
+ * word, and a string that is only a protected table's name are each found.
71
+ */
72
+ export declare function learningConstructs(source: ts.SourceFile): LearningConstruct[];
73
+ /**
74
+ * Why a changed file writes a learning-loops table (or a decision merge or restore) from a place that may not, or null
75
+ * (docs/plans/learning-loops.md, Protection 4). Every changed JavaScript or TypeScript file but tests is parsed; a
76
+ * construct its writer module doesn't own is new unless the base's copy of that file already had exactly that text
77
+ * (counted by its sha256, as storeRewriteProblem does). A file that can't be read or parsed needs a person. It reads
78
+ * text the candidate wrote, never runs it: it raises the bar for review; a person reviewing these files is the backstop.
79
+ */
80
+ export declare function learningWriteProblem(paths: readonly string[], candidate: ((file: string) => Buffer | null) | null, base: ((file: string) => Buffer | null) | null): string | null;
81
+ /**
82
+ * Why a candidate needs a person for learning loops, or null: a required call dropped, a pinned body changed (strict
83
+ * pins included), or a changed file writing a protected table from outside its writers. With `manifest.base`, only what
84
+ * the base holds is required, as evalHookProblem does — except a strict pin whose file the base holds.
85
+ */
86
+ export declare function learningHookProblem(manifest: {
87
+ candidate: (file: string) => Buffer | null;
88
+ base?: (file: string) => Buffer | null;
89
+ }, changed: {
90
+ paths: readonly string[];
91
+ candidate: ((file: string) => Buffer | null) | null;
92
+ base: ((file: string) => Buffer | null) | null;
93
+ }): string | null;
94
+ export {};
@@ -36,6 +36,7 @@ export const EVAL_HOOKS = Object.freeze([
36
36
  { file: "src/release-auto.ts", inside: "changeProblem", calls: "evalHookProblem", from: "./eval-hooks.js", why: "this check itself" },
37
37
  { file: "src/release-auto.ts", inside: "changeProblem", calls: "autoPathProblem", from: "self", why: "policy paths need a person" },
38
38
  { file: "src/release-auto.ts", inside: "changeProblem", calls: "storeRewriteProblem", from: "./eval-hooks.js", why: "no changed file rewrites a pinned Store method" },
39
+ { file: "src/release-auto.ts", inside: "changeProblem", calls: "learningHookProblem", from: "./eval-hooks.js", why: "learning loops' hooks, pins and writers (docs/plans/learning-loops.md)" },
39
40
  // The invocation gateway (invoke.ts): every agent run an eval world spawns takes a slot.
40
41
  { file: "src/invoke.ts", inside: "invokeAgent", calls: "takeEvalDispatch", from: "./eval-gate.js", why: "every agent dispatch in an eval world takes a slot" },
41
42
  // PR #263 review: what the run's billing basis and its unknown cost are computed from is held too, not only the call.
@@ -227,7 +228,7 @@ function boundAs(source, name, from, inside) {
227
228
  if (from === "self")
228
229
  return compiler.isFunctionDeclaration(only) && only.parent === source ? null : `${name} isn't its own top-level function`;
229
230
  if (from === "local")
230
- return only.pos >= inside.pos && only.end <= inside.end && !compiler.isParameter(only) ? null : `${name} isn't declared inside ${inside.name?.text ?? "it"}`;
231
+ return only.pos >= inside.pos && only.end <= inside.end && !compiler.isParameter(only) ? null : `${name} isn't declared inside its function`;
231
232
  if (!compiler.isImportSpecifier(only) || only.propertyName !== undefined || only.isTypeOnly)
232
233
  return `${name} isn't imported by its own name`;
233
234
  const clause = only.parent.parent;
@@ -254,6 +255,46 @@ function heldFunction(source, name) {
254
255
  const fn = topFunction(source, name);
255
256
  return fn === null || boundAs(source, name, "self", fn) !== null ? null : fn;
256
257
  }
258
+ /** `outer>inner`: the one function-like `inner` declared inside the held top-level `outer` (no other function between
259
+ * them), bound once in the file and never reassigned — a `function inner` or a `const inner =` arrow or function
260
+ * expression. Null otherwise. */
261
+ function nestedFunction(source, spec) {
262
+ const compiler = typescript();
263
+ const [outerName, innerName, ...rest] = spec.split(">");
264
+ if (outerName === undefined || innerName === undefined || rest.length > 0 || outerName === "" || innerName === "")
265
+ return null;
266
+ const outer = heldFunction(source, outerName);
267
+ if (outer === null || outer.body === undefined)
268
+ return null;
269
+ const all = bindingsOf(source, innerName);
270
+ if (all.length !== 1)
271
+ return null;
272
+ const only = all[0];
273
+ let fn = null;
274
+ if (compiler.isFunctionDeclaration(only) && only.body !== undefined)
275
+ fn = only;
276
+ else if (compiler.isVariableDeclaration(only) && only.initializer !== undefined && (compiler.isArrowFunction(only.initializer) || compiler.isFunctionExpression(only.initializer))
277
+ && compiler.isVariableDeclarationList(only.parent) && (only.parent.flags & compiler.NodeFlags.Const) !== 0)
278
+ fn = only.initializer;
279
+ if (fn === null)
280
+ return null;
281
+ // Directly inside `outer`: the first function-like ancestor of the declaration is `outer` itself.
282
+ for (let at = only.parent; at !== undefined; at = at.parent) {
283
+ if (at === outer)
284
+ return fn;
285
+ if (compiler.isFunctionLike(at) || compiler.isClassLike(at))
286
+ return null;
287
+ }
288
+ return null;
289
+ }
290
+ /** A held function by its manifest name: top-level, `Class#method` or `outer>inner`. */
291
+ function heldNode(source, spec) {
292
+ if (spec.includes(">"))
293
+ return nestedFunction(source, spec);
294
+ if (spec.includes("#"))
295
+ return classMethod(source, spec);
296
+ return heldFunction(source, spec);
297
+ }
257
298
  /** A reader that parses each file once. */
258
299
  function parser(read) {
259
300
  const seen = new Map();
@@ -265,7 +306,7 @@ function hookIn(tree, hook) {
265
306
  const parsed = tree(hook.file);
266
307
  if ("problem" in parsed)
267
308
  return { missing: `${parsed.problem}, and it holds a required call (${hook.why})` };
268
- const fn = heldFunction(parsed.source, hook.inside);
309
+ const fn = (hook.inside.includes(">") ? nestedFunction(parsed.source, hook.inside) : heldFunction(parsed.source, hook.inside));
269
310
  if (fn === null || fn.body === undefined)
270
311
  return { missing: `${hook.file} no longer has ${hook.inside} as one function, which must call ${hook.calls} (${hook.why})` };
271
312
  const found = callsOf(fn.body, hook.calls);
@@ -281,7 +322,7 @@ function pinnedText(tree, pin) {
281
322
  const parsed = tree(pin.file);
282
323
  if ("problem" in parsed)
283
324
  return null;
284
- const fn = pin.fn.includes("#") ? classMethod(parsed.source, pin.fn) : heldFunction(parsed.source, pin.fn);
325
+ const fn = heldNode(parsed.source, pin.fn);
285
326
  if (fn === null)
286
327
  return null;
287
328
  // The static block's pin holds the class header too: its name, modifiers and heritage, as the base has them.
@@ -308,8 +349,12 @@ export function evalHookProblem(candidate, base, hooks = EVAL_HOOKS, pinned = EV
308
349
  }
309
350
  for (const pin of pinned) {
310
351
  const before = was === null ? null : pinnedText(was, pin);
311
- if (was !== null && before === null)
352
+ if (was !== null && before === null) {
353
+ // A strict pin the base holds the file of but can't resolve is a problem (learning loops, Protection 2), never a skip.
354
+ if (pin.strict === true && base(pin.file) !== null)
355
+ return `${pin.file} at the base has no ${pin.fn} as one function, which is pinned strictly (${pin.why})`;
312
356
  continue;
357
+ }
313
358
  const after = pinnedText(now, pin);
314
359
  if (after === null)
315
360
  return `${pin.file} no longer has ${pin.fn} as one function (${pin.why})`;
@@ -475,3 +520,181 @@ export function storeRewriteProblem(paths, candidate, base, pinned = PINNED_STOR
475
520
  }
476
521
  return null;
477
522
  }
523
+ // ---- learning loops (docs/plans/learning-loops.md, "Safety", Protection 2-4) ----------------------------------------
524
+ /** The calls that record a check's facts: at disposition, and as the check's command starts. */
525
+ export const LEARNING_HOOKS = Object.freeze([
526
+ { file: "src/builder.ts", inside: "settleProof", calls: "recordCheckFacts", from: "./check-facts.js", why: "every settled check records its facts" },
527
+ { file: "src/builder.ts", inside: "settleProof>runVerification", calls: "captureCheckConditions", from: "./check-facts.js", why: "a check's conditions are captured as its command starts" },
528
+ ]);
529
+ /** The bodies a release can't change by itself, every one strict: what a run is told from project memory, how a memory
530
+ * proposal and a decision are written, and where a check's conditions are captured. */
531
+ export const LEARNING_PINNED = Object.freeze([
532
+ { file: "src/project-knowledge.ts", fn: "knowledgeContext", strict: true, why: "the decisions and references a run is given, and their snapshot" },
533
+ { file: "src/project-learning.ts", fn: "learningContext", strict: true, why: "the lessons a run is given, and their snapshot" },
534
+ { file: "src/memory-pass.ts", fn: "decideProposal", strict: true, why: "a memory proposal's one writer" },
535
+ { file: "src/project-memory.ts", fn: "recordDecision", strict: true, why: "how a decision is recorded and what it supersedes" },
536
+ { file: "src/project-memory.ts", fn: "retireDecision", strict: true, why: "how a decision is retired" },
537
+ { file: "src/builder.ts", fn: "settleProof>runVerification", strict: true, why: "the check's command and the conditions it starts under" },
538
+ ]);
539
+ /** Every file the learning manifest reads. */
540
+ export const LEARNING_HOOK_FILES = Object.freeze([...new Set([...LEARNING_HOOKS.map(hook => hook.file), ...LEARNING_PINNED.map(pin => pin.file)])]);
541
+ /** Each protected table and its only writer modules (the v149 migration in store.ts aside). The decision tables'
542
+ * existing writers stay theirs: project-memory.ts records and retires, project-delete.ts deletes a whole project. */
543
+ export const LEARNING_WRITERS = Object.freeze({
544
+ check_fact: ["src/check-facts.ts"], check_result: ["src/check-facts.ts"],
545
+ local_observation: ["src/flaky.ts"], ci_flaky_observation: ["src/flaky-ci.ts"], ci_pairing_rejected: ["src/flaky-ci.ts"],
546
+ learning_watch: ["src/flaky.ts", "src/lesson-notes.ts"],
547
+ lesson_note: ["src/lesson-notes.ts"], lesson_note_tally: ["src/lesson-notes.ts"],
548
+ memory_use: ["src/memory-use.ts"],
549
+ memory_tidy_proposal: ["src/memory-tidy.ts"], decision_merge: ["src/memory-tidy.ts"],
550
+ project_decision: ["src/memory-tidy.ts", "src/project-memory.ts", "src/project-delete.ts"],
551
+ decision_change: ["src/memory-tidy.ts", "src/project-memory.ts", "src/project-delete.ts"],
552
+ });
553
+ /** store.ts's v149 migration: the only place outside the writers that may name these tables in SQL. */
554
+ const LEARNING_MIGRATION = Object.freeze({ "src/store.ts": ["V149_SCHEMA", "V149_TABLES", "V149_REPO_TABLES"] });
555
+ const PROTECTED = Object.keys(LEARNING_WRITERS);
556
+ const tableWrite = (table) => new RegExp(String.raw `(?:\b(?:(?:INSERT(?:\s+OR\s+\w+)?|REPLACE)\s+INTO|UPDATE(?:\s+OR\s+\w+)?|DELETE\s+FROM)\s+(?:main\s*\.\s*)?["\`\[]?${table}["\`\]]?(?!\w)`
557
+ + String.raw `|\b(?:DROP\s+(?:TABLE|TRIGGER|INDEX|VIEW)(?:\s+IF\s+EXISTS)?|ALTER\s+TABLE)\s+(?:main\s*\.\s*)?["\`\[]?${table})`, "i");
558
+ const TABLE_WRITES = PROTECTED.map(table => ({ table, write: tableWrite(table) }));
559
+ const WRITE_WORD = /\b(?:INSERT|UPDATE|DELETE|REPLACE|DROP|ALTER)\b/i;
560
+ const BARE_NAME = new RegExp(String.raw `^\s*(?:main\s*\.\s*)?["\`\[]?(?:${PROTECTED.join("|")})["\`\]]?\s*$`, "i");
561
+ /**
562
+ * Every construct in the file that could write a protected table: each string literal, template (its static parts)
563
+ * and `+` concatenation (its literal operands), whatever it's passed to — `prepare`, `exec`, a renamed `prepare`, a
564
+ * helper. A write verb next to a protected table's name, a table name split around an expression next to a write
565
+ * word, and a string that is only a protected table's name are each found.
566
+ */
567
+ export function learningConstructs(source) {
568
+ const compiler = typescript();
569
+ const found = [];
570
+ const topOf = (node) => {
571
+ let at = node;
572
+ while (at.parent !== undefined && !compiler.isSourceFile(at.parent))
573
+ at = at.parent;
574
+ if (compiler.isFunctionDeclaration(at) || compiler.isClassDeclaration(at))
575
+ return at.name?.text ?? "";
576
+ if (compiler.isVariableStatement(at))
577
+ return at.declarationList.declarations.map(one => compiler.isIdentifier(one.name) ? one.name.text : "").join(",");
578
+ return "";
579
+ };
580
+ const fragmentRisk = (parts) => {
581
+ // Around each placeholder: the identifier characters just before and just after it.
582
+ for (let i = 0; i < parts.length - 1; i++) {
583
+ const before = /[A-Za-z0-9_]*$/.exec(parts[i])[0].toLowerCase(), after = /^[A-Za-z0-9_]*/.exec(parts[i + 1])[0].toLowerCase();
584
+ if (before === "" && after === "")
585
+ continue;
586
+ if (PROTECTED.some(table => (before !== "" && table.startsWith(before) && table !== before) || (after !== "" && table.endsWith(after) && table !== after)
587
+ || (before !== "" && after !== "" && table.startsWith(before) && table.endsWith(after))))
588
+ return true;
589
+ }
590
+ return false;
591
+ };
592
+ const consider = (node, parts) => {
593
+ const text = parts.join("${}");
594
+ const tables = TABLE_WRITES.filter(one => one.write.test(text)).map(one => one.table);
595
+ const unreadable = parts.length > 1 && WRITE_WORD.test(text) && fragmentRisk(parts);
596
+ const bare = parts.length === 1 && BARE_NAME.test(text);
597
+ if (tables.length > 0 || unreadable || bare)
598
+ found.push({ text: text.replace(/\s+/g, " ").trim(), tables, inside: topOf(node), unreadable, bare });
599
+ };
600
+ const isPlus = (node) => compiler.isBinaryExpression(node) && node.operatorToken.kind === compiler.SyntaxKind.PlusToken;
601
+ const flatten = (node, into) => {
602
+ let at = node;
603
+ while (compiler.isParenthesizedExpression(at))
604
+ at = at.expression;
605
+ if (isPlus(at)) {
606
+ flatten(at.left, into);
607
+ flatten(at.right, into);
608
+ }
609
+ else
610
+ into.push(at);
611
+ };
612
+ const visit = (node) => {
613
+ if (isPlus(node) && !(node.parent !== undefined && (isPlus(node.parent) || (compiler.isParenthesizedExpression(node.parent) && node.parent.parent !== undefined && isPlus(node.parent.parent))))) {
614
+ const operands = [];
615
+ flatten(node, operands);
616
+ if (operands.some(one => compiler.isStringLiteralLike(one) || compiler.isTemplateExpression(one))) {
617
+ const parts = [""];
618
+ for (const one of operands) {
619
+ if (compiler.isStringLiteralLike(one))
620
+ parts[parts.length - 1] += one.text;
621
+ else if (compiler.isTemplateExpression(one)) {
622
+ parts[parts.length - 1] += one.head.text;
623
+ for (const span of one.templateSpans)
624
+ parts.push(span.literal.text);
625
+ }
626
+ else
627
+ parts.push("");
628
+ }
629
+ if (parts.length > 1 || operands.length > 1)
630
+ consider(node, parts);
631
+ }
632
+ }
633
+ if (compiler.isStringLiteralLike(node))
634
+ consider(node, [node.text]);
635
+ else if (compiler.isTemplateExpression(node))
636
+ consider(node, [node.head.text, ...node.templateSpans.map(span => span.literal.text)]);
637
+ compiler.forEachChild(node, visit);
638
+ };
639
+ visit(source);
640
+ return found;
641
+ }
642
+ /** Whether this construct may stand in this file: its writer module for every table it writes, or the migration's own
643
+ * declarations in store.ts. An unreadable or bare construct stands only in a writer of some protected table. */
644
+ function learningAllowed(file, one) {
645
+ if ((LEARNING_MIGRATION[file] ?? []).some(name => one.inside.split(",").includes(name)))
646
+ return true;
647
+ if (one.tables.length > 0)
648
+ return one.tables.every(table => LEARNING_WRITERS[table].includes(file));
649
+ return Object.values(LEARNING_WRITERS).some(writers => writers.includes(file));
650
+ }
651
+ /**
652
+ * Why a changed file writes a learning-loops table (or a decision merge or restore) from a place that may not, or null
653
+ * (docs/plans/learning-loops.md, Protection 4). Every changed JavaScript or TypeScript file but tests is parsed; a
654
+ * construct its writer module doesn't own is new unless the base's copy of that file already had exactly that text
655
+ * (counted by its sha256, as storeRewriteProblem does). A file that can't be read or parsed needs a person. It reads
656
+ * text the candidate wrote, never runs it: it raises the bar for review; a person reviewing these files is the backstop.
657
+ */
658
+ export function learningWriteProblem(paths, candidate, base) {
659
+ const scanned = paths.filter(path => /\.(?:[cm]?[jt]sx?)$/.test(path) && !/\.test\.[cm]?[jt]sx?$/.test(path) && !path.startsWith("test/")
660
+ && !path.startsWith("node_modules/") && !path.includes("/node_modules/"));
661
+ if (scanned.length === 0)
662
+ return null;
663
+ if (candidate === null || base === null)
664
+ return "the changed files can't be read";
665
+ const key = (text) => createHash("sha256").update(text).digest("hex");
666
+ for (const path of scanned) {
667
+ const bytes = candidate(path);
668
+ if (bytes === null)
669
+ return `${path} can't be read`;
670
+ const now = parse(path, bytes);
671
+ if ("problem" in now)
672
+ return now.problem;
673
+ const before = base(path);
674
+ const was = before === null ? null : parse(path, before);
675
+ const kept = new Map();
676
+ if (was !== null && !("problem" in was))
677
+ for (const one of learningConstructs(was.source))
678
+ kept.set(key(one.text), (kept.get(key(one.text)) ?? 0) + 1);
679
+ for (const one of learningConstructs(now.source)) {
680
+ if (learningAllowed(path, one))
681
+ continue;
682
+ const left = kept.get(key(one.text)) ?? 0;
683
+ if (left > 0) {
684
+ kept.set(key(one.text), left - 1);
685
+ continue;
686
+ }
687
+ const what = one.unreadable ? "builds a learning-loops table's name around an expression in SQL" : one.bare ? "names a learning-loops table outside its writers" : "writes learning-loops rows outside their writers";
688
+ return `${path} ${what} (\`${one.text.slice(0, 80)}\`)`;
689
+ }
690
+ }
691
+ return null;
692
+ }
693
+ /**
694
+ * Why a candidate needs a person for learning loops, or null: a required call dropped, a pinned body changed (strict
695
+ * pins included), or a changed file writing a protected table from outside its writers. With `manifest.base`, only what
696
+ * the base holds is required, as evalHookProblem does — except a strict pin whose file the base holds.
697
+ */
698
+ export function learningHookProblem(manifest, changed) {
699
+ return evalHookProblem(manifest.candidate, manifest.base, LEARNING_HOOKS, LEARNING_PINNED) ?? learningWriteProblem(changed.paths, changed.candidate, changed.base);
700
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Mixed outcomes under recorded conditions (docs/plans/learning-loops.md, "Flaky tests"): advisory only. Nothing here
3
+ * changes a check's outcome, a verdict, an approval or a rerun; rerunning stays a person's `task regate`.
4
+ *
5
+ * Slice 1 holds the tracked identities an observation adds (`learning_watch`, reason `observation`): every Full check
6
+ * with a handshake asks about them for 90 days (check-facts.ts learnRequestFor). Lesson notes (lesson-notes.ts) add
7
+ * their own. This module and lesson-notes.ts are the only writers of `learning_watch`; release-auto's learning scan
8
+ * (eval-hooks.ts learningWriteProblem) needs a person for any other. A policy path (release-auto.ts EVAL_POLICY).
9
+ */
10
+ import type { Store } from "./store.js";
11
+ /** Track an identity a local observation named (one row per observation; first write wins, never rewritten). */
12
+ export declare function watchObservation(store: Store, input: {
13
+ repo: string;
14
+ identity: string;
15
+ observation: number;
16
+ now: Date;
17
+ }): boolean;
package/dist/flaky.js ADDED
@@ -0,0 +1,9 @@
1
+ /** Track an identity a local observation named (one row per observation; first write wins, never rewritten). */
2
+ export function watchObservation(store, input) {
3
+ if (input.repo.trim() === "" || input.identity.length < 1 || input.identity.length > 2600 || !Number.isSafeInteger(input.observation) || input.observation < 1) {
4
+ throw new Error("a tracked identity needs its project, a bounded identity and the observation that named it");
5
+ }
6
+ const added = store.handle.prepare(`INSERT INTO learning_watch (repo, identity, reason, source_ref, since, until) VALUES (?, ?, 'observation', ?, ?, NULL)
7
+ ON CONFLICT (repo, identity, reason, source_ref) DO NOTHING`).run(input.repo, input.identity, `observation:${input.observation}`, input.now.toISOString());
8
+ return Number(added.changes) === 1;
9
+ }
@@ -6,11 +6,11 @@
6
6
  * The words never name the machine's internals; `plainSentence` keeps a
7
7
  * recorded reason only when it is free of them. */
8
8
  /** What a person is asked for. */
9
- export type NeedKey = "approval" | "card" | "answer" | "questions" | "sign-in" | "confirm-stopped" | "check-stopped" | "hold" | "choose-project" | "define-task" | "fix-request" | "choose-agent" | "fix-dependency" | "add-requirement" | "connect-builder" | "start-builder" | "vanished" | "review-result" | "rebuild" | "replan" | "earlier-version" | "other";
9
+ export type NeedKey = "approval" | "card" | "answer" | "questions" | "sign-in" | "stop-unproven" | "hold" | "choose-project" | "define-task" | "fix-request" | "choose-agent" | "fix-dependency" | "add-requirement" | "connect-builder" | "start-builder" | "vanished" | "review-result" | "rebuild" | "replan" | "earlier-version" | "other";
10
10
  /** What the work waits for when no person can act. */
11
- export type WaitKey = "build-stopping" | "build-finishing" | "other-computer" | "card-reply" | "card-time" | "card-ci";
11
+ export type WaitKey = "build-stopping" | "build-finishing" | "build-checking" | "other-computer" | "card-reply" | "card-time" | "card-ci";
12
12
  /** The action's code: the WorkAction that owns the act, or one of the acts a surface renders itself. */
13
- export type NeedActionCode = "approve-scope" | "approve-card" | "answer-decision" | "inspect-decisions" | "sign-in" | "confirm-stopped" | "inspect-hold" | "place-task" | "write-scope" | "select-agent" | "repair-dependency" | "repair-capability" | "start-worker" | "reconcile-run" | "open-result" | "retry-task" | "inspect-task";
13
+ export type NeedActionCode = "approve-scope" | "approve-card" | "answer-decision" | "inspect-decisions" | "sign-in" | "recheck-stopped" | "inspect-hold" | "place-task" | "write-scope" | "select-agent" | "repair-dependency" | "repair-capability" | "start-worker" | "reconcile-run" | "open-result" | "retry-task" | "inspect-task";
14
14
  export type NeedAction = {
15
15
  code: NeedActionCode;
16
16
  label: string;
@@ -76,14 +76,14 @@ export declare const hasInternalWords: (text: string) => boolean;
76
76
  * it reads plainly, otherwise the need's own words. */
77
77
  export declare function needSentence(key: NeedKey, reason: string | null, context?: NeedContext): string;
78
78
  export declare function waitSentence(key: WaitKey | undefined, reason: string | null, context?: NeedContext): string;
79
- /** A finished run Toolroll can't prove stopped (store.stopQuiescenceFact):
80
- * a person can confirm it only when nothing of it may still be running.
81
- * `unknown`: Toolroll can't check, so the person confirms they checked. */
79
+ /** A finished run Toolroll can't yet prove stopped (store.stopQuiescenceFact). Nobody is asked to confirm it:
80
+ * `unprovable` is Toolroll's own check still to run (stop-proof.ts), which settles by itself; `unknown` is that this
81
+ * computer can't look, or something Toolroll didn't start is in the build's folder — held, with nothing to vouch for. */
82
82
  export declare function processNeedOf(fact: {
83
83
  run: number;
84
84
  kind: "open" | "alive" | "elsewhere" | "unprovable" | "unknown" | "settling";
85
85
  } | null): {
86
- need: "confirm-stopped" | "check-stopped";
86
+ need: "stop-unproven";
87
87
  build: number;
88
88
  } | {
89
89
  wait: WaitKey;
package/dist/needs-you.js CHANGED
@@ -22,9 +22,10 @@ export const NEEDS = {
22
22
  answer: { sentence: () => "Answer the question so the work can continue.", action: { code: "answer-decision", label: "Answer the question" }, useReason: always },
23
23
  questions: { sentence: () => "Too many questions are open. Answer one so this task can start.", action: { code: "inspect-decisions", label: "Answer questions" } },
24
24
  "sign-in": { sentence: context => `${context.provider ?? "Your agent"} needs you to sign in again. The task starts on its own after.`, action: { code: "sign-in", label: "Sign in again" }, useReason: reason => /sign in|API key/i.test(reason) },
25
- "confirm-stopped": { sentence: context => `Toolroll can't confirm ${build(context)} stopped. Nothing from it is running.`, action: { code: "confirm-stopped", label: "Confirm it stopped" } },
26
- // Toolroll can't look at all: never "nothing is running"; the person checks, says so, then confirms.
27
- "check-stopped": { sentence: context => `Toolroll can't check whether ${build(context)} stopped. Make sure nothing from it is running, then confirm.`, action: { code: "confirm-stopped", label: "Confirm it stopped" } },
25
+ // Toolroll proves a build stopped itself (stop-proof.ts); nobody is asked to vouch for it. Only when this computer
26
+ // can't look, or something Toolroll didn't start is using the build's folder, is the work held: the person can close
27
+ // what's theirs and have it look again, nothing more.
28
+ "stop-unproven": { sentence: context => `Toolroll can't prove ${build(context)} stopped from this computer, so the work is held. Close anything of yours that is using its folder, then check again.`, action: { code: "recheck-stopped", label: "Check again" } },
28
29
  hold: { sentence: () => "A hold is keeping this task from starting. Review it to release it.", action: { code: "inspect-hold", label: "Review hold" } },
29
30
  "choose-project": { sentence: () => "Choose a project so a builder can start.", action: { code: "place-task", label: "Choose a project" } },
30
31
  "define-task": { sentence: () => "Say what the task should do so it can be planned.", action: { code: "write-scope", label: "Define the task" } },
@@ -70,7 +71,7 @@ export function askChipOf(read) {
70
71
  return "Question";
71
72
  if (read.need === "hold")
72
73
  return "On hold";
73
- if (read.need === "vanished" || read.need === "confirm-stopped" || read.need === "check-stopped")
74
+ if (read.need === "vanished" || read.need === "stop-unproven")
74
75
  return "Stopped";
75
76
  if (read.need === "sign-in")
76
77
  return "Sign in";
@@ -100,11 +101,12 @@ export const NEED_ASK = {
100
101
  approval: "decide", card: "decide", answer: "decide", questions: "decide", "choose-project": "decide", "define-task": "decide",
101
102
  "fix-request": "decide", "choose-agent": "decide",
102
103
  "review-result": "review", rebuild: "review", replan: "decide",
103
- "sign-in": "unblock", "confirm-stopped": "unblock", "check-stopped": "unblock", hold: "unblock", "fix-dependency": "unblock",
104
+ "sign-in": "unblock", "stop-unproven": "unblock", hold: "unblock", "fix-dependency": "unblock",
104
105
  "add-requirement": "unblock", "connect-builder": "unblock", "start-builder": "unblock", vanished: "unblock", "earlier-version": "unblock", other: "unblock",
105
106
  };
106
107
  export const WAITS = {
107
108
  "build-finishing": () => "Finishing up; this clears on its own.",
109
+ "build-checking": context => `Making sure ${build(context)} stopped. This clears on its own.`,
108
110
  "build-stopping": context => `Waiting for ${build(context)} to stop. Nothing is needed from you.`,
109
111
  "other-computer": context => `Waiting for the computer that ran ${build(context)} to confirm it stopped.`,
110
112
  "card-reply": () => "Waiting for a reply.",
@@ -130,18 +132,18 @@ export function waitSentence(key, reason, context = {}) {
130
132
  const plain = reason?.trim() || null;
131
133
  return plain !== null && !hasInternalWords(plain) ? plain : "Waiting for the work to continue on its own.";
132
134
  }
133
- /** A finished run Toolroll can't prove stopped (store.stopQuiescenceFact):
134
- * a person can confirm it only when nothing of it may still be running.
135
- * `unknown`: Toolroll can't check, so the person confirms they checked. */
135
+ /** A finished run Toolroll can't yet prove stopped (store.stopQuiescenceFact). Nobody is asked to confirm it:
136
+ * `unprovable` is Toolroll's own check still to run (stop-proof.ts), which settles by itself; `unknown` is that this
137
+ * computer can't look, or something Toolroll didn't start is in the build's folder — held, with nothing to vouch for. */
136
138
  export function processNeedOf(fact) {
137
139
  if (fact === null)
138
140
  return null;
139
141
  if (fact.kind === "settling")
140
142
  return { wait: "build-finishing", build: fact.run };
141
143
  if (fact.kind === "unprovable")
142
- return { need: "confirm-stopped", build: fact.run };
144
+ return { wait: "build-checking", build: fact.run };
143
145
  if (fact.kind === "unknown")
144
- return { need: "check-stopped", build: fact.run };
146
+ return { need: "stop-unproven", build: fact.run };
145
147
  return { wait: fact.kind === "elsewhere" ? "other-computer" : "build-stopping", build: fact.run };
146
148
  }
147
149
  /** Dispatch and work-index codes that wait on a person, as their need. */