toolroll 0.9.58 → 0.9.60
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 +18 -0
- package/README.md +8 -6
- package/dist/action-ledger.js +10 -1
- package/dist/assignment-adapters.d.ts +1 -1
- package/dist/assignment-adapters.js +1 -1
- package/dist/assignment-delivery.d.ts +16 -0
- package/dist/assignment-delivery.js +54 -28
- package/dist/assignment-status.d.ts +13 -4
- package/dist/assignment-status.js +70 -39
- package/dist/assignment.d.ts +6 -2
- package/dist/assignment.js +13 -8
- package/dist/bin.js +11 -2
- package/dist/brief-fence.d.ts +9 -0
- package/dist/brief-fence.js +22 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +19 -19
- package/dist/builder.js +78 -27
- package/dist/chat-channel.d.ts +1 -0
- package/dist/chat-channel.js +4 -1
- package/dist/chat-delivery.js +2 -0
- package/dist/cli-fast.d.ts +8 -0
- package/dist/cli-fast.js +18 -0
- package/dist/cli-help.d.ts +5 -0
- package/dist/cli-help.js +91 -0
- package/dist/cli.d.ts +6 -5
- package/dist/cli.js +17 -96
- package/dist/contract-changes.d.ts +52 -0
- package/dist/contract-changes.js +61 -0
- package/dist/decision.d.ts +4 -0
- package/dist/decision.js +8 -0
- package/dist/deploy-lock.d.ts +8 -0
- package/dist/deploy-lock.js +45 -0
- package/dist/design/tokens.css +234 -0
- package/dist/discord.js +2 -1
- package/dist/dispatch.js +1 -1
- package/dist/evidence.d.ts +22 -0
- package/dist/evidence.js +76 -17
- package/dist/flow-actions.js +2 -1
- package/dist/guides.js +14 -11
- package/dist/lead-cli.js +4 -1
- package/dist/lead-contract.js +1 -1
- package/dist/lead-doors.js +8 -2
- package/dist/lead-follow.d.ts +2 -0
- package/dist/lead-follow.js +138 -88
- package/dist/lead-tools.js +11 -3
- package/dist/mailbox.js +3 -2
- package/dist/mcp-core.js +1 -1
- package/dist/needs-you.js +3 -1
- package/dist/operate.js +38 -17
- package/dist/password-bearer.d.ts +4 -4
- package/dist/password-bearer.js +2 -2
- package/dist/planner-source.d.ts +23 -53
- package/dist/planner-source.js +43 -68
- package/dist/provider.d.ts +8 -0
- package/dist/provider.js +12 -1
- package/dist/release-gate.d.ts +2 -3
- package/dist/release-gate.js +44 -16
- package/dist/scope.js +14 -0
- package/dist/serve.js +3 -2
- package/dist/server/chrome.d.ts +6 -3
- package/dist/server/chrome.js +8 -4
- package/dist/server/console.css +9 -64
- package/dist/server/render-chat.js +12 -2
- package/dist/server/render-tasks.d.ts +2 -0
- package/dist/server/render-tasks.js +30 -22
- package/dist/server/task-views.js +1 -0
- package/dist/server/tasks.js +3 -1
- package/dist/slack-socket.d.ts +12 -0
- package/dist/slack-socket.js +29 -0
- package/dist/slack.d.ts +0 -7
- package/dist/slack.js +2 -24
- package/dist/steer-mailbox.d.ts +39 -0
- package/dist/steer-mailbox.js +207 -0
- package/dist/steer-status.d.ts +19 -0
- package/dist/steer-status.js +30 -0
- package/dist/store.d.ts +85 -8
- package/dist/store.js +203 -15
- package/dist/surface.js +2 -2
- package/dist/sweep-gate.d.ts +10 -0
- package/dist/sweep-gate.js +18 -0
- package/dist/task-status.js +3 -1
- package/dist/team-updates.js +3 -3
- package/dist/telegram-lead.js +6 -0
- package/dist/telegram.js +9 -7
- package/dist/toolroll-update.js +20 -1
- package/package.json +21 -21
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mechanical difference between two sets of contract terms — a task's goal, exclusions, touches and acceptance
|
|
3
|
+
* rubric. Pure and import-light, so both the planner's source (planner-source.ts) and scope filing (scope.ts, which
|
|
4
|
+
* restarts planning when the terms truly change) read the same comparison.
|
|
5
|
+
*/
|
|
6
|
+
import type { AcceptanceCriterion, EvidenceKind } from "./scope.js";
|
|
7
|
+
/** The four contract fields a planner may propose to amend. */
|
|
8
|
+
export type ContractTerms = {
|
|
9
|
+
goal: string;
|
|
10
|
+
outOfScope: string | null;
|
|
11
|
+
touches: string[];
|
|
12
|
+
acceptance: AcceptanceCriterion[];
|
|
13
|
+
};
|
|
14
|
+
export type ContractChange = {
|
|
15
|
+
field: "goal";
|
|
16
|
+
kind: "changed";
|
|
17
|
+
before: string;
|
|
18
|
+
after: string;
|
|
19
|
+
} | {
|
|
20
|
+
field: "outOfScope";
|
|
21
|
+
kind: "added" | "removed" | "changed";
|
|
22
|
+
before: string | null;
|
|
23
|
+
after: string | null;
|
|
24
|
+
} | {
|
|
25
|
+
field: "touches";
|
|
26
|
+
kind: "added" | "removed";
|
|
27
|
+
path: string;
|
|
28
|
+
} | {
|
|
29
|
+
field: "acceptance";
|
|
30
|
+
kind: "added" | "removed" | "changed";
|
|
31
|
+
id: string;
|
|
32
|
+
before: {
|
|
33
|
+
statement: string;
|
|
34
|
+
evidence: EvidenceKind[];
|
|
35
|
+
how: string | null;
|
|
36
|
+
} | null;
|
|
37
|
+
after: {
|
|
38
|
+
statement: string;
|
|
39
|
+
evidence: EvidenceKind[];
|
|
40
|
+
how: string | null;
|
|
41
|
+
} | null;
|
|
42
|
+
/** For `changed`: which parts moved. `how` is advisory and unsigned,
|
|
43
|
+
* but a planner rewriting it is still a change the operator sees. */
|
|
44
|
+
moved: ("statement" | "evidence" | "how")[];
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Every addition, change, and removal between the filed contract and a
|
|
48
|
+
* proposed one. Whitespace at the ends and touch/evidence order do not
|
|
49
|
+
* count (the scope digest ignores them too); everything else does,
|
|
50
|
+
* including the advisory `how`, because the operator wrote it.
|
|
51
|
+
*/
|
|
52
|
+
export declare function contractChangesOf(filed: ContractTerms, proposed: ContractTerms): ContractChange[];
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const trimmed = (text) => {
|
|
2
|
+
if (text === null)
|
|
3
|
+
return null;
|
|
4
|
+
const t = text.trim();
|
|
5
|
+
return t === "" ? null : t;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Every addition, change, and removal between the filed contract and a
|
|
9
|
+
* proposed one. Whitespace at the ends and touch/evidence order do not
|
|
10
|
+
* count (the scope digest ignores them too); everything else does,
|
|
11
|
+
* including the advisory `how`, because the operator wrote it.
|
|
12
|
+
*/
|
|
13
|
+
export function contractChangesOf(filed, proposed) {
|
|
14
|
+
const changes = [];
|
|
15
|
+
if (filed.goal.trim() !== proposed.goal.trim()) {
|
|
16
|
+
changes.push({ field: "goal", kind: "changed", before: filed.goal, after: proposed.goal });
|
|
17
|
+
}
|
|
18
|
+
const filedOut = trimmed(filed.outOfScope);
|
|
19
|
+
const proposedOut = trimmed(proposed.outOfScope);
|
|
20
|
+
if (filedOut !== proposedOut) {
|
|
21
|
+
changes.push({
|
|
22
|
+
field: "outOfScope",
|
|
23
|
+
kind: filedOut === null ? "added" : proposedOut === null ? "removed" : "changed",
|
|
24
|
+
before: filed.outOfScope,
|
|
25
|
+
after: proposed.outOfScope,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const filedTouches = new Set(filed.touches.map(one => one.trim()).filter(one => one !== ""));
|
|
29
|
+
const proposedTouches = new Set(proposed.touches.map(one => one.trim()).filter(one => one !== ""));
|
|
30
|
+
for (const path of filedTouches)
|
|
31
|
+
if (!proposedTouches.has(path))
|
|
32
|
+
changes.push({ field: "touches", kind: "removed", path });
|
|
33
|
+
for (const path of proposedTouches)
|
|
34
|
+
if (!filedTouches.has(path))
|
|
35
|
+
changes.push({ field: "touches", kind: "added", path });
|
|
36
|
+
const criterionOf = (one) => ({ statement: one.statement, evidence: [...one.evidence].sort(), how: one.how });
|
|
37
|
+
const filedById = new Map(filed.acceptance.map(one => [one.id, one]));
|
|
38
|
+
const proposedById = new Map(proposed.acceptance.map(one => [one.id, one]));
|
|
39
|
+
for (const [id, before] of filedById) {
|
|
40
|
+
const after = proposedById.get(id);
|
|
41
|
+
if (after === undefined) {
|
|
42
|
+
changes.push({ field: "acceptance", kind: "removed", id, before: criterionOf(before), after: null, moved: [] });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const moved = [];
|
|
46
|
+
if (before.statement.trim() !== after.statement.trim())
|
|
47
|
+
moved.push("statement");
|
|
48
|
+
if ([...before.evidence].sort().join(",") !== [...after.evidence].sort().join(","))
|
|
49
|
+
moved.push("evidence");
|
|
50
|
+
if (trimmed(before.how) !== trimmed(after.how))
|
|
51
|
+
moved.push("how");
|
|
52
|
+
if (moved.length > 0) {
|
|
53
|
+
changes.push({ field: "acceptance", kind: "changed", id, before: criterionOf(before), after: criterionOf(after), moved });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const [id, after] of proposedById) {
|
|
57
|
+
if (!filedById.has(id))
|
|
58
|
+
changes.push({ field: "acceptance", kind: "added", id, before: null, after: criterionOf(after), moved: [] });
|
|
59
|
+
}
|
|
60
|
+
return changes;
|
|
61
|
+
}
|
package/dist/decision.d.ts
CHANGED
|
@@ -43,6 +43,10 @@ export declare const LIMITS: {
|
|
|
43
43
|
};
|
|
44
44
|
/** Bytes an operator's note may occupy — the UTF-8 backstop under LIMITS.note. */
|
|
45
45
|
export declare const NOTE_BYTE_CAP: 16000;
|
|
46
|
+
/** Why a question closed without an answer (v119), in plain words. */
|
|
47
|
+
export declare function closedQuestionReason(reason: "scope-changed" | "cancelled"): string;
|
|
48
|
+
/** The same, said once on one line wherever its answer would show. */
|
|
49
|
+
export declare function closedQuestionWords(reason: "scope-changed" | "cancelled"): string;
|
|
46
50
|
/**
|
|
47
51
|
* The one validator every note passes — before a draft persists AND inside
|
|
48
52
|
* the answer CAS, which stays the final gate. Trims, then refuses empty,
|
package/dist/decision.js
CHANGED
|
@@ -25,6 +25,14 @@ export const LIMITS = {
|
|
|
25
25
|
};
|
|
26
26
|
/** Bytes an operator's note may occupy — the UTF-8 backstop under LIMITS.note. */
|
|
27
27
|
export const NOTE_BYTE_CAP = TEXT_LIMITS.noteBytes;
|
|
28
|
+
/** Why a question closed without an answer (v119), in plain words. */
|
|
29
|
+
export function closedQuestionReason(reason) {
|
|
30
|
+
return reason === "scope-changed" ? "The scope changed, so planning started again." : "The task was cancelled.";
|
|
31
|
+
}
|
|
32
|
+
/** The same, said once on one line wherever its answer would show. */
|
|
33
|
+
export function closedQuestionWords(reason) {
|
|
34
|
+
return `Closed without an answer. ${closedQuestionReason(reason)}`;
|
|
35
|
+
}
|
|
28
36
|
/**
|
|
29
37
|
* Unicode that reorders or breaks lines invisibly: bidi controls and the
|
|
30
38
|
* line/paragraph separators. A note carrying these can spoof what a
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Separate from orders.db: no live writer is reserved while deployment drains or backs up.
|
|
2
|
+
* SQLite owns the OS lock until close/process death, including SIGKILL. Never unlink this file:
|
|
3
|
+
* replacing its inode would let two contenders hold different locks for the same journal. */
|
|
4
|
+
export type DeploymentLock = {
|
|
5
|
+
assertHeld(journalFile: string): void;
|
|
6
|
+
release(): void;
|
|
7
|
+
};
|
|
8
|
+
export declare function acquireDeploymentLock(journalFile: string, waitMs?: number): DeploymentLock;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { closeSync, constants, lstatSync, openSync, realpathSync } from "node:fs";
|
|
3
|
+
import { dirname, join, basename } from "node:path";
|
|
4
|
+
const canonical = (file) => join(realpathSync(dirname(file)), basename(file));
|
|
5
|
+
export function acquireDeploymentLock(journalFile, waitMs = 5000) {
|
|
6
|
+
if (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > 5000)
|
|
7
|
+
throw Error("Invalid deployment lock wait.");
|
|
8
|
+
const journal = canonical(journalFile), file = `${journal}.lock.sqlite`;
|
|
9
|
+
// Only create a missing file. On POSIX, closing ANY independently opened descriptor
|
|
10
|
+
// for an existing inode can drop this process's fcntl lock, even another connection's.
|
|
11
|
+
// Let SQLite manage all descriptors for an existing coordination file.
|
|
12
|
+
try {
|
|
13
|
+
const fd = openSync(file, constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
|
|
14
|
+
closeSync(fd);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error.code !== "EEXIST")
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
if (!lstatSync(file).isFile())
|
|
21
|
+
throw Error(`Unexpected deployment lock: ${file}`);
|
|
22
|
+
// Lazy, as elsewhere in the CLI: merely importing the help must not load SQLite.
|
|
23
|
+
const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite");
|
|
24
|
+
const db = new DatabaseSync(file);
|
|
25
|
+
try {
|
|
26
|
+
db.exec(`PRAGMA busy_timeout=${waitMs}; BEGIN EXCLUSIVE`);
|
|
27
|
+
}
|
|
28
|
+
catch (cause) {
|
|
29
|
+
db.close();
|
|
30
|
+
throw new Error(`Deployment is busy or its lock is unavailable: ${journal}. Let it finish, then retry.`, { cause });
|
|
31
|
+
}
|
|
32
|
+
let held = true;
|
|
33
|
+
return {
|
|
34
|
+
assertHeld(expected) {
|
|
35
|
+
if (!held || canonical(expected) !== journal)
|
|
36
|
+
throw Error("The deployment journal lock is not held.");
|
|
37
|
+
},
|
|
38
|
+
release() {
|
|
39
|
+
if (!held)
|
|
40
|
+
return;
|
|
41
|
+
db.close();
|
|
42
|
+
held = false;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/* @generated by youi from the Signal design language v1.0.0. Do not edit: change the tokens in design-language and run `youi sync`. */
|
|
2
|
+
/* Built with Terrazzo (DTCG 2025.10 resolver). */
|
|
3
|
+
:root {
|
|
4
|
+
color-scheme: light;
|
|
5
|
+
--so-accent: #171717;
|
|
6
|
+
--so-accent-hover: #383838;
|
|
7
|
+
--so-accent-text: #171717;
|
|
8
|
+
--so-attention: var(--so-signal);
|
|
9
|
+
--so-attention-soft: var(--so-signal-soft);
|
|
10
|
+
--so-code-bg: #fafafa;
|
|
11
|
+
/* Vermilion: failure and irreversible acts. */
|
|
12
|
+
--so-danger: #c4320a;
|
|
13
|
+
--so-danger-soft: #feebe7;
|
|
14
|
+
--so-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
|
15
|
+
--so-font-mono: "Geist Mono", ui-monospace, "SF Mono", "SFMono-Regular", "Menlo", "Consolas", monospace;
|
|
16
|
+
--so-font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
17
|
+
/* Frame Grey: the frame the sidebar sits on, and the page background of server pages. */
|
|
18
|
+
--so-ground: #efefef;
|
|
19
|
+
/* Live Blue: something is running right now. */
|
|
20
|
+
--so-info: #0d74ce;
|
|
21
|
+
--so-info-soft: #e6f4fe;
|
|
22
|
+
/* Ink: all body text, and the colour of every act: primary buttons, the New task button, switches and radio cards when on. */
|
|
23
|
+
--so-ink: #171717;
|
|
24
|
+
/* Control Line: the boundary of fields, the composer, and the switch track when off. */
|
|
25
|
+
--so-input-line: #d4d4d4;
|
|
26
|
+
/* Hairline: every divider. */
|
|
27
|
+
--so-line: #e6e6e6;
|
|
28
|
+
--so-live: #0d74ce;
|
|
29
|
+
/* Muted: meta lines, fact keys, ghost buttons, placeholders, idle tab counts. */
|
|
30
|
+
--so-muted: #666666;
|
|
31
|
+
--so-nav-current: #ffffff;
|
|
32
|
+
--so-nav-current-ink: #171717;
|
|
33
|
+
--so-nav-hover: #e4e4e4;
|
|
34
|
+
/* Nav Ink: idle sidebar rows and their hover fill on the frame. */
|
|
35
|
+
--so-nav-ink: #525252;
|
|
36
|
+
--so-neutral-ink: #525252;
|
|
37
|
+
/* Neutral Badge: the badge for anything that is a fact and not a claim on the person, such as queued, running or built today in a project peek, or a count in a fold. */
|
|
38
|
+
--so-neutral-soft: #f0f0f0;
|
|
39
|
+
--so-on-accent: #ffffff;
|
|
40
|
+
--so-on-attention: var(--so-on-signal);
|
|
41
|
+
--so-on-signal: #ffffff;
|
|
42
|
+
--so-overlay: rgb(0 0 0 / .32);
|
|
43
|
+
/* Paper: every sheet, card, field, menu and dialog. */
|
|
44
|
+
--so-paper: #ffffff;
|
|
45
|
+
--so-phone-block: 14px;
|
|
46
|
+
--so-phone-card: 12px;
|
|
47
|
+
--so-phone-gap: 12px;
|
|
48
|
+
--so-phone-gutter: 16px;
|
|
49
|
+
--so-phone-row: 8px;
|
|
50
|
+
--so-pill-shadow: 0px 0px 0px 1px rgb(0 0 0 / .06), 0px 1px 2px 0px rgb(0 0 0 / .06);
|
|
51
|
+
--so-radius-badge: 5px;
|
|
52
|
+
--so-radius-card: 10px;
|
|
53
|
+
--so-radius-full: 9999px;
|
|
54
|
+
--so-radius-md: 8px;
|
|
55
|
+
--so-radius-sheet: 12px;
|
|
56
|
+
--so-radius-sm: 6px;
|
|
57
|
+
/* Raised: segmented-tab tracks, notices, row hover fills, and the muted well behind a "next step" block or a neutral problem line. */
|
|
58
|
+
--so-raised: #f5f5f5;
|
|
59
|
+
--so-scroll: #cfcfcf;
|
|
60
|
+
/* Selection: text selection highlight, with ink text on it. */
|
|
61
|
+
--so-selection: #d4d4d4;
|
|
62
|
+
--so-shadow-overlay: 0px 0px 0px 1px rgb(0 0 0 / .08), 0px 24px 48px -12px rgb(0 0 0 / .22);
|
|
63
|
+
--so-sheet-shadow: 0px 0px 0px 1px rgb(0 0 0 / .06), 0px 1px 2px 0px rgb(0 0 0 / .04), 0px 4px 12px -6px rgb(0 0 0 / .06);
|
|
64
|
+
--so-sidebar: #efefef;
|
|
65
|
+
/* Ink accent: the default accent, used for what waits on a person. */
|
|
66
|
+
--so-signal: #171717;
|
|
67
|
+
--so-signal-hover: #383838;
|
|
68
|
+
/* Ink wash: the fill behind attention badge text and the 3px focus halo around a focused field or composer — one step stronger than the neutral badge fill, so a waiting badge still reads first. */
|
|
69
|
+
--so-signal-soft: #e8e8e8;
|
|
70
|
+
/* Soft: hover fills on outline and ghost buttons, menu items and suggestions. */
|
|
71
|
+
--so-soft: #f2f2f2;
|
|
72
|
+
--so-space-2xl: 24px;
|
|
73
|
+
--so-space-3xl: 28px;
|
|
74
|
+
--so-space-lg: 16px;
|
|
75
|
+
--so-space-md: 12px;
|
|
76
|
+
--so-space-sm: 8px;
|
|
77
|
+
--so-space-xl: 20px;
|
|
78
|
+
--so-space-xs: 4px;
|
|
79
|
+
/* Built Green: done and passed, used for "Checks passed", "Open now" and a finished live step. */
|
|
80
|
+
--so-success: #218358;
|
|
81
|
+
--so-success-soft: #e6f6eb;
|
|
82
|
+
--so-typography-badge: var(--so-typography-badge-font-weight) var(--so-typography-badge-font-size)/var(--so-typography-badge-line-height) var(--so-typography-badge-font-family);
|
|
83
|
+
--so-typography-badge-font-family: var(--so-font-sans);
|
|
84
|
+
--so-typography-badge-font-size: 11.5px;
|
|
85
|
+
--so-typography-badge-font-weight: 500;
|
|
86
|
+
--so-typography-badge-line-height: 1.5652;
|
|
87
|
+
--so-typography-body: var(--so-typography-body-font-weight) var(--so-typography-body-font-size)/var(--so-typography-body-line-height) var(--so-typography-body-font-family);
|
|
88
|
+
--so-typography-body-font-family: var(--so-font-sans);
|
|
89
|
+
--so-typography-body-font-size: 13px;
|
|
90
|
+
--so-typography-body-font-weight: 400;
|
|
91
|
+
--so-typography-body-line-height: 1.5;
|
|
92
|
+
--so-typography-body-reading: var(--so-typography-body-reading-font-weight) var(--so-typography-body-reading-font-size)/var(--so-typography-body-reading-line-height) var(--so-typography-body-reading-font-family);
|
|
93
|
+
--so-typography-body-reading-font-family: var(--so-font-sans);
|
|
94
|
+
--so-typography-body-reading-font-size: 14px;
|
|
95
|
+
--so-typography-body-reading-font-weight: 400;
|
|
96
|
+
--so-typography-body-reading-line-height: 1.7;
|
|
97
|
+
--so-typography-caption: var(--so-typography-caption-font-weight) var(--so-typography-caption-font-size)/var(--so-typography-caption-line-height) var(--so-typography-caption-font-family);
|
|
98
|
+
--so-typography-caption-font-family: var(--so-font-sans);
|
|
99
|
+
--so-typography-caption-font-size: 12px;
|
|
100
|
+
--so-typography-caption-font-weight: 500;
|
|
101
|
+
--so-typography-caption-line-height: 1.5;
|
|
102
|
+
--so-typography-display: var(--so-typography-display-font-weight) var(--so-typography-display-font-size)/var(--so-typography-display-line-height) var(--so-typography-display-font-family);
|
|
103
|
+
--so-typography-display-font-family: var(--so-font-sans);
|
|
104
|
+
--so-typography-display-font-size: 26px;
|
|
105
|
+
--so-typography-display-font-weight: 600;
|
|
106
|
+
--so-typography-display-letter-spacing: -0.65px;
|
|
107
|
+
--so-typography-display-line-height: 1.25;
|
|
108
|
+
--so-typography-headline: var(--so-typography-headline-font-weight) var(--so-typography-headline-font-size)/var(--so-typography-headline-line-height) var(--so-typography-headline-font-family);
|
|
109
|
+
--so-typography-headline-font-family: var(--so-font-sans);
|
|
110
|
+
--so-typography-headline-font-size: 18px;
|
|
111
|
+
--so-typography-headline-font-weight: 600;
|
|
112
|
+
--so-typography-headline-line-height: 1.375;
|
|
113
|
+
--so-typography-label: var(--so-typography-label-font-weight) var(--so-typography-label-font-size)/var(--so-typography-label-line-height) var(--so-typography-label-font-family);
|
|
114
|
+
--so-typography-label-font-family: var(--so-font-sans);
|
|
115
|
+
--so-typography-label-font-size: 13px;
|
|
116
|
+
--so-typography-label-font-weight: 500;
|
|
117
|
+
--so-typography-label-line-height: 1.2;
|
|
118
|
+
--so-typography-mono: var(--so-typography-mono-font-weight) var(--so-typography-mono-font-size)/var(--so-typography-mono-line-height) var(--so-typography-mono-font-family);
|
|
119
|
+
--so-typography-mono-count: var(--so-typography-mono-count-font-weight) var(--so-typography-mono-count-font-size)/var(--so-typography-mono-count-line-height) var(--so-typography-mono-count-font-family);
|
|
120
|
+
--so-typography-mono-count-font-family: var(--so-font-mono);
|
|
121
|
+
--so-typography-mono-count-font-size: 11px;
|
|
122
|
+
--so-typography-mono-count-font-weight: 500;
|
|
123
|
+
--so-typography-mono-count-line-height: 1;
|
|
124
|
+
--so-typography-mono-font-family: var(--so-font-mono);
|
|
125
|
+
--so-typography-mono-font-size: 12px;
|
|
126
|
+
--so-typography-mono-font-weight: 400;
|
|
127
|
+
--so-typography-mono-line-height: 1.5;
|
|
128
|
+
--so-typography-title: var(--so-typography-title-font-weight) var(--so-typography-title-font-size)/var(--so-typography-title-line-height) var(--so-typography-title-font-family);
|
|
129
|
+
--so-typography-title-font-family: var(--so-font-sans);
|
|
130
|
+
--so-typography-title-font-size: 15px;
|
|
131
|
+
--so-typography-title-font-weight: 600;
|
|
132
|
+
--so-typography-title-letter-spacing: -0.15px;
|
|
133
|
+
--so-typography-title-line-height: 1.4;
|
|
134
|
+
--so-typography-title-sm: var(--so-typography-title-sm-font-weight) var(--so-typography-title-sm-font-size)/var(--so-typography-title-sm-line-height) var(--so-typography-title-sm-font-family);
|
|
135
|
+
--so-typography-title-sm-font-family: var(--so-font-sans);
|
|
136
|
+
--so-typography-title-sm-font-size: 14px;
|
|
137
|
+
--so-typography-title-sm-font-weight: 600;
|
|
138
|
+
--so-typography-title-sm-letter-spacing: -0.14px;
|
|
139
|
+
--so-typography-title-sm-line-height: 1.3;
|
|
140
|
+
--so-user-bubble: #f2f2f2;
|
|
141
|
+
/* Amber: setup trouble and caution. */
|
|
142
|
+
--so-warning: #ab6400;
|
|
143
|
+
--so-warning-soft: #fff4d5;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@media (prefers-color-scheme: dark) {
|
|
147
|
+
:root:not([data-theme="light"]) {
|
|
148
|
+
color-scheme: dark;
|
|
149
|
+
--so-accent: #ededed;
|
|
150
|
+
--so-accent-hover: #ffffff;
|
|
151
|
+
--so-accent-text: #ededed;
|
|
152
|
+
--so-code-bg: #111111;
|
|
153
|
+
--so-danger: #ff977d;
|
|
154
|
+
--so-danger-soft: rgb(255 151 125 / .12);
|
|
155
|
+
--so-ground: #0b0b0b;
|
|
156
|
+
--so-info: #70b8ff;
|
|
157
|
+
--so-info-soft: rgb(112 184 255 / .12);
|
|
158
|
+
--so-ink: #ededed;
|
|
159
|
+
--so-input-line: #363636;
|
|
160
|
+
--so-line: #262626;
|
|
161
|
+
--so-live: #70b8ff;
|
|
162
|
+
--so-muted: #a1a1a1;
|
|
163
|
+
--so-nav-current: #1f1f1f;
|
|
164
|
+
--so-nav-current-ink: #ededed;
|
|
165
|
+
--so-nav-hover: #171717;
|
|
166
|
+
--so-nav-ink: #a1a1a1;
|
|
167
|
+
--so-neutral-ink: #b4b4b4;
|
|
168
|
+
--so-neutral-soft: #1f1f1f;
|
|
169
|
+
--so-on-accent: #0a0a0a;
|
|
170
|
+
--so-on-signal: #0a0a0a;
|
|
171
|
+
--so-overlay: rgb(0 0 0 / .6);
|
|
172
|
+
--so-paper: #161616;
|
|
173
|
+
--so-pill-shadow: 0px 0px 0px 1px #2a2a2a, 0px 1px 2px 0px rgb(0 0 0 / .5);
|
|
174
|
+
--so-raised: #1c1c1c;
|
|
175
|
+
--so-scroll: #333333;
|
|
176
|
+
--so-selection: #3a3a3a;
|
|
177
|
+
--so-shadow-overlay: 0px 0px 0px 1px #2e2e2e, 0px 24px 48px -12px rgb(0 0 0 / .7);
|
|
178
|
+
--so-sheet-shadow: 0px 0px 0px 1px #262626, 0px 1px 2px 0px rgb(0 0 0 / .4);
|
|
179
|
+
--so-sidebar: #0b0b0b;
|
|
180
|
+
--so-signal: #ededed;
|
|
181
|
+
--so-signal-hover: #ffffff;
|
|
182
|
+
--so-signal-soft: #2e2e2e;
|
|
183
|
+
--so-soft: #1f1f1f;
|
|
184
|
+
--so-success: #3dd68c;
|
|
185
|
+
--so-success-soft: rgb(61 214 140 / .12);
|
|
186
|
+
--so-user-bubble: #202020;
|
|
187
|
+
--so-warning: #ffca16;
|
|
188
|
+
--so-warning-soft: rgb(255 202 22 / .12);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
:root[data-theme="dark"] {
|
|
193
|
+
color-scheme: dark;
|
|
194
|
+
--so-accent: #ededed;
|
|
195
|
+
--so-accent-hover: #ffffff;
|
|
196
|
+
--so-accent-text: #ededed;
|
|
197
|
+
--so-code-bg: #111111;
|
|
198
|
+
--so-danger: #ff977d;
|
|
199
|
+
--so-danger-soft: rgb(255 151 125 / .12);
|
|
200
|
+
--so-ground: #0b0b0b;
|
|
201
|
+
--so-info: #70b8ff;
|
|
202
|
+
--so-info-soft: rgb(112 184 255 / .12);
|
|
203
|
+
--so-ink: #ededed;
|
|
204
|
+
--so-input-line: #363636;
|
|
205
|
+
--so-line: #262626;
|
|
206
|
+
--so-live: #70b8ff;
|
|
207
|
+
--so-muted: #a1a1a1;
|
|
208
|
+
--so-nav-current: #1f1f1f;
|
|
209
|
+
--so-nav-current-ink: #ededed;
|
|
210
|
+
--so-nav-hover: #171717;
|
|
211
|
+
--so-nav-ink: #a1a1a1;
|
|
212
|
+
--so-neutral-ink: #b4b4b4;
|
|
213
|
+
--so-neutral-soft: #1f1f1f;
|
|
214
|
+
--so-on-accent: #0a0a0a;
|
|
215
|
+
--so-on-signal: #0a0a0a;
|
|
216
|
+
--so-overlay: rgb(0 0 0 / .6);
|
|
217
|
+
--so-paper: #161616;
|
|
218
|
+
--so-pill-shadow: 0px 0px 0px 1px #2a2a2a, 0px 1px 2px 0px rgb(0 0 0 / .5);
|
|
219
|
+
--so-raised: #1c1c1c;
|
|
220
|
+
--so-scroll: #333333;
|
|
221
|
+
--so-selection: #3a3a3a;
|
|
222
|
+
--so-shadow-overlay: 0px 0px 0px 1px #2e2e2e, 0px 24px 48px -12px rgb(0 0 0 / .7);
|
|
223
|
+
--so-sheet-shadow: 0px 0px 0px 1px #262626, 0px 1px 2px 0px rgb(0 0 0 / .4);
|
|
224
|
+
--so-sidebar: #0b0b0b;
|
|
225
|
+
--so-signal: #ededed;
|
|
226
|
+
--so-signal-hover: #ffffff;
|
|
227
|
+
--so-signal-soft: #2e2e2e;
|
|
228
|
+
--so-soft: #1f1f1f;
|
|
229
|
+
--so-success: #3dd68c;
|
|
230
|
+
--so-success-soft: rgb(61 214 140 / .12);
|
|
231
|
+
--so-user-bubble: #202020;
|
|
232
|
+
--so-warning: #ffca16;
|
|
233
|
+
--so-warning-soft: rgb(255 202 22 / .12);
|
|
234
|
+
}
|
package/dist/discord.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/** A single leased Discord Gateway connection, hosted by the ordinary worker. */
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
|
-
import { Client, GatewayIntentBits, Partials } from "discord.js";
|
|
5
4
|
import { loadDiscordCredentials, discordApi, object, discordId, } from "./discord-api.js";
|
|
6
5
|
import { ChatState, chatHash } from "./chat-delivery-state.js";
|
|
7
6
|
import { receiveDiscord, processDiscordEvent, deliverDiscordPart, planDiscordNotifications, planDiscordRooms, } from "./discord-chat.js";
|
|
@@ -39,6 +38,8 @@ export async function followDiscord(options) {
|
|
|
39
38
|
};
|
|
40
39
|
const problem = (message) => state.setProblem(credentials.installation, owner, message);
|
|
41
40
|
const api = discordApi(credentials.botToken);
|
|
41
|
+
// The SDK loads once Discord is set up, not with every command that could host the follower.
|
|
42
|
+
const { Client, GatewayIntentBits, Partials } = await import("discord.js");
|
|
42
43
|
const client = new Client({
|
|
43
44
|
// Guild messages and their content are needed for rooms; the Message
|
|
44
45
|
// Content intent must be enabled for the app in Discord's developer portal.
|
package/dist/dispatch.js
CHANGED
|
@@ -319,7 +319,7 @@ export function diagnoseTaskDispatch(store, taskId, now, readableRepos = null) {
|
|
|
319
319
|
// this is where the task page says so.
|
|
320
320
|
const sourceProblem = plannerSourceProblemOf(store, taskId);
|
|
321
321
|
if (sourceProblem !== null)
|
|
322
|
-
return answer("planner-source", "waiting",
|
|
322
|
+
return answer("planner-source", "waiting", sourceProblem.title, sourceProblem.detail, { action: "write-scope", role });
|
|
323
323
|
return answer("planning-ready", "retrying", "Planner ready", "An eligible worker can draft the scope on the next pass.", { role });
|
|
324
324
|
}
|
|
325
325
|
if (role === "scout")
|
package/dist/evidence.d.ts
CHANGED
|
@@ -47,6 +47,10 @@ export declare const PROGRESS_PREFIX = "STANDING-ORDERS-PROGRESS-";
|
|
|
47
47
|
* (adaptive execution plans): a terminal file like park or proof, written
|
|
48
48
|
* at most once, read once at settlement. */
|
|
49
49
|
export declare const PROPOSAL_PREFIX = "STANDING-ORDERS-PROPOSAL-";
|
|
50
|
+
/** The running build's steering mailbox: a FOLDER, not a file. The pulse
|
|
51
|
+
* writes each operator note into it as NNN.json (a temp name, then a
|
|
52
|
+
* rename); the agent acknowledges a note by moving it into `handled/`. */
|
|
53
|
+
export declare const STEER_PREFIX = "STANDING-ORDERS-STEER-";
|
|
50
54
|
export declare const MAILBOX_SUFFIX = ".json";
|
|
51
55
|
/** v105: left in a run's evidence folder when the retention setting removed its files (retention.ts). */
|
|
52
56
|
export declare const RETENTION_NOTE = "REMOVED-BY-RETENTION";
|
|
@@ -78,6 +82,11 @@ export declare function looksLikeMailbox(name: string): boolean;
|
|
|
78
82
|
* The pre-rename NIGHTORDERS- prefix stays recognized here — a worktree cut
|
|
79
83
|
* down before 2026-08-13 may still hold one, and cleanup has no vintage. */
|
|
80
84
|
export declare function looksLikeProtocolFile(name: string): boolean;
|
|
85
|
+
/** A steering mailbox folder, or anything in it, as a directory listing or
|
|
86
|
+
* `git status` names it: an untracked folder is reported with a trailing
|
|
87
|
+
* slash, or file by file where a repository shows every untracked file. */
|
|
88
|
+
export declare function looksLikeSteerFolder(name: string): boolean;
|
|
89
|
+
export declare function steerFolderName(): string;
|
|
81
90
|
export declare function handoffName(): string;
|
|
82
91
|
export declare function planFileName(): string;
|
|
83
92
|
/** The build's optional proof manifest (Priority 2), named exactly like
|
|
@@ -208,6 +217,19 @@ export declare function readVerifiedArtifact(root: string, artifact: Artifact):
|
|
|
208
217
|
ok: false;
|
|
209
218
|
problem: string;
|
|
210
219
|
};
|
|
220
|
+
/**
|
|
221
|
+
* Whether an artifact's bytes still verify, without returning them. Status
|
|
222
|
+
* sweeps ask this of every artifact of every run, so a file proved earlier and
|
|
223
|
+
* not touched since (same artifact row, device, inode, size, mtime and ctime
|
|
224
|
+
* on the descriptor just opened) costs an fstat, not a re-read and SHA-256.
|
|
225
|
+
* Every path check still runs on each call; a changed or new file is hashed.
|
|
226
|
+
*/
|
|
227
|
+
export declare function verifyArtifact(root: string, artifact: Artifact): {
|
|
228
|
+
ok: true;
|
|
229
|
+
} | {
|
|
230
|
+
ok: false;
|
|
231
|
+
problem: string;
|
|
232
|
+
};
|
|
211
233
|
/** The file alone, no row — quarantines use this; they belong to no live run. */
|
|
212
234
|
export declare function writeEvidenceFile(root: string, runId: number, name: string, content: Buffer): string;
|
|
213
235
|
/**
|
package/dist/evidence.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* store holds keys relative to that root, never absolute paths.
|
|
15
15
|
*/
|
|
16
16
|
import { createHash, randomBytes } from "node:crypto";
|
|
17
|
-
import { closeSync, existsSync, constants, fstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
17
|
+
import { closeSync, existsSync, constants, fstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
18
18
|
import { join, sep } from "node:path";
|
|
19
19
|
import { namedPath } from "./names.js";
|
|
20
20
|
import { LIMITS } from "./decision.js";
|
|
@@ -50,6 +50,10 @@ export const PROGRESS_PREFIX = "STANDING-ORDERS-PROGRESS-";
|
|
|
50
50
|
* (adaptive execution plans): a terminal file like park or proof, written
|
|
51
51
|
* at most once, read once at settlement. */
|
|
52
52
|
export const PROPOSAL_PREFIX = "STANDING-ORDERS-PROPOSAL-";
|
|
53
|
+
/** The running build's steering mailbox: a FOLDER, not a file. The pulse
|
|
54
|
+
* writes each operator note into it as NNN.json (a temp name, then a
|
|
55
|
+
* rename); the agent acknowledges a note by moving it into `handled/`. */
|
|
56
|
+
export const STEER_PREFIX = "STANDING-ORDERS-STEER-";
|
|
53
57
|
export const MAILBOX_SUFFIX = ".json";
|
|
54
58
|
/** v105: left in a run's evidence folder when the retention setting removed its files (retention.ts). */
|
|
55
59
|
export const RETENTION_NOTE = "REMOVED-BY-RETENTION";
|
|
@@ -128,6 +132,8 @@ export function looksLikeMailbox(name) {
|
|
|
128
132
|
* The pre-rename NIGHTORDERS- prefix stays recognized here — a worktree cut
|
|
129
133
|
* down before 2026-08-13 may still hold one, and cleanup has no vintage. */
|
|
130
134
|
export function looksLikeProtocolFile(name) {
|
|
135
|
+
if (looksLikeSteerFolder(name))
|
|
136
|
+
return true;
|
|
131
137
|
return ((name.startsWith(MAILBOX_PREFIX) ||
|
|
132
138
|
name.startsWith(HANDOFF_PREFIX) ||
|
|
133
139
|
name.startsWith(PLAN_PREFIX) ||
|
|
@@ -141,6 +147,15 @@ export function looksLikeProtocolFile(name) {
|
|
|
141
147
|
name.startsWith("NIGHTORDERS-")) &&
|
|
142
148
|
name.endsWith(MAILBOX_SUFFIX));
|
|
143
149
|
}
|
|
150
|
+
/** A steering mailbox folder, or anything in it, as a directory listing or
|
|
151
|
+
* `git status` names it: an untracked folder is reported with a trailing
|
|
152
|
+
* slash, or file by file where a repository shows every untracked file. */
|
|
153
|
+
export function looksLikeSteerFolder(name) {
|
|
154
|
+
return /^STANDING-ORDERS-STEER-[0-9a-f]{16}(?:\/.*)?$/.test(name);
|
|
155
|
+
}
|
|
156
|
+
export function steerFolderName() {
|
|
157
|
+
return `${STEER_PREFIX}${randomBytes(8).toString("hex")}`;
|
|
158
|
+
}
|
|
144
159
|
export function handoffName() {
|
|
145
160
|
return `${HANDOFF_PREFIX}${randomBytes(8).toString("hex")}${MAILBOX_SUFFIX}`;
|
|
146
161
|
}
|
|
@@ -412,6 +427,29 @@ export function storeEvidence(store, root, runId, kind, name, content, capture,
|
|
|
412
427
|
// Recovery uses this read boundary for retained receipts and handoffs: inspecting
|
|
413
428
|
// bytes must never reseal a missing artifact or turn a saved failure into a pass.
|
|
414
429
|
export function readVerifiedArtifact(root, artifact) {
|
|
430
|
+
return withArtifactFile(root, artifact, readAndHash);
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Whether an artifact's bytes still verify, without returning them. Status
|
|
434
|
+
* sweeps ask this of every artifact of every run, so a file proved earlier and
|
|
435
|
+
* not touched since (same artifact row, device, inode, size, mtime and ctime
|
|
436
|
+
* on the descriptor just opened) costs an fstat, not a re-read and SHA-256.
|
|
437
|
+
* Every path check still runs on each call; a changed or new file is hashed.
|
|
438
|
+
*/
|
|
439
|
+
export function verifyArtifact(root, artifact) {
|
|
440
|
+
return withArtifactFile(root, artifact, (fd, stat, memoKey) => {
|
|
441
|
+
if (VERIFIED_FILES.get(memoKey) === fileIdentity(artifact, stat))
|
|
442
|
+
return { ok: true };
|
|
443
|
+
const read = readAndHash(fd, stat, memoKey, artifact);
|
|
444
|
+
return read.ok ? { ok: true } : read;
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
/** Files proved by an earlier read, keyed by artifact and resolved path. Bounded; the oldest proof is forgotten first. */
|
|
448
|
+
const VERIFIED_FILES = new Map();
|
|
449
|
+
const VERIFIED_FILES_LIMIT = 20_000;
|
|
450
|
+
const fileIdentity = (artifact, stat) => [artifact.sha256, artifact.bytesStored, stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(":");
|
|
451
|
+
/** The checks both readers share, on the descriptor `use` then reads. */
|
|
452
|
+
function withArtifactFile(root, artifact, use) {
|
|
415
453
|
const segments = artifact.key.split("/");
|
|
416
454
|
const wellFormed = segments.length > 0 &&
|
|
417
455
|
segments.every(segment => segment.length > 0 &&
|
|
@@ -447,32 +485,40 @@ export function readVerifiedArtifact(root, artifact) {
|
|
|
447
485
|
return { ok: false, problem: "cannot open the file without following a link" };
|
|
448
486
|
}
|
|
449
487
|
try {
|
|
450
|
-
const stat = fstatSync(fd);
|
|
488
|
+
const stat = fstatSync(fd, { bigint: true });
|
|
451
489
|
if (!stat.isFile())
|
|
452
490
|
return { ok: false, problem: "not a regular file" };
|
|
453
491
|
if (Number(stat.size) !== artifact.bytesStored) {
|
|
454
492
|
return { ok: false, problem: "the file's size no longer matches its record" };
|
|
455
493
|
}
|
|
456
|
-
|
|
457
|
-
let offset = 0;
|
|
458
|
-
while (offset < buffer.length) {
|
|
459
|
-
const read = readSync(fd, buffer, offset, buffer.length - offset, offset);
|
|
460
|
-
if (read <= 0)
|
|
461
|
-
break;
|
|
462
|
-
offset += read;
|
|
463
|
-
}
|
|
464
|
-
if (offset !== buffer.length)
|
|
465
|
-
return { ok: false, problem: "the file ended early" };
|
|
466
|
-
const digest = createHash("sha256").update(buffer).digest("hex");
|
|
467
|
-
if (digest !== artifact.sha256) {
|
|
468
|
-
return { ok: false, problem: "the file no longer hashes to its record" };
|
|
469
|
-
}
|
|
470
|
-
return { ok: true, content: buffer };
|
|
494
|
+
return use(fd, stat, `${artifact.id}\0${resolved}`, artifact);
|
|
471
495
|
}
|
|
472
496
|
finally {
|
|
473
497
|
closeSync(fd);
|
|
474
498
|
}
|
|
475
499
|
}
|
|
500
|
+
/** Read the whole file from the open descriptor; only bytes matching the row's SHA-256 leave, and only they are remembered as proved. */
|
|
501
|
+
function readAndHash(fd, stat, memoKey, artifact) {
|
|
502
|
+
VERIFIED_FILES.delete(memoKey);
|
|
503
|
+
const buffer = Buffer.alloc(artifact.bytesStored);
|
|
504
|
+
let offset = 0;
|
|
505
|
+
while (offset < buffer.length) {
|
|
506
|
+
const read = readSync(fd, buffer, offset, buffer.length - offset, offset);
|
|
507
|
+
if (read <= 0)
|
|
508
|
+
break;
|
|
509
|
+
offset += read;
|
|
510
|
+
}
|
|
511
|
+
if (offset !== buffer.length)
|
|
512
|
+
return { ok: false, problem: "the file ended early" };
|
|
513
|
+
const digest = createHash("sha256").update(buffer).digest("hex");
|
|
514
|
+
if (digest !== artifact.sha256) {
|
|
515
|
+
return { ok: false, problem: "the file no longer hashes to its record" };
|
|
516
|
+
}
|
|
517
|
+
if (VERIFIED_FILES.size >= VERIFIED_FILES_LIMIT)
|
|
518
|
+
VERIFIED_FILES.delete(VERIFIED_FILES.keys().next().value);
|
|
519
|
+
VERIFIED_FILES.set(memoKey, fileIdentity(artifact, stat));
|
|
520
|
+
return { ok: true, content: buffer };
|
|
521
|
+
}
|
|
476
522
|
/** The file alone, no row — quarantines use this; they belong to no live run. */
|
|
477
523
|
export function writeEvidenceFile(root, runId, name, content) {
|
|
478
524
|
const dir = join(root, String(runId));
|
|
@@ -502,6 +548,19 @@ export function quarantineMailboxes(worktree, root, runId) {
|
|
|
502
548
|
if (!looksLikeProtocolFile(name))
|
|
503
549
|
continue;
|
|
504
550
|
const path = join(worktree, name);
|
|
551
|
+
// An earlier attempt's steering folder holds copies of notes the
|
|
552
|
+
// database already keeps: removed whole (a symlink is removed, never
|
|
553
|
+
// followed), never quarantined.
|
|
554
|
+
if (looksLikeSteerFolder(name)) {
|
|
555
|
+
try {
|
|
556
|
+
rmSync(path, { recursive: true, force: true });
|
|
557
|
+
swept.push(name);
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
// Its name carries an old nonce; nothing reads it again.
|
|
561
|
+
}
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
505
564
|
if (root !== null && runId !== null) {
|
|
506
565
|
const read = readMailbox(path);
|
|
507
566
|
if (read.ok) {
|