agents-pod 0.0.0-stage → 0.1.0
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 +55 -0
- package/LICENSE +661 -0
- package/README.md +343 -2
- package/bin/agents-pod.mjs +15 -0
- package/bin/ts-loader.mjs +66 -0
- package/bin/wrapper.mjs +12 -0
- package/index.mjs +11 -0
- package/package.json +39 -4
- package/src/agents-store.ts +88 -0
- package/src/cli.ts +1512 -0
- package/src/extension/handoff.ts +57 -0
- package/src/extension/index.ts +34 -0
- package/src/extension/management.ts +149 -0
- package/src/extension/self.ts +80 -0
- package/src/inbox.ts +155 -0
- package/src/listeners.ts +78 -0
- package/src/liveness.ts +191 -0
- package/src/locks.ts +74 -0
- package/src/notify/config.ts +125 -0
- package/src/notify/dispatch.ts +63 -0
- package/src/notify/email.ts +72 -0
- package/src/notify/format.ts +42 -0
- package/src/notify/system.ts +79 -0
- package/src/notify/types.ts +96 -0
- package/src/notify/webhook.ts +39 -0
- package/src/paths.ts +57 -0
- package/src/projects.ts +49 -0
- package/src/registry.ts +103 -0
- package/src/workspace.ts +124 -0
- package/src/wrapper.ts +484 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pod notifications: the second adapter family, copied from agents-cron
|
|
3
|
+
* (ADR 0021). Notifiers own "how a pod moment is announced"; reconcile owns
|
|
4
|
+
* "when". The inventory is two events only — park and done.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Identifies one configured channel — the `id` of a `channels[]` entry. */
|
|
8
|
+
export type ChannelId = string;
|
|
9
|
+
|
|
10
|
+
/** Posts the moment to an HTTP endpoint (Slack, Lark, Discord, n8n, …). */
|
|
11
|
+
export type WebhookChannel = {
|
|
12
|
+
id: string;
|
|
13
|
+
type: "webhook";
|
|
14
|
+
url: string;
|
|
15
|
+
/** Extra request headers, e.g. `{"Authorization": "Bearer …"}`. */
|
|
16
|
+
headers?: Record<string, string>;
|
|
17
|
+
/** json (default) posts the raw notification; text posts plain text; feishu posts the Feishu bot body. */
|
|
18
|
+
format?: "json" | "text" | "feishu";
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Sends the moment as an email over SMTP. */
|
|
22
|
+
export type EmailChannel = {
|
|
23
|
+
id: string;
|
|
24
|
+
type: "email";
|
|
25
|
+
host: string;
|
|
26
|
+
/** Defaults to 465. */
|
|
27
|
+
port?: number;
|
|
28
|
+
/** Defaults to true when the port is 465, false otherwise. */
|
|
29
|
+
secure?: boolean;
|
|
30
|
+
/** SMTP user; omit for an unauthenticated relay. */
|
|
31
|
+
user?: string;
|
|
32
|
+
/** Env var holding the SMTP password; defaults to AGENTS_POD_SMTP_PASSWORD. */
|
|
33
|
+
passwordEnv?: string;
|
|
34
|
+
from: string;
|
|
35
|
+
/** Comma-separated recipient list. */
|
|
36
|
+
to: string;
|
|
37
|
+
subjectPrefix?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Shows the moment as a desktop notification (macOS Notification Center, Linux notify-send). */
|
|
41
|
+
export type SystemChannel = {
|
|
42
|
+
id: string;
|
|
43
|
+
type: "system";
|
|
44
|
+
/** macOS sound name (Glass, Ping, …); ignored on Linux. */
|
|
45
|
+
sound?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type NotificationChannel = WebhookChannel | EmailChannel | SystemChannel;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Which pod moments produce a notification (ADR 0021): `error` means park
|
|
52
|
+
* only, `always` means park + done, `never` means nothing. Default `always` —
|
|
53
|
+
* for work measured in days, completion is the product.
|
|
54
|
+
*/
|
|
55
|
+
export type NotifyLevel = "always" | "error" | "never";
|
|
56
|
+
|
|
57
|
+
/** The full inventory: two events, no more. */
|
|
58
|
+
export type NotificationEvent = "park" | "done";
|
|
59
|
+
|
|
60
|
+
export type NotificationsConfig = {
|
|
61
|
+
on: NotifyLevel;
|
|
62
|
+
channels: NotificationChannel[];
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** One failure in the consecutive run that provoked a park (ADR 0009 detail). */
|
|
66
|
+
export type FailureLine = {
|
|
67
|
+
n: number;
|
|
68
|
+
outcome: string;
|
|
69
|
+
detail?: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A pod moment shaped for delivery. Park carries the verdict (pod id, goal
|
|
74
|
+
* first line, the consecutive failures with their details); done carries the
|
|
75
|
+
* handoff's status line (ADR 0021).
|
|
76
|
+
*/
|
|
77
|
+
export type PodNotification = {
|
|
78
|
+
event: NotificationEvent;
|
|
79
|
+
podId: string;
|
|
80
|
+
/** The goal's first line — the one-line handle (ADR 0011). */
|
|
81
|
+
goal: string;
|
|
82
|
+
/** park: consecutive failures at the verdict. */
|
|
83
|
+
consecutiveFailures?: number;
|
|
84
|
+
/** park: each failure with its ADR 0009 detail. */
|
|
85
|
+
failures?: FailureLine[];
|
|
86
|
+
/** park: a non-streak cause, e.g. agent resolution failure (ADR 0012). */
|
|
87
|
+
reason?: string;
|
|
88
|
+
/** done: the handoff's status line. */
|
|
89
|
+
handoffStatus?: string;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** One delivery channel, resolved and ready to send. */
|
|
93
|
+
export type Notifier = {
|
|
94
|
+
readonly id: string;
|
|
95
|
+
send(notification: PodNotification): Promise<void>;
|
|
96
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { bodyOf, subjectOf } from "./format.ts";
|
|
2
|
+
import type { Notifier, PodNotification, WebhookChannel } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/** Delivery budget per channel; a hung endpoint must not stall a pass (ADR 0021). */
|
|
5
|
+
const TIMEOUT_MS = 10_000;
|
|
6
|
+
|
|
7
|
+
export function webhookNotifier(channel: WebhookChannel): Notifier {
|
|
8
|
+
return {
|
|
9
|
+
id: channel.id,
|
|
10
|
+
async send(n: PodNotification): Promise<void> {
|
|
11
|
+
const body =
|
|
12
|
+
channel.format === "feishu"
|
|
13
|
+
? JSON.stringify({ msg_type: "text", content: { text: `${subjectOf(n)}\n\n${bodyOf(n)}` } })
|
|
14
|
+
: channel.format === "text"
|
|
15
|
+
? `${subjectOf(n)}\n\n${bodyOf(n)}`
|
|
16
|
+
: JSON.stringify({ source: "agents-pod", ...n }, null, 2);
|
|
17
|
+
const res = await fetch(channel.url, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: {
|
|
20
|
+
"content-type": channel.format === "text" ? "text/plain; charset=utf-8" : "application/json",
|
|
21
|
+
...(channel.headers ?? {}),
|
|
22
|
+
},
|
|
23
|
+
body,
|
|
24
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
25
|
+
});
|
|
26
|
+
if (!res.ok) throw new Error(`webhook answered HTTP ${res.status}`);
|
|
27
|
+
if (channel.format === "feishu") {
|
|
28
|
+
// Feishu answers HTTP 200 even for business errors (bad keyword,
|
|
29
|
+
// wrong format); a non-zero code means the bot never delivered.
|
|
30
|
+
const answer = (await res.json().catch(() => undefined)) as { code?: number; msg?: string } | undefined;
|
|
31
|
+
// Only an explicit non-zero code is a rejection; endpoints that answer
|
|
32
|
+
// nothing (or Feishu-compatible servers without a body) stay OK.
|
|
33
|
+
if (typeof answer?.code === "number" && answer.code !== 0) {
|
|
34
|
+
throw new Error(`feishu webhook rejected the message: ${answer.code} ${answer.msg ?? ""}`.trim());
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The machine-global store root: `$AGENTS_POD_HOME ?? $HOME` + `.agents/pods/`.
|
|
6
|
+
* The env override exists for the same reason agents-cron's does: tests and
|
|
7
|
+
* secondary installs need a throwaway root without touching the real store
|
|
8
|
+
* (ADR 0004: own store, never shared with agents-cron).
|
|
9
|
+
*/
|
|
10
|
+
export function storeRoot(): string {
|
|
11
|
+
const home = process.env.AGENTS_POD_HOME ?? homedir();
|
|
12
|
+
return join(home, ".agents", "pods");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function podsFile(): string {
|
|
16
|
+
return join(storeRoot(), "pods.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Per-project wake-up capacity (ADR 0022), machine-global like the Pod registry. */
|
|
20
|
+
export function projectsFile(): string {
|
|
21
|
+
return join(storeRoot(), "projects.json");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The one definition source in P0 (ADR 0012): machine-global, no project layer. */
|
|
25
|
+
export function agentsFile(): string {
|
|
26
|
+
return join(storeRoot(), "agents.json");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Listeners and their claim records: intake is data, never a process (ADR 0007). */
|
|
30
|
+
export function listenersFile(): string {
|
|
31
|
+
return join(storeRoot(), "listeners.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The pass's single-flight advisory lock, one for the whole registry (ADR 0018). */
|
|
35
|
+
export function reconcileLockPath(): string {
|
|
36
|
+
return join(storeRoot(), "reconcile.lock");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Where a stolen lock is audited: one line per steal, read by cron (ADR 0018). */
|
|
40
|
+
export function cronLogPath(): string {
|
|
41
|
+
return join(storeRoot(), "cron.log");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The one notification config, in the store root (ADR 0021): no per-pod routing. */
|
|
45
|
+
export function notifyConfigFile(): string {
|
|
46
|
+
return join(storeRoot(), "config.json");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The short-lived registry mutex that makes state transitions CAS (ADR 0017, 0018). */
|
|
50
|
+
export function registryLockPath(): string {
|
|
51
|
+
return join(storeRoot(), "registry.lock");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The pod's working tree lives under the project (ADR 0004): docs, log, wake-ups. */
|
|
55
|
+
export function podDir(project: string, podId: string): string {
|
|
56
|
+
return join(project, ".agents", "pods", podId);
|
|
57
|
+
}
|
package/src/projects.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { projectsFile } from "./paths.ts";
|
|
5
|
+
import type { PodRecord } from "./registry.ts";
|
|
6
|
+
|
|
7
|
+
/** One project's wake-up capacity; absent records mean unlimited (ADR 0022). */
|
|
8
|
+
export interface ProjectRecord {
|
|
9
|
+
project: string;
|
|
10
|
+
maxActive: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function readProjects(): Promise<ProjectRecord[]> {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(await readFile(projectsFile(), "utf8")) as ProjectRecord[];
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function writeProjects(projects: ProjectRecord[]): Promise<void> {
|
|
23
|
+
const file = projectsFile();
|
|
24
|
+
await mkdir(dirname(file), { recursive: true });
|
|
25
|
+
const tmp = `${file}.${randomBytes(4).toString("hex")}.tmp`;
|
|
26
|
+
await writeFile(tmp, `${JSON.stringify(projects, null, 2)}\n`);
|
|
27
|
+
await rename(tmp, file);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function projectMaxActive(project: string, projects: ProjectRecord[]): number | undefined {
|
|
31
|
+
const root = resolve(project);
|
|
32
|
+
return projects.find((candidate) => resolve(candidate.project) === root)?.maxActive;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Spawning and active are the two states with a live Wake-up being admitted. */
|
|
36
|
+
export function projectHasCapacity(
|
|
37
|
+
project: string,
|
|
38
|
+
pods: PodRecord[],
|
|
39
|
+
projects: ProjectRecord[],
|
|
40
|
+
): boolean {
|
|
41
|
+
const maxActive = projectMaxActive(project, projects);
|
|
42
|
+
if (maxActive === undefined) return true;
|
|
43
|
+
const root = resolve(project);
|
|
44
|
+
const active = pods.filter(
|
|
45
|
+
(pod) =>
|
|
46
|
+
resolve(pod.project) === root && (pod.state === "spawning" || pod.state === "active"),
|
|
47
|
+
).length;
|
|
48
|
+
return active < maxActive;
|
|
49
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { acquireWithRetry, releaseLock } from "./locks.ts";
|
|
5
|
+
import { podsFile, registryLockPath } from "./paths.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pod states (ADR 0006): spawning / active / idle / done / cancelled. `lost`
|
|
9
|
+
* and `cancelled` are wake-up outcomes, never pod states.
|
|
10
|
+
*/
|
|
11
|
+
export type PodState = "spawning" | "active" | "idle" | "done" | "cancelled";
|
|
12
|
+
|
|
13
|
+
export type WakeUpOutcome = "more" | "done" | "failed" | "lost" | "cancelled";
|
|
14
|
+
|
|
15
|
+
export interface WakeUpEntry {
|
|
16
|
+
n: number;
|
|
17
|
+
outcome: WakeUpOutcome;
|
|
18
|
+
detail?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PodRecord {
|
|
22
|
+
id: string;
|
|
23
|
+
goal: string;
|
|
24
|
+
state: PodState;
|
|
25
|
+
/** The project root the pod's workspace lives under. */
|
|
26
|
+
project: string;
|
|
27
|
+
/** The upstream Pod whose done state admits this Pod's first wake-up (ADR 0022). */
|
|
28
|
+
after?: string;
|
|
29
|
+
wakeUps: WakeUpEntry[];
|
|
30
|
+
policy?: WakePolicy;
|
|
31
|
+
/** When a pass first observed a wedged wrapper (ADR 0017's two-pass rule). */
|
|
32
|
+
wedgedAt?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Reconcile's progress-kill intent (ADR 0019): written under the registry
|
|
35
|
+
* lock before the signal, so the verdict is on disk before it is acted on.
|
|
36
|
+
*/
|
|
37
|
+
/** A reconcile kill's intent, written before the signal (ADRs 0017, 0019): the
|
|
38
|
+
* wrapper defers to the verdict and records no second outcome. */
|
|
39
|
+
killIntent?: { n: number; at: number; cause: "progressless" | "wedged" };
|
|
40
|
+
/**
|
|
41
|
+
* When this episode's notification was sent (ADR 0021). A human wake that
|
|
42
|
+
* clears a park also clears it, opening the next episode.
|
|
43
|
+
*/
|
|
44
|
+
notifiedAt?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The policy block reconcile derives from outcome facts (ADR 0011). */
|
|
48
|
+
export interface WakePolicy {
|
|
49
|
+
failureStreak: number;
|
|
50
|
+
nextWakeAt?: number;
|
|
51
|
+
parked?: boolean;
|
|
52
|
+
/** How many wake-up outcomes the policy has already taken into account. */
|
|
53
|
+
appliedOutcomes: number;
|
|
54
|
+
/** The agent every wake-up resolves to unless one wake names another (ADR 0012). */
|
|
55
|
+
agent?: string;
|
|
56
|
+
/** The pod's model pin: it beats the definition's model (ADR 0012). */
|
|
57
|
+
model?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function readPods(): Promise<PodRecord[]> {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(await readFile(podsFile(), "utf8")) as PodRecord[];
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Atomic write: a crash mid-write never leaves a half-written registry. The
|
|
71
|
+
* temp name is unique per writer — the CLI and a detached wrapper both write
|
|
72
|
+
* this file, and a shared temp name let one rename steal the other's file.
|
|
73
|
+
*/
|
|
74
|
+
export async function writePods(pods: PodRecord[]): Promise<void> {
|
|
75
|
+
const file = podsFile();
|
|
76
|
+
await mkdir(dirname(file), { recursive: true });
|
|
77
|
+
const tmp = `${file}.${randomBytes(4).toString("hex")}.tmp`;
|
|
78
|
+
await writeFile(tmp, `${JSON.stringify(pods, null, 2)}\n`);
|
|
79
|
+
await rename(tmp, file);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The registry mutex (ADR 0017's lock budget, ADR 0018's division of labour):
|
|
84
|
+
* a state transition re-reads pods.json inside it, verifies its condition
|
|
85
|
+
* still holds, then writes — the pass lock only keeps one pass around, this is
|
|
86
|
+
* what makes two racers (two passes, or a human and a pass) unable to both
|
|
87
|
+
* win. `undefined` means the lock could not be had: callers skip, never write
|
|
88
|
+
* blind.
|
|
89
|
+
*/
|
|
90
|
+
export async function withRegistryLock<T>(
|
|
91
|
+
work: () => Promise<T>,
|
|
92
|
+
): Promise<T | undefined> {
|
|
93
|
+
const held = await acquireWithRetry(registryLockPath(), {
|
|
94
|
+
pid: process.pid,
|
|
95
|
+
startedAt: Date.now(),
|
|
96
|
+
});
|
|
97
|
+
if (!held) return undefined;
|
|
98
|
+
try {
|
|
99
|
+
return await work();
|
|
100
|
+
} finally {
|
|
101
|
+
await releaseLock(registryLockPath());
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/workspace.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { InboxItem } from "./inbox.ts";
|
|
5
|
+
import { podDir } from "./paths.ts";
|
|
6
|
+
import type { PodRecord } from "./registry.ts";
|
|
7
|
+
|
|
8
|
+
/** The document store's three slots: the only progress signal P1 trusts (ADR 0019). */
|
|
9
|
+
export const DOC_FILES = ["HANDOFF.md", "DECISIONS.md", "TASKS.md"] as const;
|
|
10
|
+
|
|
11
|
+
/** A document at a moment: progress is mtime or size moving (ADR 0019). */
|
|
12
|
+
export interface DocFacts {
|
|
13
|
+
mtimeMs: number;
|
|
14
|
+
size: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The progress baseline (ADR 0019): the wake-up's start records what the
|
|
19
|
+
* documents looked like, so a later pass can prove they never moved. An absent
|
|
20
|
+
* file is simply not recorded — its appearing or vanishing is a change.
|
|
21
|
+
*/
|
|
22
|
+
export async function snapshotDocs(docsDir: string): Promise<Record<string, DocFacts>> {
|
|
23
|
+
const facts: Record<string, DocFacts> = {};
|
|
24
|
+
for (const name of DOC_FILES) {
|
|
25
|
+
const info = await stat(join(docsDir, name)).catch(() => undefined);
|
|
26
|
+
if (info !== undefined) facts[name] = { mtimeMs: info.mtimeMs, size: info.size };
|
|
27
|
+
}
|
|
28
|
+
return facts;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The document store's three slots, created from templates at spawn (ADR 0008).
|
|
33
|
+
* The wording is free markdown; the only machine surface P0 depends on is the
|
|
34
|
+
* HANDOFF first line and TASKS checkboxes, both grepped, never parsed.
|
|
35
|
+
*
|
|
36
|
+
* `## Inbox` is last so reconcile's append is an EOF append (ADR 0014).
|
|
37
|
+
*/
|
|
38
|
+
const TEMPLATES: Record<string, string> = {
|
|
39
|
+
"HANDOFF.md": [
|
|
40
|
+
"status: rotating",
|
|
41
|
+
"",
|
|
42
|
+
"No wake-up has written a handoff yet.",
|
|
43
|
+
"",
|
|
44
|
+
].join("\n"),
|
|
45
|
+
"DECISIONS.md": [
|
|
46
|
+
"# Decisions",
|
|
47
|
+
"",
|
|
48
|
+
"One line per decision, with its reason. Append-only.",
|
|
49
|
+
"",
|
|
50
|
+
].join("\n"),
|
|
51
|
+
"TASKS.md": ["# Tasks", "", "## Plan", "", "## Inbox", ""].join("\n"),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** The one section reconcile may write (ADR 0008, 0014). */
|
|
55
|
+
const INBOX_HEADING = "## Inbox";
|
|
56
|
+
|
|
57
|
+
export async function createPodWorkspace(pod: PodRecord): Promise<string> {
|
|
58
|
+
const dir = podDir(pod.project, pod.id);
|
|
59
|
+
const docs = join(dir, "docs");
|
|
60
|
+
await mkdir(docs, { recursive: true });
|
|
61
|
+
for (const [name, body] of Object.entries(TEMPLATES)) {
|
|
62
|
+
await writeFile(join(docs, name), body);
|
|
63
|
+
}
|
|
64
|
+
return dir;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One Inbox entry block (ADR 0014): `- [ ] <id> — <title>`, then the body
|
|
69
|
+
* copied in as an indented list continuation. The id leads so counting and
|
|
70
|
+
* audit have an anchor; the body is copied because the source file belongs to
|
|
71
|
+
* its writer and may be deleted once the item is claimed.
|
|
72
|
+
*/
|
|
73
|
+
export function inboxBlock(item: Pick<InboxItem, "id" | "title" | "body">): string {
|
|
74
|
+
const title = item.title.split("\n", 1)[0]!.trim();
|
|
75
|
+
const lines = [`- [ ] ${item.id} — ${title}`];
|
|
76
|
+
const body = item.body.replace(/\r\n/g, "\n").replace(/\n+$/, "");
|
|
77
|
+
if (body.length > 0) {
|
|
78
|
+
for (const line of body.split("\n")) {
|
|
79
|
+
// A blank line stays blank: an indented empty line is trailing whitespace.
|
|
80
|
+
lines.push(line.trim().length === 0 ? "" : ` ${line}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return `${lines.join("\n")}\n`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function escapeRegExp(text: string): string {
|
|
87
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The id's checkbox line, checked or not: the document is the truth (ADR 0014). */
|
|
91
|
+
function alreadyInboxed(text: string, id: string): boolean {
|
|
92
|
+
return new RegExp(`^- \\[[ xX]\\] ${escapeRegExp(id)} —`, "m").test(text);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Append a batch to a pod's `## Inbox` (ADR 0014) — reconcile's only write into
|
|
97
|
+
* a pod's documents (ADR 0008), and only while no wake-up is live (the caller's
|
|
98
|
+
* gate). Idempotent by document, not by marker: an id that already has a
|
|
99
|
+
* checkbox line is skipped, so a crash between this write and the claim's
|
|
100
|
+
* `appended` marker repairs on the next pass instead of duplicating the block.
|
|
101
|
+
* An older pod without the section gets the heading at EOF on its first append.
|
|
102
|
+
*/
|
|
103
|
+
export async function appendInbox(docsDir: string, items: readonly InboxItem[]): Promise<void> {
|
|
104
|
+
const file = join(docsDir, "TASKS.md");
|
|
105
|
+
const existing = await readFile(file, "utf8").catch(() => "");
|
|
106
|
+
const fresh = items.filter((item) => !alreadyInboxed(existing, item.id));
|
|
107
|
+
if (fresh.length === 0) return; // a repair pass: the document already has it
|
|
108
|
+
|
|
109
|
+
let text = existing.length === 0 ? "# Tasks\n" : existing;
|
|
110
|
+
if (!/^##\s+Inbox\s*$/m.test(text)) {
|
|
111
|
+
if (!text.endsWith("\n")) text += "\n";
|
|
112
|
+
if (!text.endsWith("\n\n")) text += "\n";
|
|
113
|
+
text += `${INBOX_HEADING}\n`;
|
|
114
|
+
}
|
|
115
|
+
if (!text.endsWith("\n")) text += "\n";
|
|
116
|
+
if (!text.endsWith("\n\n")) text += "\n";
|
|
117
|
+
text += fresh.map(inboxBlock).join("\n");
|
|
118
|
+
|
|
119
|
+
// The registry's atomic write, applied to the one shared file: a crash never
|
|
120
|
+
// leaves a half-written TASKS.md for the next wake-up to read.
|
|
121
|
+
const tmp = `${file}.${randomBytes(4).toString("hex")}.tmp`;
|
|
122
|
+
await writeFile(tmp, text);
|
|
123
|
+
await rename(tmp, file);
|
|
124
|
+
}
|