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.
- package/CHANGELOG.md +41 -0
- package/dist/approval-authority.d.ts +9 -1
- package/dist/approval-authority.js +15 -2
- package/dist/assignment-status.d.ts +1 -1
- package/dist/assignment-ui.js +2 -2
- package/dist/assignment.js +11 -3
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +30 -30
- package/dist/browser-crew.js +2 -2
- package/dist/browser-workspace.d.ts +3 -5
- package/dist/build-review.js +2 -0
- package/dist/builder.js +21 -2
- package/dist/chat-actions.d.ts +11 -3
- package/dist/chat-actions.js +47 -2
- package/dist/chat-decide.js +2 -0
- package/dist/chat-review.d.ts +2 -1
- package/dist/chat-review.js +2 -2
- package/dist/check-facts.d.ts +124 -0
- package/dist/check-facts.js +272 -0
- package/dist/check-report.d.ts +5 -0
- package/dist/check-report.js +4 -3
- package/dist/cli.js +5 -0
- package/dist/contracts/chat-actions.d.ts +10 -0
- package/dist/contracts/chat-actions.js +4 -0
- package/dist/contracts/console-api.d.ts +3 -2
- package/dist/contracts/console-api.js +5 -2
- package/dist/contracts/release-check.d.ts +25 -1
- package/dist/contracts/release-check.js +19 -0
- package/dist/contracts/route-results.d.ts +121 -0
- package/dist/contracts/route-results.js +71 -0
- package/dist/contracts/route.d.ts +343 -5
- package/dist/contracts/route.js +36 -7
- package/dist/contracts/store-json.d.ts +2 -0
- package/dist/desktop-update.d.ts +2 -2
- package/dist/desktop-update.js +13 -9
- package/dist/dispose.js +6 -0
- package/dist/eval-hooks.d.ts +55 -0
- package/dist/eval-hooks.js +227 -4
- package/dist/flaky.d.ts +17 -0
- package/dist/flaky.js +9 -0
- package/dist/flow-engine.js +1 -1
- package/dist/flow-pull-request.js +1 -1
- package/dist/lead-doors.js +4 -2
- package/dist/lead-tools.js +10 -2
- package/dist/local-owner.d.ts +1 -1
- package/dist/local-owner.js +11 -0
- package/dist/memory-cli.d.ts +3 -0
- package/dist/memory-cli.js +3 -3
- package/dist/memory-pass.js +5 -1
- package/dist/model-choices.d.ts +7 -3
- package/dist/model-choices.js +26 -11
- package/dist/models-cli.d.ts +205 -0
- package/dist/models-cli.js +143 -4
- package/dist/models-ui.d.ts +57 -0
- package/dist/models-ui.js +44 -1
- package/dist/needs-you.d.ts +7 -7
- package/dist/needs-you.js +12 -10
- package/dist/operate-remote-arguments.js +4 -4
- package/dist/operate-remote.js +1 -1
- package/dist/operate.js +191 -23
- package/dist/phase-routing.d.ts +82 -2
- package/dist/phase-routing.js +132 -18
- package/dist/project-delete.d.ts +4 -0
- package/dist/project-delete.js +16 -4
- package/dist/project-memory.d.ts +8 -0
- package/dist/project-memory.js +20 -1
- package/dist/pull-request-flow.js +1 -1
- package/dist/push.d.ts +10 -3
- package/dist/push.js +22 -6
- package/dist/release-adapters.js +11 -1
- package/dist/release-approval.d.ts +191 -0
- package/dist/release-approval.js +382 -0
- package/dist/release-auto.js +22 -4
- package/dist/release-card.d.ts +73 -4
- package/dist/release-card.js +168 -23
- package/dist/release-cli.js +9 -1
- package/dist/release.d.ts +30 -0
- package/dist/release.js +42 -3
- package/dist/result-actions.d.ts +4 -0
- package/dist/result-actions.js +3 -0
- package/dist/result-acts.d.ts +3 -3
- package/dist/result-acts.js +1 -1
- package/dist/route-hooks.d.ts +54 -0
- package/dist/route-hooks.js +179 -0
- package/dist/route-results.d.ts +70 -0
- package/dist/route-results.js +340 -0
- package/dist/route-rules.d.ts +324 -0
- package/dist/route-rules.js +781 -0
- package/dist/route-savings.d.ts +136 -0
- package/dist/route-savings.js +193 -0
- package/dist/route-store.d.ts +300 -0
- package/dist/route-store.js +598 -0
- package/dist/scope.d.ts +3 -1
- package/dist/scope.js +4 -1
- package/dist/server/guards.js +1 -0
- package/dist/server/pages-assets.js +1 -1
- package/dist/server/render-chat.js +2 -2
- package/dist/server/render-release.d.ts +17 -0
- package/dist/server/render-release.js +84 -0
- package/dist/server/render-results.d.ts +0 -2
- package/dist/server/render-results.js +4 -6
- package/dist/server/render-tasks.d.ts +7 -0
- package/dist/server/render-tasks.js +16 -3
- package/dist/server/route-table.d.ts +1 -1
- package/dist/server/route-table.js +19 -1
- package/dist/server/settings.d.ts +2 -1
- package/dist/server/settings.js +87 -1
- package/dist/server/task-views.js +6 -0
- package/dist/server/tasks.d.ts +15 -0
- package/dist/server/tasks.js +105 -20
- package/dist/settings-proofs.js +2 -0
- package/dist/spend-ui.d.ts +11 -0
- package/dist/spend-ui.js +49 -2
- package/dist/stop-proof.d.ts +67 -0
- package/dist/stop-proof.js +293 -0
- package/dist/store.d.ts +106 -31
- package/dist/store.js +653 -128
- package/dist/surface.js +17 -5
- package/dist/task-outcome-cli.js +24 -2
- package/dist/toolroll-update-ui.js +2 -2
- package/dist/toolroll-update.js +4 -2
- package/dist/work-index.js +7 -9
- package/dist/work-summary.d.ts +2 -2
- package/package.json +1 -1
package/dist/phase-routing.js
CHANGED
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
*/
|
|
51
51
|
import { ACP_AGENTS, isAcpProvider } from "./acp-agents.js";
|
|
52
52
|
import { createHash } from "node:crypto";
|
|
53
|
-
import { MODEL_ID_SHAPE, postureOf, readRouteOverrides, readSealedRoute, RISKS, ROUTE_PHASES, ROUTE_PROVIDERS, ROUTE_VERSION, TASK_SIZE_WORDS, } from "./contracts/route.js";
|
|
53
|
+
import { MODEL_ID_SHAPE, postureOf, readRouteOverrides, PLAIN_ROUTE_VERSION, readSealedRoute, RISKS, ROUTE_PHASES, ROUTE_PROVIDERS, ROUTE_VERSION, TASK_SIZE_WORDS, } from "./contracts/route.js";
|
|
54
54
|
import { canonicalJson } from "./canonical-json.js";
|
|
55
55
|
export { MODEL_ID_SHAPE };
|
|
56
56
|
// Type-only imports from provider.ts: it sits under evidence.ts and scope.ts
|
|
@@ -72,7 +72,7 @@ export function exactModelId(v) {
|
|
|
72
72
|
/** v2 adds the task's size (small / medium / large, risky or not), the
|
|
73
73
|
* light tier, and plan headroom. A v1 route — sealed before sizing — still
|
|
74
74
|
* reads back exactly and keeps its own digest domain. */
|
|
75
|
-
export { ROUTE_VERSION };
|
|
75
|
+
export { PLAIN_ROUTE_VERSION, ROUTE_VERSION };
|
|
76
76
|
/** The durable route ERA a scope row carries once it was filed under this
|
|
77
77
|
* policy: NULL on a row proven to predate v47 (legacy — the sealed profile
|
|
78
78
|
* alone governs), this value on every row filed since. A row with an era
|
|
@@ -270,23 +270,13 @@ export function recommendRoute(input) {
|
|
|
270
270
|
legs.push({ phase: "plan", provider: rec.spec.provider, model: rec.spec.model, tier: rec.tier, chosen: "recommended", recommended, reasons: rec.reasons, problem: planProblem(rec.spec) });
|
|
271
271
|
}
|
|
272
272
|
}
|
|
273
|
-
// build — pin > override >
|
|
273
|
+
// build — one precedence order (selectBuildLeg): pin > override > trial > earned rule > configured > headroom.
|
|
274
274
|
let buildMoved = false;
|
|
275
275
|
const build = (() => {
|
|
276
|
-
const
|
|
277
|
-
buildMoved =
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const recommended = { provider: rec.spec.provider, model: rec.spec.model, tier: rec.tier };
|
|
281
|
-
if (pin !== null || override !== null)
|
|
282
|
-
buildMoved = false;
|
|
283
|
-
const leg = pin !== null
|
|
284
|
-
? { phase: "build", provider: pin.provider, model: pin.model, tier: rec.tier, chosen: "pinned", recommended, reasons: [`pinned to ${specWords(pin)} by the firing that filed this task — nothing overrides a pin`], problem: null }
|
|
285
|
-
: override !== null
|
|
286
|
-
? { phase: "build", provider: override.provider, model: override.model, tier: rec.tier, chosen: "override", recommended, reasons: [`overridden by ${override.by} to ${specWords(override)} (recommended ${specWords(rec.spec)})`], problem: null }
|
|
287
|
-
: { phase: "build", provider: rec.spec.provider, model: rec.spec.model, tier: rec.tier, chosen: "recommended", recommended, reasons: rec.reasons, problem: null };
|
|
288
|
-
legs.push(leg);
|
|
289
|
-
return leg;
|
|
276
|
+
const chosen = selectBuildLeg(input, pickTier("build"));
|
|
277
|
+
buildMoved = chosen.moved;
|
|
278
|
+
legs.push(chosen.leg);
|
|
279
|
+
return chosen.leg;
|
|
290
280
|
})();
|
|
291
281
|
// repair — ALWAYS the build provider (repair resumes the builder's
|
|
292
282
|
// session; cross-provider repair does not exist). Only the model routes:
|
|
@@ -383,7 +373,8 @@ export function recommendRoute(input) {
|
|
|
383
373
|
}
|
|
384
374
|
}
|
|
385
375
|
return {
|
|
386
|
-
|
|
376
|
+
// Version 3 only for a build leg that carries a selection: every other route's bytes stay version 2's.
|
|
377
|
+
version: build.selection !== undefined ? ROUTE_VERSION : PLAIN_ROUTE_VERSION,
|
|
387
378
|
risk: "routine",
|
|
388
379
|
qualityMode: input.qualityMode,
|
|
389
380
|
publication: input.publication,
|
|
@@ -398,6 +389,127 @@ export function recommendRoute(input) {
|
|
|
398
389
|
size: input.size ?? null,
|
|
399
390
|
};
|
|
400
391
|
}
|
|
392
|
+
/**
|
|
393
|
+
* THE BUILD LEG'S ONE PRECEDENCE ORDER (results routing, docs/plans/results-routing.md slice 2), pure:
|
|
394
|
+
*
|
|
395
|
+
* pin > person override > trial > earned rule > configured row (light / routine / strong by size)
|
|
396
|
+
* > headroom move
|
|
397
|
+
*
|
|
398
|
+
* Headroom moves only a configured leg, never a rule's or a trial's: their evidence is about one exact model. A rule
|
|
399
|
+
* or trial whose provider is past the headroom limits is skipped for this filing, and its reason says so. A trial
|
|
400
|
+
* applies only with a place reserved for this task (slice 3). A rule or trial applies only to an
|
|
401
|
+
* eligible task — small or medium, not risky, default quality, no screenshot or manual-review evidence, not a
|
|
402
|
+
* merging mode, not a release or candidate, nothing protected declared — and only over the configured builder it was
|
|
403
|
+
* earned against. Its leg says how it was selected (`selection`), so the route seals as version 3 and every later
|
|
404
|
+
* check reads the sealed selection, never the current rules or the reason text.
|
|
405
|
+
*/
|
|
406
|
+
export function selectBuildLeg(input, configured) {
|
|
407
|
+
const pin = input.pins?.build ?? null;
|
|
408
|
+
const override = input.overrides.find(one => one.phase === "build") ?? null;
|
|
409
|
+
if (pin !== null || override !== null) {
|
|
410
|
+
// What would have run otherwise is said as it always was: the configured leg after headroom.
|
|
411
|
+
const rec = withHeadroom(input, "build", configured);
|
|
412
|
+
const recommended = { provider: rec.spec.provider, model: rec.spec.model, tier: rec.tier };
|
|
413
|
+
const leg = pin !== null
|
|
414
|
+
? { phase: "build", provider: pin.provider, model: pin.model, tier: rec.tier, chosen: "pinned", recommended, reasons: [`pinned to ${specWords(pin)} by the firing that filed this task — nothing overrides a pin`], problem: null }
|
|
415
|
+
: { phase: "build", provider: override.provider, model: override.model, tier: rec.tier, chosen: "override", recommended, reasons: [`overridden by ${override.by} to ${specWords(override)} (recommended ${specWords(rec.spec)})`], problem: null };
|
|
416
|
+
return { leg, moved: false };
|
|
417
|
+
}
|
|
418
|
+
const trial = trialFor(input, configured);
|
|
419
|
+
if (trial !== null && trial.kind === "trial") {
|
|
420
|
+
const one = trial.trial;
|
|
421
|
+
const spec = { provider: one.provider, model: one.model };
|
|
422
|
+
return {
|
|
423
|
+
leg: {
|
|
424
|
+
phase: "build", provider: one.provider, model: one.model, tier: configured.tier, chosen: "recommended",
|
|
425
|
+
recommended: { ...spec, tier: configured.tier },
|
|
426
|
+
reasons: [...configured.reasons.slice(0, 1), `trial: ${specWords(spec)} is being tried on this project's next ${one.limit} eligible ${one.cohortSize === "small" ? "small" : "routine"} tasks instead of the configured ${specWords({ provider: one.configured.provider, model: one.configured.model ?? "default" })}; a person approves each one`],
|
|
427
|
+
problem: null,
|
|
428
|
+
selection: { kind: "trial", rule: one.id, reservation: one.reservation },
|
|
429
|
+
},
|
|
430
|
+
moved: false,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
const earned = earnedRuleFor(input, configured);
|
|
434
|
+
if (earned !== null && earned.kind === "rule") {
|
|
435
|
+
const rule = earned.rule;
|
|
436
|
+
const spec = { provider: rule.provider, model: rule.model };
|
|
437
|
+
return {
|
|
438
|
+
leg: {
|
|
439
|
+
phase: "build", provider: rule.provider, model: rule.model, tier: configured.tier, chosen: "recommended",
|
|
440
|
+
recommended: { ...spec, tier: configured.tier },
|
|
441
|
+
reasons: [...configured.reasons.slice(0, 1), `earned rule: ${specWords(spec)} builds this project's eligible ${rule.cohortSize === "small" ? "small" : "routine"} tasks instead of the configured ${specWords({ provider: rule.configured.provider, model: rule.configured.model ?? "default" })}, on its recorded results here`],
|
|
442
|
+
problem: null,
|
|
443
|
+
selection: { kind: "rule", rule: rule.id, ruleGeneration: rule.generation },
|
|
444
|
+
},
|
|
445
|
+
moved: false,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
const rec = withHeadroom(input, "build", configured);
|
|
449
|
+
const reasons = [...rec.reasons, ...(trial === null ? [] : [trial.reason]), ...(earned === null ? [] : [earned.reason])];
|
|
450
|
+
return {
|
|
451
|
+
leg: { phase: "build", provider: rec.spec.provider, model: rec.spec.model, tier: rec.tier, chosen: "recommended", recommended: { provider: rec.spec.provider, model: rec.spec.model, tier: rec.tier }, reasons, problem: null },
|
|
452
|
+
moved: rec.moved,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/** The earned rule this task's build takes (pure): null when none could apply (no rule for its size, or the task
|
|
456
|
+
* isn't eligible), or a rule skipped for this filing with the words why. */
|
|
457
|
+
export function earnedRuleFor(input, configured) {
|
|
458
|
+
const earned = input.earned ?? null;
|
|
459
|
+
const size = input.size ?? null;
|
|
460
|
+
if (earned === null || size === null || size.risky || (size.size !== "small" && size.size !== "medium"))
|
|
461
|
+
return null;
|
|
462
|
+
const rule = earned.rules.find(one => one.cohortSize === size.size) ?? null;
|
|
463
|
+
if (rule === null)
|
|
464
|
+
return null;
|
|
465
|
+
if (earned.release || earned.protected || input.qualityMode !== "default" || input.publication === "automerge")
|
|
466
|
+
return null;
|
|
467
|
+
if (input.evidence.some(kind => kind === "screenshot" || kind === "manual-review"))
|
|
468
|
+
return null;
|
|
469
|
+
if (demandedTier(input, "build").tier !== "routine")
|
|
470
|
+
return null;
|
|
471
|
+
// Only Claude reports the model it ran, so only a Claude cohort can earn a rule.
|
|
472
|
+
if (rule.provider !== "claude" || !exactModel(rule.model))
|
|
473
|
+
return null;
|
|
474
|
+
if (configured.spec.provider !== rule.configured.provider || configured.spec.model !== rule.configured.model) {
|
|
475
|
+
return { kind: "skipped", reason: `the earned rule for ${size.size === "small" ? "small" : "routine"} tasks was earned against another configured builder — not applied` };
|
|
476
|
+
}
|
|
477
|
+
const room = (input.headroom ?? []).find(one => one.provider === rule.provider) ?? null;
|
|
478
|
+
const tight = room === null ? null : tightWords(room);
|
|
479
|
+
if (tight !== null)
|
|
480
|
+
return { kind: "skipped", reason: `earned rule skipped for this filing: ${tight} — the configured builder runs it` };
|
|
481
|
+
return { kind: "rule", rule };
|
|
482
|
+
}
|
|
483
|
+
/** The trial this task's build takes (pure, results routing slice 3): null when none could apply (no live trial for
|
|
484
|
+
* its size, or the task isn't eligible: the same facts an earned rule needs), a trial with this task's place, or a
|
|
485
|
+
* trial skipped for this filing with the words why (another configured builder, no place left, no headroom). */
|
|
486
|
+
export function trialFor(input, configured) {
|
|
487
|
+
const trial = input.earned?.trial ?? null;
|
|
488
|
+
const size = input.size ?? null;
|
|
489
|
+
if (trial === null || size === null || size.risky || (size.size !== "small" && size.size !== "medium") || trial.cohortSize !== size.size)
|
|
490
|
+
return null;
|
|
491
|
+
if (input.earned.release || input.earned.protected || input.qualityMode !== "default" || input.publication === "automerge")
|
|
492
|
+
return null;
|
|
493
|
+
if (input.evidence.some(kind => kind === "screenshot" || kind === "manual-review"))
|
|
494
|
+
return null;
|
|
495
|
+
if (demandedTier(input, "build").tier !== "routine")
|
|
496
|
+
return null;
|
|
497
|
+
// Only Claude reports the model it ran, so only a Claude cohort runs a trial.
|
|
498
|
+
if (trial.provider !== "claude" || !exactModel(trial.model) || !Number.isSafeInteger(trial.limit) || trial.limit < 1)
|
|
499
|
+
return null;
|
|
500
|
+
const words = size.size === "small" ? "small" : "routine";
|
|
501
|
+
if (configured.spec.provider !== trial.configured.provider || configured.spec.model !== trial.configured.model) {
|
|
502
|
+
return { kind: "skipped", reason: `the trial for ${words} tasks was started against another configured builder — not applied` };
|
|
503
|
+
}
|
|
504
|
+
const room = (input.headroom ?? []).find(one => one.provider === trial.provider) ?? null;
|
|
505
|
+
const tight = room === null ? null : tightWords(room);
|
|
506
|
+
if (tight !== null)
|
|
507
|
+
return { kind: "skipped", reason: `trial skipped for this filing: ${tight} — the configured builder runs it` };
|
|
508
|
+
if (trial.reservation === null || !Number.isSafeInteger(trial.reservation) || trial.reservation < 1) {
|
|
509
|
+
return { kind: "skipped", reason: `every place in the trial for ${words} tasks is taken — the configured builder runs it` };
|
|
510
|
+
}
|
|
511
|
+
return { kind: "trial", trial: { ...trial, reservation: trial.reservation } };
|
|
512
|
+
}
|
|
401
513
|
/**
|
|
402
514
|
* PLAN HEADROOM (v2), at filing:
|
|
403
515
|
*
|
|
@@ -494,6 +606,8 @@ export function canonicalRouteJson(route) {
|
|
|
494
606
|
recommended: leg.recommended,
|
|
495
607
|
reasons: leg.reasons,
|
|
496
608
|
problem: leg.problem,
|
|
609
|
+
// v3 (results routing): the build leg's selection is in the bytes, so in the digest; v1 and v2 bytes never change.
|
|
610
|
+
...(route.version === 3 && leg.selection !== undefined ? { selection: leg.selection } : {}),
|
|
497
611
|
})),
|
|
498
612
|
overrides: route.overrides.map(one => ({ phase: one.phase, provider: one.provider, model: one.model, by: one.by, at: one.at })),
|
|
499
613
|
// v2 carries the size it was routed for; a v1 snapshot's bytes never change.
|
package/dist/project-delete.d.ts
CHANGED
|
@@ -30,6 +30,10 @@ export type ProjectDeleteResult = {
|
|
|
30
30
|
said: string;
|
|
31
31
|
running?: string[];
|
|
32
32
|
};
|
|
33
|
+
/** The project's Models rows, counted with its settings and deleted by forgetProjectModels only. */
|
|
34
|
+
export declare const MODEL_SETTINGS: readonly [table: string, column: string][];
|
|
35
|
+
/** The project's settings tables, for the deletion test's inventory. */
|
|
36
|
+
export declare const PROJECT_SETTINGS: readonly [table: string, column: string][];
|
|
33
37
|
/** What Toolroll holds for a project, counted. */
|
|
34
38
|
export declare function projectHoldings(store: Store, repo: string): ProjectHoldings;
|
|
35
39
|
/** What's in it, in words: "12 tasks, 3 versions, 30 runs, …". Empty kinds are left out. */
|
package/dist/project-delete.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { existsSync, lstatSync, rmSync } from "node:fs";
|
|
16
16
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
17
17
|
import { teardownGit, refusal } from "./teardown-git.js";
|
|
18
|
-
import { BUILT_IN } from "./store.js";
|
|
18
|
+
import { BUILT_IN, forgetProjectModels, V149_REPO_TABLES } from "./store.js";
|
|
19
19
|
import { BRANCH_PREFIXES, isOwnBranch } from "./names.js";
|
|
20
20
|
/** The tables whose history is the audit record itself: never touched. */
|
|
21
21
|
const KEPT = new Set(["action_ledger", "ledger_seal", "ledger_checkpoint"]);
|
|
@@ -42,13 +42,19 @@ function doomed(db, repo) {
|
|
|
42
42
|
const subagents = ids(db, "SELECT id FROM subagent WHERE repo = ?", repo);
|
|
43
43
|
return { refs, tasks, runs, artifacts, threads, turns, flows, cards, subagents };
|
|
44
44
|
}
|
|
45
|
-
/** A project's settings: one row per thing someone set for it.
|
|
45
|
+
/** A project's settings: one row per thing someone set for it. Its Models rows (phase_config, phase_tier_config,
|
|
46
|
+
* phase_tier_alternate) are settings too, but go only through store.ts forgetProjectModels, which moves their
|
|
47
|
+
* generations and takes the project's results-routing rows with them (MODEL_SETTINGS). */
|
|
46
48
|
const SETTINGS = [
|
|
47
49
|
["approval_policy", "repo"], ["verify_command", "repo"], ["worktree_setup", "repo"], ["backend_grant", "repo"], ["publication_grant", "repo"],
|
|
48
|
-
["intake_grant", "repo"], ["capability", "repo"], ["project_tool", "repo"],
|
|
50
|
+
["intake_grant", "repo"], ["capability", "repo"], ["project_tool", "repo"],
|
|
49
51
|
["learning_policy", "repo"], ["operating_mode", "repo"], ["project_knowledge", "repo"], ["live_detail_setting", "repo"],
|
|
50
52
|
["project_decision", "repo"], ["flow_script", "repo"], ["workflow_recipe", "repo"], ["team_lead_project", "project"],
|
|
51
53
|
];
|
|
54
|
+
/** The project's Models rows, counted with its settings and deleted by forgetProjectModels only. */
|
|
55
|
+
export const MODEL_SETTINGS = Object.freeze([["phase_config", "scope"], ["phase_tier_config", "scope"], ["phase_tier_alternate", "scope"]]);
|
|
56
|
+
/** The project's settings tables, for the deletion test's inventory. */
|
|
57
|
+
export const PROJECT_SETTINGS = Object.freeze(SETTINGS.map(one => [...one]));
|
|
52
58
|
/** What Toolroll holds for a project, counted. */
|
|
53
59
|
export function projectHoldings(store, repo) {
|
|
54
60
|
const db = store.handle;
|
|
@@ -61,7 +67,7 @@ export function projectHoldings(store, repo) {
|
|
|
61
67
|
checkouts: count("SELECT COUNT(*) AS n FROM worktree WHERE repo = ?", repo),
|
|
62
68
|
chats: d.threads.length, flows: d.flows.length, cards: d.cards.length, subagents: d.subagents.length,
|
|
63
69
|
budgets: count(`SELECT COUNT(*) AS n FROM budget WHERE removed_at IS NULL AND ((scope_kind = 'project' AND scope_key = ?) OR (scope_kind = 'subagent' AND scope_key IN (SELECT CAST(value AS TEXT) FROM json_each(?))))`, repo, json(d.subagents)),
|
|
64
|
-
settings: SETTINGS.filter(([table]) => tableExists(db, table)).reduce((sum, [table, column]) => sum + count(`SELECT COUNT(*) AS n FROM ${table} WHERE ${column} = ?`, repo), 0),
|
|
70
|
+
settings: [...SETTINGS, ...MODEL_SETTINGS].filter(([table]) => tableExists(db, table)).reduce((sum, [table, column]) => sum + count(`SELECT COUNT(*) AS n FROM ${table} WHERE ${column} = ?`, repo), 0),
|
|
65
71
|
};
|
|
66
72
|
}
|
|
67
73
|
/** What's in it, in words: "12 tasks, 3 versions, 30 runs, …". Empty kinds are left out. */
|
|
@@ -312,9 +318,15 @@ function deleteRows(store, repo, d, now) {
|
|
|
312
318
|
// Settings, knowledge and the project itself.
|
|
313
319
|
del("operating_mode_event", "mode IN (SELECT id FROM operating_mode WHERE repo = ?)", repo);
|
|
314
320
|
del("decision_change", "decision IN (SELECT id FROM project_decision WHERE repo = ?)", repo);
|
|
321
|
+
// Its learning-loops rows (v149), by repo; a check's results, a note's tallies and a merge of its decisions go with
|
|
322
|
+
// the rows they hang off (the deleted runs, notes and decisions), below.
|
|
323
|
+
for (const table of V149_REPO_TABLES)
|
|
324
|
+
del(table, "repo = ?", repo);
|
|
315
325
|
del("memory_sighting", "gap IN (SELECT id FROM memory_gap WHERE repo = ?) OR session IN (SELECT id FROM memory_session WHERE repo = ?)", repo, repo);
|
|
316
326
|
for (const [table, column] of SETTINGS)
|
|
317
327
|
del(table, `${column} = ?`, repo);
|
|
328
|
+
// Its Models rows and results-routing rows: through the Models counter, so whatever depended on them pauses.
|
|
329
|
+
rows += forgetProjectModels(store, repo, now);
|
|
318
330
|
del("project_mute", "repo = ?", repo);
|
|
319
331
|
for (const table of ["knowledge_change", "project_identity_carry", "project_skill_change", "memory_gap", "memory_proposal", "memory_rejection", "memory_session", "memory_search", "mode_rail", "side_spend", "watch_episode", "worktree", "coordinator_proposal", "lead_commitment"])
|
|
320
332
|
del(table, "repo = ?", repo);
|
package/dist/project-memory.d.ts
CHANGED
|
@@ -7,6 +7,14 @@ export declare function listDecisions(store: Store, repo: string, actor: string,
|
|
|
7
7
|
limit?: number;
|
|
8
8
|
}): Decision[];
|
|
9
9
|
export declare function getDecision(store: Store, repo: string, actor: string, id: number): Decision | null;
|
|
10
|
+
/**
|
|
11
|
+
* Project memory is changed by a person (like `knowledge apply`, project-knowledge.ts applySavedKnowledge): never in the
|
|
12
|
+
* lead's chain and never inside a remote run (an API token), whatever credential reached here. The lead proposes (chat
|
|
13
|
+
* cards decision_record/decision_retire) and a person confirms. Every writer here and in memory-pass.ts calls this first,
|
|
14
|
+
* and again inside its transaction beside the person's standing (`admission`), so a revocation made since the caller
|
|
15
|
+
* proved them is seen by the write.
|
|
16
|
+
*/
|
|
17
|
+
export declare function refuseLeadMemoryWrite(): void;
|
|
10
18
|
/** Record one settled choice. A superseding decision retires the older one in the same transaction. */
|
|
11
19
|
export declare function recordDecision(store: Store, args: {
|
|
12
20
|
repo: string;
|
package/dist/project-memory.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* model; the backward pass that proposes edits lives in memory-pass.ts.
|
|
9
9
|
*/
|
|
10
10
|
import { createHash } from 'node:crypto';
|
|
11
|
+
import { currentActor } from './actor.js';
|
|
12
|
+
import { activeRemote } from './remote-run.js';
|
|
11
13
|
import { scanForSecrets } from './evidence.js';
|
|
12
14
|
import { learningIdentity } from './project-learning.js';
|
|
13
15
|
import { TEXT_LIMITS } from './text-limits.js';
|
|
@@ -112,8 +114,22 @@ export function getDecision(store, repo, actor, id) {
|
|
|
112
114
|
throw Error('This decision could not be verified.');
|
|
113
115
|
return d;
|
|
114
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Project memory is changed by a person (like `knowledge apply`, project-knowledge.ts applySavedKnowledge): never in the
|
|
119
|
+
* lead's chain and never inside a remote run (an API token), whatever credential reached here. The lead proposes (chat
|
|
120
|
+
* cards decision_record/decision_retire) and a person confirms. Every writer here and in memory-pass.ts calls this first,
|
|
121
|
+
* and again inside its transaction beside the person's standing (`admission`), so a revocation made since the caller
|
|
122
|
+
* proved them is seen by the write.
|
|
123
|
+
*/
|
|
124
|
+
export function refuseLeadMemoryWrite() {
|
|
125
|
+
if (currentActor()?.lead === true)
|
|
126
|
+
throw Error('Project memory is changed by a person, not the lead.');
|
|
127
|
+
if (activeRemote() !== null)
|
|
128
|
+
throw Error('Project memory is changed by a person with their own password, not an API token.');
|
|
129
|
+
}
|
|
115
130
|
/** Record one settled choice. A superseding decision retires the older one in the same transaction. */
|
|
116
131
|
export function recordDecision(store, args, now = new Date()) {
|
|
132
|
+
refuseLeadMemoryWrite();
|
|
117
133
|
const identity = admission(store, args.repo, args.actor, true);
|
|
118
134
|
const claim = clean(args.draft.claim, TEXT_LIMITS.decisionClaimBytes, 'The decision'), why = clean(args.draft.why, TEXT_LIMITS.decisionWhyBytes, 'The reason');
|
|
119
135
|
const decidedBy = args.draft.decidedBy === undefined ? args.actor : clean(args.draft.decidedBy, TEXT_LIMITS.decisionByBytes, 'Who decided');
|
|
@@ -123,6 +139,8 @@ export function recordDecision(store, args, now = new Date()) {
|
|
|
123
139
|
const sourceKind = args.draft.sourceKind ?? 'manual';
|
|
124
140
|
const sourceRef = args.draft.sourceRef === undefined || args.draft.sourceRef === null ? null : clean(String(args.draft.sourceRef), TEXT_LIMITS.decisionSourceBytes, 'The source');
|
|
125
141
|
return store.transact(() => {
|
|
142
|
+
refuseLeadMemoryWrite();
|
|
143
|
+
admission(store, args.repo, args.actor, true);
|
|
126
144
|
let supersedes = null;
|
|
127
145
|
if (args.draft.supersedes !== undefined && args.draft.supersedes !== null) {
|
|
128
146
|
const older = getDecision(store, args.repo, args.actor, Number(args.draft.supersedes));
|
|
@@ -155,9 +173,10 @@ function changeStatus(store, repo, actor, id, status, reason, now) {
|
|
|
155
173
|
}
|
|
156
174
|
/** Retire a decision that no longer holds; history keeps it. */
|
|
157
175
|
export function retireDecision(store, args, now = new Date()) {
|
|
176
|
+
refuseLeadMemoryWrite();
|
|
158
177
|
admission(store, args.repo, args.actor, true);
|
|
159
178
|
const reason = clean(args.reason, TEXT_LIMITS.decisionRetireBytes, 'The reason');
|
|
160
|
-
return store.transact(() => { const d = changeStatus(store, args.repo, args.actor, args.id, 'retired', reason, now); reindexProjectMemory(store, args.repo); return d; });
|
|
179
|
+
return store.transact(() => { refuseLeadMemoryWrite(); admission(store, args.repo, args.actor, true); const d = changeStatus(store, args.repo, args.actor, args.id, 'retired', reason, now); reindexProjectMemory(store, args.repo); return d; });
|
|
161
180
|
}
|
|
162
181
|
export function decisionHistory(store, repo, actor, id) {
|
|
163
182
|
admission(store, repo, actor);
|
|
@@ -319,7 +319,7 @@ export async function followPullRequests(store, seen, options) {
|
|
|
319
319
|
const filed = store.transact(() => {
|
|
320
320
|
const revised = requestResultChanges(store, options.evidenceRoot, {
|
|
321
321
|
run: publication.run, batch: "", source: revisionSourceOf(scope?.digest ?? null), actor: CI_ACTOR, repos: null, includeUnplaced: true,
|
|
322
|
-
allowMode: false, note, path: "", line: "", request,
|
|
322
|
+
allowMode: false, note, path: "", line: "", request, origin: "flow",
|
|
323
323
|
}, now);
|
|
324
324
|
if (!revised.ok)
|
|
325
325
|
return revised;
|
package/dist/push.d.ts
CHANGED
|
@@ -61,14 +61,21 @@ export declare function validatePushEndpoint(raw: string): {
|
|
|
61
61
|
ok: false;
|
|
62
62
|
problem: string;
|
|
63
63
|
};
|
|
64
|
+
/** A push's class: a stored class, or `release`, which is never stored: it is derived at send time from the plane's own
|
|
65
|
+
* state (release-approval.ts releasePushOf), never from a title, a task id or a link. */
|
|
66
|
+
export type PushClass = NonNullable<Notification["pushClass"]> | "release";
|
|
64
67
|
/** Fixed words, except for one validated machine-only suite/count line. */
|
|
65
|
-
export declare const PUSH_WORDS: Record<
|
|
68
|
+
export declare const PUSH_WORDS: Record<PushClass, {
|
|
66
69
|
title: string;
|
|
67
70
|
body: string;
|
|
68
71
|
}>;
|
|
69
72
|
/** Static words and machine-minted numbers only — re-proved at send time. */
|
|
70
|
-
export declare function safePushLink(pushClass:
|
|
71
|
-
|
|
73
|
+
export declare function safePushLink(pushClass: PushClass, link: string | null): string;
|
|
74
|
+
/** `release`: the class and link the plane derived for this row at send time (releasePushOf); absent or null, the
|
|
75
|
+
* row's stored class and link, revalidated. */
|
|
76
|
+
export declare function buildPushPayload(notification: Notification, waiting?: number | null, release?: {
|
|
77
|
+
link: string;
|
|
78
|
+
} | null): string;
|
|
72
79
|
export type PushFetch = (url: string, init: Record<string, unknown>) => Promise<{
|
|
73
80
|
status: number;
|
|
74
81
|
headers: {
|
package/dist/push.js
CHANGED
|
@@ -20,6 +20,7 @@ import { closeSync, constants as fsConstants, fsyncSync, openSync, readFileSync,
|
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import { createCipheriv, createECDH, createPrivateKey, hkdfSync, randomBytes, createHash, sign as cryptoSign } from "node:crypto";
|
|
22
22
|
import { isCheckProgressLine } from "./check-progress.js";
|
|
23
|
+
import { RELEASE_PUSH_WORDS, releasePushOf, releaseReadyObsolete } from "./release-approval.js";
|
|
23
24
|
export const VAPID_FILE = "vapid-keys.json";
|
|
24
25
|
/** RFC 8291 §4: rs 4096 leaves room for the record's delimiter and tag. */
|
|
25
26
|
const RECORD_SIZE = 4096;
|
|
@@ -193,7 +194,6 @@ export function validatePushEndpoint(raw) {
|
|
|
193
194
|
return { ok: false, problem: "that host is not a known push service — extending the list is a code change, deliberately" };
|
|
194
195
|
return { ok: true, url };
|
|
195
196
|
}
|
|
196
|
-
// ---- the payload ------------------------------------------------------------
|
|
197
197
|
/** Fixed words, except for one validated machine-only suite/count line. */
|
|
198
198
|
export const PUSH_WORDS = {
|
|
199
199
|
decision: { title: "Toolroll", body: "a decision needs you" },
|
|
@@ -201,28 +201,37 @@ export const PUSH_WORDS = {
|
|
|
201
201
|
merge: { title: "Toolroll", body: "a pull request needs a person" },
|
|
202
202
|
attention: { title: "Toolroll", body: "the plane needs attention" },
|
|
203
203
|
progress: { title: "Toolroll", body: "checks in progress" },
|
|
204
|
+
release: { title: "Toolroll", body: RELEASE_PUSH_WORDS },
|
|
204
205
|
};
|
|
205
|
-
|
|
206
|
+
// `/approve/<n>`: a release's opaque locator (a task ref number: no version, commit or branch), resolved by the console.
|
|
207
|
+
const RELEASE_LINK = /^\/approve\/[0-9]{1,15}$/;
|
|
208
|
+
const LINK_SHAPES = [/^\/next$/, /^\/review$/, /^\/system$/, /^\/routines$/, /^\/routines\/[0-9]{1,15}$/, /^\/d\/[0-9]{1,15}$/, /^\/r\/[0-9]{1,15}$/, RELEASE_LINK];
|
|
206
209
|
const CLASS_FALLBACK = {
|
|
207
210
|
decision: "/next",
|
|
208
211
|
pick: "/next",
|
|
209
212
|
merge: "/review",
|
|
210
213
|
attention: "/next",
|
|
211
214
|
progress: "/next",
|
|
215
|
+
release: "/next",
|
|
212
216
|
};
|
|
213
217
|
/** Static words and machine-minted numbers only — re-proved at send time. */
|
|
214
218
|
export function safePushLink(pushClass, link) {
|
|
219
|
+
// The release locator rides only the derived `release` class: a stored link of that shape on any other row is not one.
|
|
220
|
+
if (link !== null && RELEASE_LINK.test(link))
|
|
221
|
+
return pushClass === "release" ? link : CLASS_FALLBACK[pushClass];
|
|
215
222
|
if (link !== null && LINK_SHAPES.some(shape => shape.test(link)))
|
|
216
223
|
return link;
|
|
217
224
|
return CLASS_FALLBACK[pushClass];
|
|
218
225
|
}
|
|
219
|
-
|
|
220
|
-
|
|
226
|
+
/** `release`: the class and link the plane derived for this row at send time (releasePushOf); absent or null, the
|
|
227
|
+
* row's stored class and link, revalidated. */
|
|
228
|
+
export function buildPushPayload(notification, waiting = null, release = null) {
|
|
229
|
+
const pushClass = release !== null ? "release" : notification.pushClass;
|
|
221
230
|
const words = PUSH_WORDS[pushClass];
|
|
222
231
|
return JSON.stringify({
|
|
223
232
|
title: words.title,
|
|
224
233
|
body: pushClass === "progress" && isCheckProgressLine(notification.body) ? notification.body : words.body,
|
|
225
|
-
url: safePushLink(pushClass, notification.link),
|
|
234
|
+
url: safePushLink(pushClass, release !== null ? release.link : notification.link),
|
|
226
235
|
// The opaque tag collapses a crash-duplicate on the lock screen.
|
|
227
236
|
tag: pushClass === "progress" && notification.run !== null ? `so-run-${notification.run}` : `so-${notification.id}`,
|
|
228
237
|
// The app-icon badge (Phase 2E, v2 S4): the server-computed
|
|
@@ -268,6 +277,13 @@ export async function pushPass(store, options) {
|
|
|
268
277
|
if (fenced === null)
|
|
269
278
|
continue; // resolved, retired, rotated, or lost — nothing to send
|
|
270
279
|
const subscription = fenced.subscriptionRow;
|
|
280
|
+
// A release's "ready for your approval" that no longer stands (approved, superseded, put away) is resolved, never
|
|
281
|
+
// sent; one that stands is sent as `release`, its class and link derived from the plane's state now.
|
|
282
|
+
if (releaseReadyObsolete(store, fenced.notificationRow)) {
|
|
283
|
+
store.handle.prepare("UPDATE notification SET resolved_at = ? WHERE id = ? AND resolved_at IS NULL").run(clock().toISOString(), fenced.notificationRow.id);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const release = releasePushOf(store, fenced.notificationRow);
|
|
271
287
|
const endpoint = validatePushEndpoint(subscription.endpoint);
|
|
272
288
|
if (!endpoint.ok) {
|
|
273
289
|
store.retirePushSubscription(subscription.id, "invalid-endpoint", clock());
|
|
@@ -280,7 +296,7 @@ export async function pushPass(store, options) {
|
|
|
280
296
|
// The badge count rides every push: the same inbox classifier the
|
|
281
297
|
// console's own sidebar badge reads, unscoped (a phone has no
|
|
282
298
|
// project pane), computed at send time.
|
|
283
|
-
buildPushPayload(fenced.notificationRow, store.countInboxScoped(null, clock(), 100, null).count));
|
|
299
|
+
buildPushPayload(fenced.notificationRow, store.countInboxScoped(null, clock(), 100, null).count, release));
|
|
284
300
|
}
|
|
285
301
|
catch {
|
|
286
302
|
// Off-curve or malformed subscriber material: the device, not us.
|
package/dist/release-adapters.js
CHANGED
|
@@ -31,6 +31,8 @@ import { randomBytes, randomUUID } from "node:crypto";
|
|
|
31
31
|
import { deploymentGate } from "./deploy-gate.js";
|
|
32
32
|
import { failedBeforeTests } from "./github-jobs.js";
|
|
33
33
|
import { mainHistoryOf, nightlyBoundary } from "./nightly-github.js";
|
|
34
|
+
import { noteReleaseReady, noteReleaseStopped, recordReleaseCi } from "./release-approval.js";
|
|
35
|
+
import { recordVerifiedRelease } from "./release-card.js";
|
|
34
36
|
export { failedBeforeTests } from "./github-jobs.js";
|
|
35
37
|
import { extractCommit } from "./commit-extract.js";
|
|
36
38
|
import { teardownGit, refusal as gitRefusal } from "./teardown-git.js";
|
|
@@ -529,7 +531,7 @@ export function releaseAdapters(options) {
|
|
|
529
531
|
},
|
|
530
532
|
async complete(taskId, receipt) {
|
|
531
533
|
const answer = await toolroll("task", "complete", taskId, "--digest", receipt);
|
|
532
|
-
return answer["ok"] === true ? { ok: true } : { ok: false, message: refused(answer) };
|
|
534
|
+
return answer["ok"] === true ? { ok: true } : { ok: false, message: refused(answer), ...(typeof answer["reason"] === "string" ? { reason: answer["reason"] } : {}) };
|
|
533
535
|
},
|
|
534
536
|
async busy() {
|
|
535
537
|
const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
|
|
@@ -971,6 +973,14 @@ nightlyReads) {
|
|
|
971
973
|
} };
|
|
972
974
|
return {
|
|
973
975
|
...(auto === undefined ? {} : { auto: autoReleaseAdapter(withStore, now, auto) }),
|
|
976
|
+
// One-tap release approval (trusted-release-reports.md, slice A): ids and the coordinator's own readings only; the
|
|
977
|
+
// plane decides from its own records whether anything is written.
|
|
978
|
+
ready: {
|
|
979
|
+
async asked(taskId) { withStore(store => { noteReleaseReady(store, { taskId, now: now() }); }); },
|
|
980
|
+
async ci(input) { withStore(store => { recordReleaseCi(store, { ...input, now: now() }); }); },
|
|
981
|
+
async stopped(input) { withStore(store => { noteReleaseStopped(store, { ...input, now: now() }); }); },
|
|
982
|
+
async verified(input) { withStore(store => { recordVerifiedRelease(store, { ...input, now: now() }); }); },
|
|
983
|
+
},
|
|
974
984
|
// After a failed release check, and before a pass is taken: the plane's own classification (check-failure.ts).
|
|
975
985
|
...(auto === undefined ? {} : {
|
|
976
986
|
async recheck(taskId) {
|