agent-lyceum 0.4.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/README.zh-TW.md +133 -0
- package/dist/adapters/claude.d.ts +14 -0
- package/dist/adapters/claude.js +155 -0
- package/dist/adapters/codex.d.ts +5 -0
- package/dist/adapters/codex.js +109 -0
- package/dist/adapters/index.d.ts +6 -0
- package/dist/adapters/index.js +20 -0
- package/dist/adapters/types.d.ts +61 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +471 -0
- package/dist/config.d.ts +115 -0
- package/dist/config.js +253 -0
- package/dist/dispatcher.d.ts +37 -0
- package/dist/dispatcher.js +467 -0
- package/dist/doctor.d.ts +46 -0
- package/dist/doctor.js +151 -0
- package/dist/format.d.ts +33 -0
- package/dist/format.js +100 -0
- package/dist/fs-util.d.ts +2 -0
- package/dist/fs-util.js +9 -0
- package/dist/guard.d.ts +20 -0
- package/dist/guard.js +72 -0
- package/dist/mailbox.d.ts +97 -0
- package/dist/mailbox.js +259 -0
- package/dist/message-store.d.ts +91 -0
- package/dist/message-store.js +225 -0
- package/dist/paths.d.ts +30 -0
- package/dist/paths.js +62 -0
- package/dist/policy.d.ts +38 -0
- package/dist/policy.js +143 -0
- package/dist/process-runner.d.ts +21 -0
- package/dist/process-runner.js +165 -0
- package/dist/project-lock.d.ts +30 -0
- package/dist/project-lock.js +186 -0
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +125 -0
- package/dist/run-cleanup.d.ts +51 -0
- package/dist/run-cleanup.js +120 -0
- package/dist/run-store.d.ts +111 -0
- package/dist/run-store.js +119 -0
- package/dist/scaffold.d.ts +11 -0
- package/dist/scaffold.js +126 -0
- package/dist/schema.d.ts +303 -0
- package/dist/schema.js +84 -0
- package/dist/status.d.ts +142 -0
- package/dist/status.js +399 -0
- package/dist/task.d.ts +24 -0
- package/dist/task.js +71 -0
- package/dist/validate.d.ts +13 -0
- package/dist/validate.js +116 -0
- package/dist/worktree.d.ts +103 -0
- package/dist/worktree.js +395 -0
- package/package.json +58 -0
package/dist/format.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { MessageType } from "./mailbox.js";
|
|
2
|
+
import { type RunOutcome } from "./schema.js";
|
|
3
|
+
/** Required `##` sections per message type. `done` and `failure` are exempt. */
|
|
4
|
+
export declare const REQUIRED_SECTIONS: Partial<Record<MessageType, string[]>>;
|
|
5
|
+
/** Required sections that have no Markdown heading in `body` (case-insensitive, any heading level). */
|
|
6
|
+
export declare function missingSections(type: MessageType, body: string): string[];
|
|
7
|
+
export declare function formatWarning(type: MessageType, missing: string[]): string;
|
|
8
|
+
export interface Step {
|
|
9
|
+
text: string;
|
|
10
|
+
done: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** Checklist items (`- [ ] x` / `- [x] x`). With `section`, only those under a `## Steps` heading; otherwise the whole text. */
|
|
13
|
+
export declare function parseSteps(body: string, section?: boolean): Step[];
|
|
14
|
+
/** One line saying what a message is about: the first line under Changes/Goal, else its first prose line. */
|
|
15
|
+
export declare function briefOf(body: string): string | undefined;
|
|
16
|
+
/** Text under the `##` heading `name` (case-insensitive) up to the next heading, or undefined if there is no such heading. */
|
|
17
|
+
export declare function sectionText(body: string, name: string): string | undefined;
|
|
18
|
+
/** Outcomes a lead may declare in `done`; `cancelled` is only ever the dispatcher's. */
|
|
19
|
+
export declare const DECLARABLE_OUTCOMES: readonly ["completed", "partial", "blocked", "failed"];
|
|
20
|
+
export interface DoneContract {
|
|
21
|
+
/** The declared outcome, when it is a valid one. */
|
|
22
|
+
outcome?: RunOutcome;
|
|
23
|
+
/** What the report lacks; empty when the contract is met. */
|
|
24
|
+
missing: string[];
|
|
25
|
+
/** First lines of `## Verification`, for display. */
|
|
26
|
+
verification?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The completion contract of a `done` report: frontmatter `outcome`, and `## Result` and `## Not done`;
|
|
30
|
+
* a `completed` report must also carry `## Files` and `## Verification` and leave no checklist step unticked
|
|
31
|
+
* (the report's own `## Steps`, else the run's latest `stateSteps`).
|
|
32
|
+
*/
|
|
33
|
+
export declare function doneContract(meta: Record<string, unknown>, body: string, stateSteps?: Step[]): DoneContract;
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** Required `##` sections per message type. `done` and `failure` are exempt. */
|
|
2
|
+
export const REQUIRED_SECTIONS = {
|
|
3
|
+
task: ["Goal", "Acceptance criteria", "Scope", "Upstream"],
|
|
4
|
+
reply: ["Changes", "Verification", "Open items", "Risks"],
|
|
5
|
+
};
|
|
6
|
+
/** Required sections that have no Markdown heading in `body` (case-insensitive, any heading level). */
|
|
7
|
+
export function missingSections(type, body) {
|
|
8
|
+
const required = REQUIRED_SECTIONS[type];
|
|
9
|
+
if (!required)
|
|
10
|
+
return [];
|
|
11
|
+
const headings = new Set([...body.matchAll(/^#{1,6}[ \t]+(.+?)[ \t]*:?[ \t]*#*[ \t]*$/gm)].map((m) => m[1].trim().toLowerCase()));
|
|
12
|
+
return required.filter((s) => !headings.has(s.toLowerCase()));
|
|
13
|
+
}
|
|
14
|
+
export function formatWarning(type, missing) {
|
|
15
|
+
return `[agent-lyceum] Format warning: this ${type} is missing required section(s): ${missing.map((s) => `\`## ${s}\``).join(", ")}.`;
|
|
16
|
+
}
|
|
17
|
+
/** Checklist items (`- [ ] x` / `- [x] x`). With `section`, only those under a `## Steps` heading; otherwise the whole text. */
|
|
18
|
+
export function parseSteps(body, section = true) {
|
|
19
|
+
let text = body;
|
|
20
|
+
if (section) {
|
|
21
|
+
const m = /^#{1,6}[ \t]+steps[ \t]*:?[ \t]*#*[ \t]*$/im.exec(body);
|
|
22
|
+
if (!m)
|
|
23
|
+
return [];
|
|
24
|
+
const rest = body.slice(m.index + m[0].length);
|
|
25
|
+
const next = /^#{1,6}[ \t]+\S/m.exec(rest);
|
|
26
|
+
text = next ? rest.slice(0, next.index) : rest;
|
|
27
|
+
}
|
|
28
|
+
return [...text.matchAll(/^[ \t]*[-*][ \t]+\[([ xX])\][ \t]+(.+?)[ \t]*$/gm)].map((m) => ({
|
|
29
|
+
text: m[2],
|
|
30
|
+
done: m[1] !== " ",
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
/** One line saying what a message is about: the first line under Changes/Goal, else its first prose line. */
|
|
34
|
+
export function briefOf(body) {
|
|
35
|
+
const lines = body.split(/\r?\n/);
|
|
36
|
+
const clean = (l) => l
|
|
37
|
+
.trim()
|
|
38
|
+
.replace(/^[-*]\s+(\[[ xX]\]\s+)?/, "")
|
|
39
|
+
.trim();
|
|
40
|
+
const usable = (l) => l !== "" && !l.startsWith("#") && !l.startsWith("[agent-lyceum]") && !/^none\.?$/i.test(l);
|
|
41
|
+
for (const want of ["changes", "goal"]) {
|
|
42
|
+
const i = lines.findIndex((l) => new RegExp(`^#{1,6}[ \\t]+${want}[ \\t]*:?[ \\t]*#*[ \\t]*$`, "i").test(l));
|
|
43
|
+
if (i < 0)
|
|
44
|
+
continue;
|
|
45
|
+
for (const l of lines.slice(i + 1)) {
|
|
46
|
+
if (l.trim().startsWith("#"))
|
|
47
|
+
break;
|
|
48
|
+
const c = clean(l);
|
|
49
|
+
if (usable(c))
|
|
50
|
+
return c;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return lines.map(clean).find(usable);
|
|
54
|
+
}
|
|
55
|
+
const HEADING = /^#{1,6}[ \t]+(.+?)[ \t]*:?[ \t]*#*[ \t]*$/gm;
|
|
56
|
+
/** Text under the `##` heading `name` (case-insensitive) up to the next heading, or undefined if there is no such heading. */
|
|
57
|
+
export function sectionText(body, name) {
|
|
58
|
+
const heads = [...body.matchAll(HEADING)];
|
|
59
|
+
const i = heads.findIndex((m) => m[1].trim().toLowerCase() === name.toLowerCase());
|
|
60
|
+
if (i < 0)
|
|
61
|
+
return undefined;
|
|
62
|
+
const start = heads[i].index + heads[i][0].length;
|
|
63
|
+
const end = heads[i + 1]?.index ?? body.length;
|
|
64
|
+
return body.slice(start, end).trim();
|
|
65
|
+
}
|
|
66
|
+
/** Outcomes a lead may declare in `done`; `cancelled` is only ever the dispatcher's. */
|
|
67
|
+
export const DECLARABLE_OUTCOMES = ["completed", "partial", "blocked", "failed"];
|
|
68
|
+
/**
|
|
69
|
+
* The completion contract of a `done` report: frontmatter `outcome`, and `## Result` and `## Not done`;
|
|
70
|
+
* a `completed` report must also carry `## Files` and `## Verification` and leave no checklist step unticked
|
|
71
|
+
* (the report's own `## Steps`, else the run's latest `stateSteps`).
|
|
72
|
+
*/
|
|
73
|
+
export function doneContract(meta, body, stateSteps) {
|
|
74
|
+
const missing = [];
|
|
75
|
+
const declared = String(meta.outcome ?? "").trim().toLowerCase();
|
|
76
|
+
const outcome = DECLARABLE_OUTCOMES.includes(declared) ? declared : undefined;
|
|
77
|
+
if (!outcome)
|
|
78
|
+
missing.push(`\`outcome: ${DECLARABLE_OUTCOMES.join("|")}\` in the frontmatter${declared ? ` (got "${declared}")` : ""}`);
|
|
79
|
+
const need = outcome === "completed" || !outcome ? ["Result", "Files", "Verification", "Not done"] : ["Result", "Not done"];
|
|
80
|
+
const have = new Set([...body.matchAll(HEADING)].map((m) => m[1].trim().toLowerCase()));
|
|
81
|
+
for (const h of need)
|
|
82
|
+
if (!have.has(h.toLowerCase()))
|
|
83
|
+
missing.push(`\`## ${h}\``);
|
|
84
|
+
if (outcome === "completed") {
|
|
85
|
+
const own = parseSteps(body);
|
|
86
|
+
const open = (own.length ? own : (stateSteps ?? [])).filter((x) => !x.done);
|
|
87
|
+
if (open.length)
|
|
88
|
+
missing.push(`unfinished steps: ${open.slice(0, 3).map((x) => `"${x.text}"`).join(", ")}${open.length > 3 ? ", ..." : ""} (finish and tick them, or report \`partial\`)`);
|
|
89
|
+
}
|
|
90
|
+
const v = sectionText(body, "Verification");
|
|
91
|
+
const verification = v
|
|
92
|
+
? v
|
|
93
|
+
.split(/\r?\n/)
|
|
94
|
+
.map((l) => l.trim().replace(/^[-*]\s+/, ""))
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.join(" ")
|
|
97
|
+
.slice(0, 400) || undefined
|
|
98
|
+
: undefined;
|
|
99
|
+
return { outcome, missing, verification };
|
|
100
|
+
}
|
package/dist/fs-util.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/** Atomic write: temp file in the same dir, then rename. */
|
|
4
|
+
export function atomicWrite(file, content) {
|
|
5
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
6
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.tmp`);
|
|
7
|
+
fs.writeFileSync(tmp, content);
|
|
8
|
+
fs.renameSync(tmp, file);
|
|
9
|
+
}
|
package/dist/guard.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ResolvedAgent, ResolvedProject } from "./config.js";
|
|
2
|
+
export interface Violation {
|
|
3
|
+
file: string;
|
|
4
|
+
action: "restored" | "removed";
|
|
5
|
+
suspects: string[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Detects (and reverts) changes to protected files made by agents that may not edit them.
|
|
9
|
+
* This is the runtime-independent safety net behind the OS/tool-level enforcement.
|
|
10
|
+
*/
|
|
11
|
+
export declare class ProtectedGuard {
|
|
12
|
+
private project;
|
|
13
|
+
private baseline;
|
|
14
|
+
constructor(project: ResolvedProject);
|
|
15
|
+
static protectedFiles(project: ResolvedProject): string[];
|
|
16
|
+
/** Copy the current protected files into `dir` for the audit trail. */
|
|
17
|
+
saveSnapshot(dir: string): void;
|
|
18
|
+
/** Compare against the baseline after `running` agents finished; revert unauthorized changes. */
|
|
19
|
+
check(running: ResolvedAgent[]): Violation[];
|
|
20
|
+
}
|
package/dist/guard.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { commonFile, mayEditProtected, repoInstructionFiles } from "./policy.js";
|
|
4
|
+
/**
|
|
5
|
+
* Detects (and reverts) changes to protected files made by agents that may not edit them.
|
|
6
|
+
* This is the runtime-independent safety net behind the OS/tool-level enforcement.
|
|
7
|
+
*/
|
|
8
|
+
export class ProtectedGuard {
|
|
9
|
+
project;
|
|
10
|
+
baseline = new Map();
|
|
11
|
+
constructor(project) {
|
|
12
|
+
this.project = project;
|
|
13
|
+
for (const f of ProtectedGuard.protectedFiles(project))
|
|
14
|
+
this.baseline.set(f, read(f));
|
|
15
|
+
}
|
|
16
|
+
static protectedFiles(project) {
|
|
17
|
+
const files = new Set();
|
|
18
|
+
for (const a of Object.values(project.agents))
|
|
19
|
+
files.add(a.agentMd);
|
|
20
|
+
files.add(commonFile(project));
|
|
21
|
+
for (const f of repoInstructionFiles(project))
|
|
22
|
+
files.add(f);
|
|
23
|
+
return [...files];
|
|
24
|
+
}
|
|
25
|
+
/** Copy the current protected files into `dir` for the audit trail. */
|
|
26
|
+
saveSnapshot(dir) {
|
|
27
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
28
|
+
for (const [file, buf] of this.baseline) {
|
|
29
|
+
if (!buf)
|
|
30
|
+
continue;
|
|
31
|
+
const safe = file.replace(/[\\/:]/g, "_").replace(/^_+/, "");
|
|
32
|
+
fs.writeFileSync(path.join(dir, safe), buf);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Compare against the baseline after `running` agents finished; revert unauthorized changes. */
|
|
36
|
+
check(running) {
|
|
37
|
+
const violations = [];
|
|
38
|
+
for (const [file, before] of this.baseline) {
|
|
39
|
+
const now = read(file);
|
|
40
|
+
if (same(before, now))
|
|
41
|
+
continue;
|
|
42
|
+
const authorized = running.some((a) => mayEditProtected(this.project, a, file));
|
|
43
|
+
if (authorized) {
|
|
44
|
+
this.baseline.set(file, now);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (before === null) {
|
|
48
|
+
fs.rmSync(file, { force: true });
|
|
49
|
+
violations.push({ file, action: "removed", suspects: running.map((a) => a.name) });
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
53
|
+
fs.writeFileSync(file, before);
|
|
54
|
+
violations.push({ file, action: "restored", suspects: running.map((a) => a.name) });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return violations;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function read(file) {
|
|
61
|
+
try {
|
|
62
|
+
return fs.readFileSync(file);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function same(a, b) {
|
|
69
|
+
if (a === null || b === null)
|
|
70
|
+
return a === b;
|
|
71
|
+
return a.equals(b);
|
|
72
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ResolvedProject } from "./config.js";
|
|
2
|
+
import { type Step } from "./format.js";
|
|
3
|
+
import { atomicWrite } from "./fs-util.js";
|
|
4
|
+
import { RouteJournal } from "./message-store.js";
|
|
5
|
+
export { atomicWrite };
|
|
6
|
+
export declare const MESSAGE_TYPES: readonly ["task", "reply", "failure", "done"];
|
|
7
|
+
export type MessageType = (typeof MESSAGE_TYPES)[number];
|
|
8
|
+
export interface MessageMeta {
|
|
9
|
+
id: string;
|
|
10
|
+
from: string;
|
|
11
|
+
to: string;
|
|
12
|
+
thread: string;
|
|
13
|
+
reply_to?: string;
|
|
14
|
+
type: MessageType;
|
|
15
|
+
subject: string;
|
|
16
|
+
created: string;
|
|
17
|
+
/** The run this mail belongs to. Stamped by the dispatcher; absent on mail of legacy runs. */
|
|
18
|
+
run_id?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface Message {
|
|
21
|
+
meta: MessageMeta;
|
|
22
|
+
body: string;
|
|
23
|
+
file: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function newId(): string;
|
|
26
|
+
export declare function serialize(meta: MessageMeta, body: string): string;
|
|
27
|
+
export declare function parseRaw(text: string): {
|
|
28
|
+
data: Record<string, unknown>;
|
|
29
|
+
body: string;
|
|
30
|
+
};
|
|
31
|
+
export declare function ensureProjectDirs(project: ResolvedProject): void;
|
|
32
|
+
export interface DeliverInput {
|
|
33
|
+
from: string;
|
|
34
|
+
to: string;
|
|
35
|
+
type: MessageType;
|
|
36
|
+
subject: string;
|
|
37
|
+
body: string;
|
|
38
|
+
thread?: string;
|
|
39
|
+
reply_to?: string;
|
|
40
|
+
}
|
|
41
|
+
/** Deliver a message into `to`'s inbox (dispatcher-side; senders other than agents allowed). */
|
|
42
|
+
export declare function deliver(project: ResolvedProject, input: DeliverInput & {
|
|
43
|
+
id?: string;
|
|
44
|
+
created?: string;
|
|
45
|
+
fileName?: string;
|
|
46
|
+
}): Message;
|
|
47
|
+
/**
|
|
48
|
+
* Deliver the mail identified by `(sourceKey, input.to)` at most once, even across crashes and restarts:
|
|
49
|
+
* the plan (id, file name, timestamp) is journaled before anything is written, a retry writes the identical file,
|
|
50
|
+
* and mail the recipient already read is never written again. Without a run (no journal) it just delivers.
|
|
51
|
+
*/
|
|
52
|
+
export declare function deliverOnce(project: ResolvedProject, journal: RouteJournal | undefined, sourceKey: string, input: DeliverInput): {
|
|
53
|
+
msg: Message;
|
|
54
|
+
fresh: boolean;
|
|
55
|
+
};
|
|
56
|
+
export declare function readMessage(file: string): Message;
|
|
57
|
+
export declare function listUnread(project: ResolvedProject, agent: string): Message[];
|
|
58
|
+
export declare function markRead(files: string[]): void;
|
|
59
|
+
export interface RouteResult {
|
|
60
|
+
/** Mail delivered by this pass (mail a previous pass already delivered is not repeated). */
|
|
61
|
+
delivered: {
|
|
62
|
+
from: string;
|
|
63
|
+
to: string;
|
|
64
|
+
id: string;
|
|
65
|
+
file: string;
|
|
66
|
+
type: MessageType;
|
|
67
|
+
subject: string;
|
|
68
|
+
}[];
|
|
69
|
+
rejected: {
|
|
70
|
+
from: string;
|
|
71
|
+
file: string;
|
|
72
|
+
reason: string;
|
|
73
|
+
}[];
|
|
74
|
+
/** Mail that was delivered but lacks required sections (see format.ts). */
|
|
75
|
+
warnings: {
|
|
76
|
+
from: string;
|
|
77
|
+
id: string;
|
|
78
|
+
subject: string;
|
|
79
|
+
missing: string[];
|
|
80
|
+
}[];
|
|
81
|
+
/** The lead's `done`. Its file stays in the outbox until the caller has stored the result and calls `finishDone`. */
|
|
82
|
+
done?: {
|
|
83
|
+
from: string;
|
|
84
|
+
subject: string;
|
|
85
|
+
body: string;
|
|
86
|
+
file: string;
|
|
87
|
+
meta: Record<string, unknown>;
|
|
88
|
+
};
|
|
89
|
+
/** Latest `## Steps` checklist the lead sent, if any. */
|
|
90
|
+
steps?: Step[];
|
|
91
|
+
}
|
|
92
|
+
/** Move a handled `done` file out of the outbox (to `read/`). */
|
|
93
|
+
export declare function finishDone(file: string): void;
|
|
94
|
+
/** Set a `done` that broke the completion contract aside (in the outbox's `rejected/`). */
|
|
95
|
+
export declare function rejectDone(file: string): void;
|
|
96
|
+
/** Validate each agent's outbox and move accepted mail into recipients' inboxes. Safe to run again after a crash. */
|
|
97
|
+
export declare function routeOutboxes(project: ResolvedProject): RouteResult;
|
package/dist/mailbox.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import { formatWarning, missingSections, parseSteps } from "./format.js";
|
|
6
|
+
import { atomicWrite } from "./fs-util.js";
|
|
7
|
+
import { deterministicId, fault, RouteJournal, sourceIdOf } from "./message-store.js";
|
|
8
|
+
import { inboxDir, outboxDir } from "./policy.js";
|
|
9
|
+
export { atomicWrite };
|
|
10
|
+
export const MESSAGE_TYPES = ["task", "reply", "failure", "done"];
|
|
11
|
+
export function newId() {
|
|
12
|
+
return crypto.randomBytes(3).toString("hex");
|
|
13
|
+
}
|
|
14
|
+
let lastStampMs = 0;
|
|
15
|
+
/** UTC timestamp for filenames; strictly increasing so inbox order always equals delivery order. */
|
|
16
|
+
function stamp() {
|
|
17
|
+
lastStampMs = Math.max(Date.now(), lastStampMs + 1);
|
|
18
|
+
return new Date(lastStampMs).toISOString().replace(/[-:.]/g, "");
|
|
19
|
+
}
|
|
20
|
+
export function serialize(meta, body) {
|
|
21
|
+
const fm = YAML.stringify(meta).trimEnd();
|
|
22
|
+
return `---\n${fm}\n---\n\n${body.trim()}\n`;
|
|
23
|
+
}
|
|
24
|
+
export function parseRaw(text) {
|
|
25
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
|
|
26
|
+
if (!m)
|
|
27
|
+
throw new Error("missing frontmatter (--- ... ---)");
|
|
28
|
+
const data = YAML.parse(m[1]);
|
|
29
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
30
|
+
throw new Error("frontmatter is not a mapping");
|
|
31
|
+
return { data: data, body: m[2].trim() };
|
|
32
|
+
}
|
|
33
|
+
export function ensureProjectDirs(project) {
|
|
34
|
+
fs.mkdirSync(path.join(project.paths.shared, "common"), { recursive: true });
|
|
35
|
+
for (const name of Object.keys(project.agents)) {
|
|
36
|
+
fs.mkdirSync(path.join(inboxDir(project, name), "read"), { recursive: true });
|
|
37
|
+
fs.mkdirSync(path.join(outboxDir(project, name), "rejected"), { recursive: true });
|
|
38
|
+
const a = project.agents[name];
|
|
39
|
+
for (const m of [a.memory.global, a.memory.project, a.memory.task])
|
|
40
|
+
if (m)
|
|
41
|
+
fs.mkdirSync(m, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Deliver a message into `to`'s inbox (dispatcher-side; senders other than agents allowed). */
|
|
45
|
+
export function deliver(project, input) {
|
|
46
|
+
const id = input.id ?? newId();
|
|
47
|
+
const meta = {
|
|
48
|
+
id,
|
|
49
|
+
from: input.from,
|
|
50
|
+
to: input.to,
|
|
51
|
+
thread: input.thread ?? input.reply_to ?? id,
|
|
52
|
+
...(input.reply_to ? { reply_to: input.reply_to } : {}),
|
|
53
|
+
type: input.type,
|
|
54
|
+
subject: input.subject,
|
|
55
|
+
created: input.created ?? new Date().toISOString(),
|
|
56
|
+
...(project.run?.layout === "run" ? { run_id: project.run.id } : {}),
|
|
57
|
+
};
|
|
58
|
+
const file = path.join(inboxDir(project, input.to), input.fileName ?? `${stamp()}-${input.from}-${id}.md`);
|
|
59
|
+
atomicWrite(file, serialize(meta, input.body));
|
|
60
|
+
return { meta, body: input.body, file };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Deliver the mail identified by `(sourceKey, input.to)` at most once, even across crashes and restarts:
|
|
64
|
+
* the plan (id, file name, timestamp) is journaled before anything is written, a retry writes the identical file,
|
|
65
|
+
* and mail the recipient already read is never written again. Without a run (no journal) it just delivers.
|
|
66
|
+
*/
|
|
67
|
+
export function deliverOnce(project, journal, sourceKey, input) {
|
|
68
|
+
if (!journal)
|
|
69
|
+
return { msg: deliver(project, input), fresh: true };
|
|
70
|
+
const prior = journal.get(sourceKey, input.to);
|
|
71
|
+
const planned = prior ??
|
|
72
|
+
journal.intent({
|
|
73
|
+
source: sourceKey,
|
|
74
|
+
to: input.to,
|
|
75
|
+
msg_id: deterministicId(sourceKey, input.to),
|
|
76
|
+
file: `${stamp()}-${input.from}-${deterministicId(sourceKey, input.to)}.md`,
|
|
77
|
+
created: new Date().toISOString(),
|
|
78
|
+
});
|
|
79
|
+
fault("route:intent");
|
|
80
|
+
const dest = path.join(inboxDir(project, input.to), planned.file);
|
|
81
|
+
const meta = deliveredMeta(project, input, planned);
|
|
82
|
+
const want = serialize(meta, input.body);
|
|
83
|
+
const alreadyRead = fs.existsSync(path.join(path.dirname(dest), "read", planned.file));
|
|
84
|
+
// Write the planned file; if one is already there, verify it instead of trusting it. Mail already read is never written again.
|
|
85
|
+
if (!alreadyRead && (!fs.existsSync(dest) || fs.readFileSync(dest, "utf8") !== want))
|
|
86
|
+
atomicWrite(dest, want);
|
|
87
|
+
fault("route:written");
|
|
88
|
+
const fresh = !planned.delivered;
|
|
89
|
+
if (fresh)
|
|
90
|
+
journal.delivered(sourceKey, input.to);
|
|
91
|
+
fault("route:delivered");
|
|
92
|
+
return { msg: { meta, body: input.body, file: dest }, fresh };
|
|
93
|
+
}
|
|
94
|
+
function deliveredMeta(project, input, planned) {
|
|
95
|
+
return {
|
|
96
|
+
id: planned.msg_id,
|
|
97
|
+
from: input.from,
|
|
98
|
+
to: input.to,
|
|
99
|
+
thread: input.thread ?? input.reply_to ?? planned.msg_id,
|
|
100
|
+
...(input.reply_to ? { reply_to: input.reply_to } : {}),
|
|
101
|
+
type: input.type,
|
|
102
|
+
subject: input.subject,
|
|
103
|
+
created: planned.created,
|
|
104
|
+
...(project.run?.layout === "run" ? { run_id: project.run.id } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export function readMessage(file) {
|
|
108
|
+
const { data, body } = parseRaw(fs.readFileSync(file, "utf8"));
|
|
109
|
+
const meta = data;
|
|
110
|
+
return { meta, body, file };
|
|
111
|
+
}
|
|
112
|
+
export function listUnread(project, agent) {
|
|
113
|
+
const dir = inboxDir(project, agent);
|
|
114
|
+
if (!fs.existsSync(dir))
|
|
115
|
+
return [];
|
|
116
|
+
const files = fs
|
|
117
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
118
|
+
.filter((e) => e.isFile() && e.name.endsWith(".md") && !e.name.startsWith("."))
|
|
119
|
+
.map((e) => path.join(dir, e.name))
|
|
120
|
+
.sort();
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const f of files) {
|
|
123
|
+
try {
|
|
124
|
+
out.push(readMessage(f));
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
out.push({
|
|
128
|
+
meta: { id: path.basename(f), from: "?", to: agent, thread: "?", type: "reply", subject: "(unreadable message)", created: "" },
|
|
129
|
+
body: fs.readFileSync(f, "utf8"),
|
|
130
|
+
file: f,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
export function markRead(files) {
|
|
137
|
+
for (const f of files) {
|
|
138
|
+
if (!fs.existsSync(f))
|
|
139
|
+
continue;
|
|
140
|
+
const dest = path.join(path.dirname(f), "read", path.basename(f));
|
|
141
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
142
|
+
fs.renameSync(f, dest);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Move a handled `done` file out of the outbox (to `read/`). */
|
|
146
|
+
export function finishDone(file) {
|
|
147
|
+
if (!fs.existsSync(file))
|
|
148
|
+
return;
|
|
149
|
+
const dest = path.join(path.dirname(file), "read", path.basename(file));
|
|
150
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
151
|
+
fs.renameSync(file, dest);
|
|
152
|
+
}
|
|
153
|
+
/** Set a `done` that broke the completion contract aside (in the outbox's `rejected/`). */
|
|
154
|
+
export function rejectDone(file) {
|
|
155
|
+
if (!fs.existsSync(file))
|
|
156
|
+
return;
|
|
157
|
+
const dest = path.join(path.dirname(file), "rejected", path.basename(file));
|
|
158
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
159
|
+
fs.renameSync(file, dest);
|
|
160
|
+
}
|
|
161
|
+
/** Validate each agent's outbox and move accepted mail into recipients' inboxes. Safe to run again after a crash. */
|
|
162
|
+
export function routeOutboxes(project) {
|
|
163
|
+
const res = { delivered: [], rejected: [], warnings: [] };
|
|
164
|
+
const journal = project.run ? new RouteJournal(project.run.dir) : undefined;
|
|
165
|
+
for (const sender of Object.values(project.agents)) {
|
|
166
|
+
const dir = outboxDir(project, sender.name);
|
|
167
|
+
if (!fs.existsSync(dir))
|
|
168
|
+
continue;
|
|
169
|
+
const files = fs
|
|
170
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
171
|
+
.filter((e) => e.isFile() && e.name.endsWith(".md") && !e.name.startsWith("."))
|
|
172
|
+
.map((e) => path.join(dir, e.name))
|
|
173
|
+
.sort((a, b) => fs.statSync(a).mtimeMs - fs.statSync(b).mtimeMs || a.localeCompare(b));
|
|
174
|
+
for (const file of files) {
|
|
175
|
+
const source = sourceIdOf(sender.name, file);
|
|
176
|
+
const reject = (reason) => {
|
|
177
|
+
deliverOnce(project, journal, `reject:${source}`, {
|
|
178
|
+
from: "dispatcher",
|
|
179
|
+
to: sender.name,
|
|
180
|
+
type: "failure",
|
|
181
|
+
subject: `Message ${path.basename(file)} was rejected`,
|
|
182
|
+
body: `Your outbox message \`${path.basename(file)}\` was not delivered: ${reason}\n\nIt was moved to \`rejected/\`.`,
|
|
183
|
+
});
|
|
184
|
+
const dest = path.join(dir, "rejected", path.basename(file));
|
|
185
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
186
|
+
fs.renameSync(file, dest);
|
|
187
|
+
fault("route:moved");
|
|
188
|
+
res.rejected.push({ from: sender.name, file: dest, reason });
|
|
189
|
+
};
|
|
190
|
+
let parsed;
|
|
191
|
+
try {
|
|
192
|
+
parsed = parseRaw(fs.readFileSync(file, "utf8"));
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
reject(`invalid message format (${e.message}). Expected frontmatter with "to", "type", "subject".`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const d = parsed.data;
|
|
199
|
+
if (project.run?.layout === "run" && d.run_id !== undefined && String(d.run_id) !== project.run.id) {
|
|
200
|
+
reject(`it is addressed to run "${String(d.run_id)}", but this is run "${project.run.id}"; mail never crosses runs.`);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const type = (d.type ?? "reply");
|
|
204
|
+
if (!MESSAGE_TYPES.includes(type) || type === "failure") {
|
|
205
|
+
reject(`unknown or not allowed message type "${String(d.type)}" (use task, reply or done).`);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const subject = String(d.subject ?? "(no subject)");
|
|
209
|
+
if (type === "done") {
|
|
210
|
+
if (sender.name !== project.lead) {
|
|
211
|
+
reject(`only the lead ("${project.lead}") may send a "done" message.`);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
res.done = { from: sender.name, subject, body: parsed.body, file, meta: d };
|
|
215
|
+
const ds = parseSteps(parsed.body);
|
|
216
|
+
if (ds.length)
|
|
217
|
+
res.steps = ds;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const targets = Array.isArray(d.to) ? d.to.map(String) : d.to ? [String(d.to)] : [];
|
|
221
|
+
if (targets.length === 0) {
|
|
222
|
+
reject(`missing "to".`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const bad = targets.find((t) => !project.agents[t] || t === sender.name || (sender.canMessage !== "all" && !sender.canMessage.includes(t)));
|
|
226
|
+
if (bad !== undefined) {
|
|
227
|
+
const why = !project.agents[bad]
|
|
228
|
+
? `no such agent "${bad}"`
|
|
229
|
+
: bad === sender.name
|
|
230
|
+
? "cannot message yourself"
|
|
231
|
+
: `you may only message: ${sender.canMessage.join(", ") || "(nobody)"}`;
|
|
232
|
+
reject(why + ".");
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const replyTo = d.reply_to ? String(d.reply_to) : undefined;
|
|
236
|
+
const thread = d.thread ? String(d.thread) : undefined;
|
|
237
|
+
const missing = missingSections(type, parsed.body);
|
|
238
|
+
const body = missing.length ? `${formatWarning(type, missing)}\n\n${parsed.body}` : parsed.body;
|
|
239
|
+
if (sender.name === project.lead) {
|
|
240
|
+
const st = parseSteps(parsed.body);
|
|
241
|
+
if (st.length)
|
|
242
|
+
res.steps = st;
|
|
243
|
+
}
|
|
244
|
+
for (const to of targets) {
|
|
245
|
+
const { msg, fresh } = deliverOnce(project, journal, source, { from: sender.name, to, type, subject, body, thread, reply_to: replyTo });
|
|
246
|
+
if (!fresh)
|
|
247
|
+
continue;
|
|
248
|
+
res.delivered.push({ from: sender.name, to, id: msg.meta.id, file: msg.file, type, subject });
|
|
249
|
+
if (missing.length)
|
|
250
|
+
res.warnings.push({ from: sender.name, id: msg.meta.id, subject, missing });
|
|
251
|
+
}
|
|
252
|
+
fault("route:source-done");
|
|
253
|
+
fs.mkdirSync(path.join(dir, "sent"), { recursive: true });
|
|
254
|
+
fs.renameSync(file, path.join(dir, "sent", path.basename(file)));
|
|
255
|
+
fault("route:moved");
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return res;
|
|
259
|
+
}
|