toolroll 0.9.91 → 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 (124) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/approval-authority.d.ts +9 -1
  3. package/dist/approval-authority.js +15 -2
  4. package/dist/assignment-status.d.ts +1 -1
  5. package/dist/assignment-ui.js +2 -2
  6. package/dist/assignment.js +11 -3
  7. package/dist/browser/workspace.css +1 -1
  8. package/dist/browser/workspace.js +30 -30
  9. package/dist/browser-crew.js +2 -2
  10. package/dist/browser-workspace.d.ts +3 -5
  11. package/dist/build-review.js +2 -0
  12. package/dist/builder.js +21 -2
  13. package/dist/chat-actions.d.ts +11 -3
  14. package/dist/chat-actions.js +47 -2
  15. package/dist/chat-decide.js +2 -0
  16. package/dist/chat-review.d.ts +2 -1
  17. package/dist/chat-review.js +2 -2
  18. package/dist/check-facts.d.ts +124 -0
  19. package/dist/check-facts.js +272 -0
  20. package/dist/check-report.d.ts +5 -0
  21. package/dist/check-report.js +4 -3
  22. package/dist/cli.js +5 -0
  23. package/dist/contracts/chat-actions.d.ts +10 -0
  24. package/dist/contracts/chat-actions.js +4 -0
  25. package/dist/contracts/console-api.d.ts +3 -2
  26. package/dist/contracts/console-api.js +5 -2
  27. package/dist/contracts/release-check.d.ts +25 -1
  28. package/dist/contracts/release-check.js +19 -0
  29. package/dist/contracts/route-results.d.ts +121 -0
  30. package/dist/contracts/route-results.js +71 -0
  31. package/dist/contracts/route.d.ts +343 -5
  32. package/dist/contracts/route.js +36 -7
  33. package/dist/contracts/store-json.d.ts +2 -0
  34. package/dist/desktop-update.d.ts +2 -2
  35. package/dist/desktop-update.js +13 -9
  36. package/dist/dispose.js +6 -0
  37. package/dist/eval-hooks.d.ts +55 -0
  38. package/dist/eval-hooks.js +227 -4
  39. package/dist/flaky.d.ts +17 -0
  40. package/dist/flaky.js +9 -0
  41. package/dist/flow-engine.js +1 -1
  42. package/dist/flow-pull-request.js +1 -1
  43. package/dist/lead-doors.js +4 -2
  44. package/dist/lead-tools.js +10 -2
  45. package/dist/local-owner.d.ts +1 -1
  46. package/dist/local-owner.js +11 -0
  47. package/dist/memory-cli.d.ts +3 -0
  48. package/dist/memory-cli.js +3 -3
  49. package/dist/memory-pass.js +5 -1
  50. package/dist/model-choices.d.ts +7 -3
  51. package/dist/model-choices.js +26 -11
  52. package/dist/models-cli.d.ts +205 -0
  53. package/dist/models-cli.js +143 -4
  54. package/dist/models-ui.d.ts +57 -0
  55. package/dist/models-ui.js +44 -1
  56. package/dist/needs-you.d.ts +7 -7
  57. package/dist/needs-you.js +12 -10
  58. package/dist/operate-remote-arguments.js +4 -4
  59. package/dist/operate-remote.js +1 -1
  60. package/dist/operate.js +191 -23
  61. package/dist/phase-routing.d.ts +82 -2
  62. package/dist/phase-routing.js +132 -18
  63. package/dist/project-delete.d.ts +4 -0
  64. package/dist/project-delete.js +16 -4
  65. package/dist/project-memory.d.ts +8 -0
  66. package/dist/project-memory.js +20 -1
  67. package/dist/pull-request-flow.js +1 -1
  68. package/dist/push.d.ts +10 -3
  69. package/dist/push.js +22 -6
  70. package/dist/release-adapters.js +11 -1
  71. package/dist/release-approval.d.ts +191 -0
  72. package/dist/release-approval.js +382 -0
  73. package/dist/release-auto.js +22 -4
  74. package/dist/release-card.d.ts +73 -4
  75. package/dist/release-card.js +168 -23
  76. package/dist/release-cli.js +9 -1
  77. package/dist/release.d.ts +30 -0
  78. package/dist/release.js +42 -3
  79. package/dist/result-actions.d.ts +4 -0
  80. package/dist/result-actions.js +3 -0
  81. package/dist/result-acts.d.ts +3 -3
  82. package/dist/result-acts.js +1 -1
  83. package/dist/route-hooks.d.ts +54 -0
  84. package/dist/route-hooks.js +179 -0
  85. package/dist/route-results.d.ts +70 -0
  86. package/dist/route-results.js +340 -0
  87. package/dist/route-rules.d.ts +324 -0
  88. package/dist/route-rules.js +781 -0
  89. package/dist/route-savings.d.ts +136 -0
  90. package/dist/route-savings.js +193 -0
  91. package/dist/route-store.d.ts +300 -0
  92. package/dist/route-store.js +598 -0
  93. package/dist/scope.d.ts +3 -1
  94. package/dist/scope.js +4 -1
  95. package/dist/server/guards.js +1 -0
  96. package/dist/server/pages-assets.js +1 -1
  97. package/dist/server/render-chat.js +2 -2
  98. package/dist/server/render-release.d.ts +17 -0
  99. package/dist/server/render-release.js +84 -0
  100. package/dist/server/render-results.d.ts +0 -2
  101. package/dist/server/render-results.js +4 -6
  102. package/dist/server/render-tasks.d.ts +7 -0
  103. package/dist/server/render-tasks.js +16 -3
  104. package/dist/server/route-table.d.ts +1 -1
  105. package/dist/server/route-table.js +19 -1
  106. package/dist/server/settings.d.ts +2 -1
  107. package/dist/server/settings.js +87 -1
  108. package/dist/server/task-views.js +6 -0
  109. package/dist/server/tasks.d.ts +15 -0
  110. package/dist/server/tasks.js +105 -20
  111. package/dist/settings-proofs.js +2 -0
  112. package/dist/spend-ui.d.ts +11 -0
  113. package/dist/spend-ui.js +49 -2
  114. package/dist/stop-proof.d.ts +67 -0
  115. package/dist/stop-proof.js +293 -0
  116. package/dist/store.d.ts +106 -31
  117. package/dist/store.js +653 -128
  118. package/dist/surface.js +17 -5
  119. package/dist/task-outcome-cli.js +24 -2
  120. package/dist/toolroll-update-ui.js +2 -2
  121. package/dist/toolroll-update.js +4 -2
  122. package/dist/work-index.js +7 -9
  123. package/dist/work-summary.d.ts +2 -2
  124. package/package.json +1 -1
@@ -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
+ }
@@ -507,7 +507,7 @@ export function revisionWithNote(store, card, target, note, actor, repos, eviden
507
507
  return null;
508
508
  const revised = requestResultChanges(store, evidenceRoot, {
509
509
  run: runId, source: revisionSourceOf(store.getScope(runTask)?.digest ?? null), actor, repos,
510
- batch: "", note, path: "", line: "", request: `flow-${card.id}-${card.entry}`, allowMode: true,
510
+ batch: "", note, path: "", line: "", request: `flow-${card.id}-${card.entry}`, allowMode: true, origin: "flow",
511
511
  }, now);
512
512
  return revised.ok ? revised.id : null;
513
513
  }
@@ -140,7 +140,7 @@ export async function pullRequestStep(store, flow, definition, stage, card, now,
140
140
  const runTask = store.externalIdFor(store.getRun(runId)?.taskRef ?? -1);
141
141
  const revised = runTask === null ? null : requestResultChanges(store, io.evidenceRoot, {
142
142
  run: runId, source: revisionSourceOf(store.getScope(runTask)?.digest ?? null), actor: "toolroll", repos: [flow.repo], batch: "",
143
- note: found?.note ?? said, path: "", line: "", allowMode: true,
143
+ note: found?.note ?? said, path: "", line: "", allowMode: true, origin: "flow",
144
144
  request: createHash("sha256").update(`flow-ci ${card.id} ${card.entry} ${publication.id}`).digest("hex").slice(0, 32),
145
145
  }, now);
146
146
  if (revised?.ok === true)
@@ -25,7 +25,7 @@ import { teamChatAuthorization, subscriptionTeamChatProvider } from './team-chat
25
25
  import { readProjectAccess } from "./project-access.js";
26
26
  import { TeamLeads } from './team-leads.js';
27
27
  import { fileTaskProposal } from "./proposal.js";
28
- import { proposeGuarded } from "./scope.js";
28
+ import { machineActing, proposeGuarded } from "./scope.js";
29
29
  import { isTaskSize, PHASES, specWords } from "./phase-routing.js";
30
30
  import { isProviderId } from "./provider.js";
31
31
  import { applyChatReview } from "./chat-review.js";
@@ -377,7 +377,9 @@ function executeProposal(store, actor, kind, payload, now, options) {
377
377
  const request = payload;
378
378
  if (request.snapshot == null || request.snapshot.task !== taskId || (request.operation !== "note" && request.operation !== "revise"))
379
379
  return refuse("refused", "This feedback card is incomplete. Ask again.");
380
- const result = applyChatReview(store, actor, options.evidenceRoot, request, now, options.via !== "cli");
380
+ // A review card is the lead's (or a coordinator's) proposal; a person confirming it here decided the result needs
381
+ // these changes, so the revision is theirs. Under the lead's own chain or a remote run nobody confirmed it: lead.
382
+ const result = applyChatReview(store, actor, options.evidenceRoot, request, now, options.via !== "cli", machineActing() ? "lead" : "person");
381
383
  return result.ok ? { ok: true, kind, taskId: result.taskId, said: result.said } : refuse("stale", result.message);
382
384
  }
383
385
  // The place the proposer saw must be the place the task holds now
@@ -1,3 +1,4 @@
1
+ import { rulesOf, ruleWords as earnedRuleWords, suggestionsFor, suggestionWords, trialOffersFor, trialOfferWords, trialsOf, trialWords } from "./route-rules.js";
1
2
  import { sameFeedbackImage } from "./result-review.js";
2
3
  import { sweepLine, sweepTable } from "./lead-sweep.js";
3
4
  import { connectionsOf, oneClickOf } from "./mcp-connect.js";
@@ -1602,7 +1603,7 @@ const LEAD_TOOL_HANDLERS = {
1602
1603
  },
1603
1604
  },
1604
1605
  get_models: {
1605
- description: "Default agent per role with the exact model each name runs now, installed CLI versions and updates, and models released in the last two weeks. Read-only; changes happen in Settings → Models.",
1606
+ description: "Default agent per role with the exact model each name runs now, installed CLI versions and updates, models released in the last two weeks, and cheaper models your projects' own results suggest (with their earned rules), cheaper models to try and the trials in use. Read-only; accepting and starting a trial happen in Settings → Models; Not now is propose_action suggestion_not_now; stopping a trial is propose_action trial_stop.",
1606
1607
  handle: (ctx) => {
1607
1608
  const roles = ["plan", "build", "review", "repair"].map(phase => {
1608
1609
  const row = ctx.store.phaseConfig(INSTALLATION_SCOPE, phase);
@@ -1610,7 +1611,14 @@ const LEAD_TOOL_HANDLERS = {
1610
1611
  });
1611
1612
  const tools = runtimeStates(ctx.store).map(one => ({ name: one.name, installed: one.installed, latest: one.latest, updateAvailable: one.behind }));
1612
1613
  const newModels = ["claude", "codex", "gemini"].flatMap(source => seenModels(ctx.store, source)).filter(model => isNewModel(model, ctx.now)).map(model => ({ name: model.name, id: model.id, price: priceWords(model), released: model.releasedAt }));
1613
- return { ok: true, body: { roles, tools, newModels, change: "/settings/models" } };
1614
+ // Results routing: the same suggestions and rules Settings → Models and `toolroll models suggestions` show.
1615
+ const suggestions = ctx.who.repos.flatMap(repo => suggestionsFor(ctx.store, repo, ctx.now)).map(one => ({ id: one.id, repo: one.repo, words: suggestionWords(one),
1616
+ review: `/settings/models?scope=${encodeURIComponent(one.repo)}&suggestion=${encodeURIComponent(one.id)}#models-suggested` }));
1617
+ const rules = ctx.who.repos.flatMap(repo => rulesOf(ctx.store, repo, ctx.now)).map(one => ({ id: one.id, repo: one.repo, state: one.state, words: earnedRuleWords(ctx.store, one) }));
1618
+ const trialOffers = ctx.who.repos.flatMap(repo => trialOffersFor(ctx.store, repo, ctx.now)).map(one => ({ id: one.id, repo: one.repo, words: trialOfferWords(one),
1619
+ start: `/settings/models?scope=${encodeURIComponent(one.repo)}#models-suggested` }));
1620
+ const trials = ctx.who.repos.flatMap(repo => trialsOf(ctx.store, repo, ctx.now)).map(one => ({ id: one.id, repo: one.repo, state: one.state, words: trialWords(ctx.store, one) }));
1621
+ return { ok: true, body: { roles, tools, newModels, suggestions, rules, trialOffers, trials, change: "/settings/models" } };
1614
1622
  },
1615
1623
  },
1616
1624
  list_tasks: {
@@ -10,7 +10,7 @@ export declare function authorityOf(value: unknown): Authority | null;
10
10
  * The acts a local owner may do (plan C1). Ids only: each surface (a route row, a chat action, a CLI command) names
11
11
  * the act it is, and an act not in this list is refused to a local owner.
12
12
  */
13
- export declare const LOCAL_OWNER_ACTS: readonly ["file-task", "lead-send", "approve-plan", "answer-question", "stop-run", "pause-run", "resume-run", "complete-task", "request-revision", "archive-task", "add-project", "choose-default-agent", "mark-seen", "watch-agent", "read"];
13
+ export declare const LOCAL_OWNER_ACTS: readonly ["file-task", "lead-send", "approve-plan", "answer-question", "stop-run", "pause-run", "resume-run", "complete-task", "request-revision", "archive-task", "add-project", "choose-default-agent", "mark-seen", "watch-agent", "read", "routing-turn-off", "dismiss-suggestion", "trial-stop"];
14
14
  export type LocalOwnerAct = (typeof LOCAL_OWNER_ACTS)[number];
15
15
  /** Whether a runtime string names an act a local owner may do. */
16
16
  export declare function localOwnerAct(value: unknown): value is LocalOwnerAct;
@@ -32,6 +32,13 @@ export const LOCAL_OWNER_ACTS = Object.freeze([
32
32
  "mark-seen",
33
33
  "watch-agent",
34
34
  "read",
35
+ // Results routing (docs/plans/results-routing.md, Authority): lowering only — turning an earned rule off (or
36
+ // dropping a paused one), and saying Not now to a suggestion (or switching suggestions off). The writer
37
+ // (route-store.ts proveRuleDoor) checks the in-flight act is exactly the one it needs.
38
+ "routing-turn-off",
39
+ "dismiss-suggestion",
40
+ // Slice 3: stopping a trial of a cheaper model only lowers (proveRuleDoor checks this exact act).
41
+ "trial-stop",
35
42
  ]);
36
43
  const ACT_SET = new Set(LOCAL_OWNER_ACTS);
37
44
  /** Whether a runtime string names an act a local owner may do. */
@@ -150,6 +157,10 @@ export const CHAT_ACTION_LOCAL_ACTS = Object.freeze({
150
157
  subagent_create: null, subagent_soul: null, subagent_state: null, subagent_note: null, subagent_ask: null,
151
158
  subagent_answer: null, subagent_tools: null, subagent_memory: null, subagent_routine: null, subagent_undo: null,
152
159
  kit_setup: null, decision_record: null, decision_retire: null, lead_about_you: null,
160
+ // Results routing: Not now only lowers (the rule door checks this exact act).
161
+ suggestion_not_now: "dismiss-suggestion",
162
+ // Stopping a trial only lowers (the rule door checks this exact act).
163
+ trial_stop: "trial-stop",
153
164
  scope_approve: "approve-plan",
154
165
  result_accept: "complete-task",
155
166
  task_cancel: "stop-run",
@@ -200,6 +200,7 @@ export declare const MEMORY_DESCRIPTORS: readonly [{
200
200
  readonly takesValue: false;
201
201
  readonly meaning: 'read only the plane\'s own transcripts, not local Claude Code or Codex session files';
202
202
  }];
203
+ readonly audience: 'operator';
203
204
  }, {
204
205
  readonly action: 'retire';
205
206
  readonly synopsis: 'retire a decision that no longer holds: memory retire <id> --reason "<why>"';
@@ -250,6 +251,7 @@ export declare const MEMORY_DESCRIPTORS: readonly [{
250
251
  readonly takesValue: false;
251
252
  readonly meaning: 'read only the plane\'s own transcripts, not local Claude Code or Codex session files';
252
253
  }];
254
+ readonly audience: 'operator';
253
255
  }, {
254
256
  readonly action: 'propose';
255
257
  readonly synopsis: 'the backward pass: read recent sessions, keep a ledger of evidenced gaps, propose a few edits (writes nothing to memory)';
@@ -400,6 +402,7 @@ export declare const MEMORY_DESCRIPTORS: readonly [{
400
402
  readonly takesValue: false;
401
403
  readonly meaning: 'read only the plane\'s own transcripts, not local Claude Code or Codex session files';
402
404
  }];
405
+ readonly audience: 'operator';
403
406
  }, {
404
407
  readonly action: 'status';
405
408
  readonly synopsis: 'surface version, instruction budget, sessions analysed, open gaps and pending proposals';
@@ -21,11 +21,11 @@ export const MEMORY_DESCRIPTORS = [
21
21
  { action: 'search', synopsis: 'search decisions, instructions, references, lessons and your conversations', mutation: 'none', takesQuery: true, flags },
22
22
  { action: 'decisions', synopsis: "list a project's active decisions (--all for history)", mutation: 'none', takesQuery: false, flags },
23
23
  { action: 'show', synopsis: 'read one decision with its reason and history', mutation: 'none', takesQuery: true, flags },
24
- { action: 'decide', synopsis: 'record a settled choice: memory decide "<choice>" --why "<reason>" --repo PATH', mutation: 'unkeyed', takesQuery: true, flags },
25
- { action: 'retire', synopsis: 'retire a decision that no longer holds: memory retire <id> --reason "<why>"', mutation: 'unkeyed', takesQuery: true, flags },
24
+ { action: 'decide', synopsis: 'record a settled choice: memory decide "<choice>" --why "<reason>" --repo PATH', mutation: 'unkeyed', takesQuery: true, flags, audience: 'operator' },
25
+ { action: 'retire', synopsis: 'retire a decision that no longer holds: memory retire <id> --reason "<why>"', mutation: 'unkeyed', takesQuery: true, flags, audience: 'operator' },
26
26
  { action: 'propose', synopsis: 'the backward pass: read recent sessions, keep a ledger of evidenced gaps, propose a few edits (writes nothing to memory)', mutation: 'unkeyed', takesQuery: false, flags },
27
27
  { action: 'review', synopsis: 'list pending memory proposals with their evidence', mutation: 'none', takesQuery: false, flags },
28
- { action: 'apply', synopsis: 'decide one proposal: memory apply <id> --decision accept|reject — the only writer', mutation: 'unkeyed', takesQuery: true, flags },
28
+ { action: 'apply', synopsis: 'decide one proposal: memory apply <id> --decision accept|reject — the only writer', mutation: 'unkeyed', takesQuery: true, flags, audience: 'operator' },
29
29
  { action: 'status', synopsis: 'surface version, instruction budget, sessions analysed, open gaps and pending proposals', mutation: 'none', takesQuery: false, flags },
30
30
  ];
31
31
  const line = (d) => `#${d.id} ${d.claim} — ${d.decidedBy}, ${d.decidedAt.slice(0, 10)}${d.status === 'active' ? '' : ` (${d.status})`}`;
@@ -17,7 +17,7 @@ import { homedir } from 'node:os';
17
17
  import { join } from 'node:path';
18
18
  import { scanForSecrets, readVerifiedArtifact } from './evidence.js';
19
19
  import { changeKnowledge, knowledgeView } from './project-knowledge.js';
20
- import { listDecisions, recordDecision } from './project-memory.js';
20
+ import { listDecisions, recordDecision, refuseLeadMemoryWrite } from './project-memory.js';
21
21
  import { composeLeadRequest, performLeadRequest, isDirectChatProvider, CHAT_KEY_ENV } from './converse.js';
22
22
  import { performSubscriptionLeadRequest } from './subscription-chat.js';
23
23
  import { TEXT_LIMITS } from './text-limits.js';
@@ -399,7 +399,11 @@ export function listProposals(store, repo, status = 'pending') {
399
399
  }
400
400
  /** The one writer: accept applies the edit through the knowledge or decision store as the person; reject remembers the fingerprint and its corroboration. */
401
401
  export function decideProposal(store, args, now = new Date()) {
402
+ refuseLeadMemoryWrite();
402
403
  return store.transact(() => {
404
+ // A person who may change this project's memory, read inside the write (a revoked account or lost access refuses).
405
+ if (!store.accountCanAccess(args.actor, args.repo) || store.accountOf(args.actor)?.role !== 'approver')
406
+ throw Error('Project memory is outside your access.');
403
407
  const row = store.handle.prepare('SELECT * FROM memory_proposal WHERE id=? AND repo=?').get(args.id, args.repo);
404
408
  if (!row)
405
409
  throw Error('That proposal is unavailable.');
@@ -1,5 +1,5 @@
1
1
  import { type SeenModel } from "./model-catalog.js";
2
- import { type PhaseRoute } from "./phase-routing.js";
2
+ import { type EarnedRule, type PhaseRoute } from "./phase-routing.js";
3
3
  import type { Store } from "./store.js";
4
4
  export type SlotPhase = "plan" | "build" | "repair" | "review";
5
5
  export type SlotTier = "routine" | "light" | "strong";
@@ -76,7 +76,9 @@ export declare const ROUTE_CASES: readonly {
76
76
  risky: boolean;
77
77
  strict: boolean;
78
78
  }[];
79
- export declare function effectiveRoutes(store: Store, repo: string | null): {
79
+ export declare function effectiveRoutes(store: Store, repo: string | null, options?: {
80
+ rules?: readonly EarnedRule[] | false;
81
+ }): {
80
82
  ok: true;
81
83
  routes: {
82
84
  label: string;
@@ -164,7 +166,9 @@ export type ScopeCheck = {
164
166
  * or a route of any size or demand (small, routine, large, risky, strict) whose repair can't resume its builder,
165
167
  * inherited rows included. An invalid result is never saved.
166
168
  */
167
- export declare function checkScope(store: Store, repo: string | null): ScopeCheck;
169
+ export declare function checkScope(store: Store, repo: string | null, options?: {
170
+ rules?: readonly EarnedRule[];
171
+ }): ScopeCheck;
168
172
  /** A resolver's words for a browser: what is wrong, never a terminal command to fix it. */
169
173
  export declare function browserWords(problem: string): string;
170
174
  /** The problem alone, or null when tasks can be filed. */