@taicho-ai/contracts 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/README.md +4 -0
- package/package.json +32 -0
- package/src/agent.ts +59 -0
- package/src/annotation.ts +37 -0
- package/src/artifact.ts +46 -0
- package/src/brief.ts +12 -0
- package/src/index.ts +11 -0
- package/src/knowledge.ts +35 -0
- package/src/plan.ts +113 -0
- package/src/policy.ts +34 -0
- package/src/schedule.ts +55 -0
- package/src/skill.ts +15 -0
- package/src/team.ts +87 -0
- package/src/trace.ts +73 -0
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@taicho-ai/contracts",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/index.ts",
|
|
7
|
+
"./*": "./src/*.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "bun build src/index.ts --outdir dist --target bun",
|
|
11
|
+
"test": "bun test src"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"zod": "^4.4.3"
|
|
15
|
+
},
|
|
16
|
+
"description": "Shared runtime schemas and domain types for taicho (agents, briefs, traces, artifacts, teams).",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"homepage": "https://taicho.ai",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/taicho-ai/taicho.git",
|
|
22
|
+
"directory": "packages/contracts"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"!src/**/*.test.ts",
|
|
27
|
+
"!src/**/*.test.tsx"
|
|
28
|
+
],
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/agent.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DEFAULT_TEAM_ID } from "./team";
|
|
3
|
+
|
|
4
|
+
/** Agent identity — canonical form lives in agents/<id>/agent.md frontmatter. */
|
|
5
|
+
const AgentObject = z.object({
|
|
6
|
+
id: z.string().regex(/^[a-z][a-z0-9-]*$/),
|
|
7
|
+
role: z.string(), // one-line capability description (shown in discovery)
|
|
8
|
+
identity: z.string(), // body of agent.md — the SOUL
|
|
9
|
+
// Capability allowlist. Built-in tool names (e.g. "delegate_task", "run_command") grant those
|
|
10
|
+
// tools. MCP capabilities are opt-in the SAME way (Plan 08 security hardening): "mcp:<server>"
|
|
11
|
+
// grants every tool that connected server exposes; "mcp:<server>/<tool>" grants exactly one.
|
|
12
|
+
// Anything NOT listed is not exposed to the agent — MCP tools included (no blanket grant).
|
|
13
|
+
tools: z.array(z.string()).default([]),
|
|
14
|
+
// Plan 22: the teams this agent sits on (was a single `team`). An agent may belong to MANY teams and
|
|
15
|
+
// declares them HERE — its own frontmatter is the single source of truth for membership, so
|
|
16
|
+
// teams/<id>/team.md deliberately carries no member list. This holds only EXPLICIT memberships: the
|
|
17
|
+
// universal `default` team is implicit (every agent belongs; the derived index adds it), so an empty
|
|
18
|
+
// list ⇒ default-only. A pre-Plan-22 `team: news` is migrated to `teams: [news]` at parse (below).
|
|
19
|
+
teams: z.array(z.string()).default([]),
|
|
20
|
+
// Visibility + delegation ACLs. Each entry is "*", an exact agent id, or (Plan 19) "team:<id>",
|
|
21
|
+
// which matches every member of that team. Additive: no existing agent id contains a colon.
|
|
22
|
+
canSee: z.array(z.string()).default(["*"]), // org visibility ACL
|
|
23
|
+
canDelegateTo: z.array(z.string()).default(["*"]),
|
|
24
|
+
budgets: z.object({
|
|
25
|
+
maxIterationsPerRun: z.number().int().positive().default(30),
|
|
26
|
+
maxWorkItemsPerRequest: z.number().int().positive().default(20),
|
|
27
|
+
maxTokensPerRun: z.number().int().positive().optional(),
|
|
28
|
+
maxCostPerRunUsd: z.number().positive().optional(),
|
|
29
|
+
// Plan 04 Phase 3: how many background tasks this agent may have RUNNING at once (config
|
|
30
|
+
// disposes the concurrency the model proposes via dispatch_task). Undefined ⇒ unbounded;
|
|
31
|
+
// extra dispatches sit in the persistent queue (status "queued") until a slot frees.
|
|
32
|
+
maxConcurrentRuns: z.number().int().positive().optional(),
|
|
33
|
+
}).prefault({}),
|
|
34
|
+
isRoot: z.boolean().default(false),
|
|
35
|
+
created: z.string().datetime(),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** Parse an agent, migrating the legacy single `team: <id>` into `teams: [<id>]` so a pre-Plan-22
|
|
39
|
+
* agent.md still loads (files are canon; boot reindex then re-serializes it in the new shape). */
|
|
40
|
+
export const AgentDef = z.preprocess((raw) => {
|
|
41
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
42
|
+
const o = raw as Record<string, unknown>;
|
|
43
|
+
if (o.teams === undefined && "team" in o)
|
|
44
|
+
o.teams = o.team == null || o.team === "" ? [] : [o.team];
|
|
45
|
+
if ("team" in o) delete o.team;
|
|
46
|
+
}
|
|
47
|
+
return raw;
|
|
48
|
+
}, AgentObject);
|
|
49
|
+
export type AgentDef = z.infer<typeof AgentDef>;
|
|
50
|
+
|
|
51
|
+
/** An agent's EFFECTIVE membership: the universal `default` team plus its explicit teams, deduped, with
|
|
52
|
+
* default FIRST. This is what the ACL matches against and what the derived agent_teams index stores, so
|
|
53
|
+
* `team:default` matches everyone and membersOf("default") is the whole squad. Kept out of the file —
|
|
54
|
+
* agent.md lists only explicit teams — so a squad with no explicit teams looks byte-identical to before. */
|
|
55
|
+
export function effectiveTeams(agent: { teams: string[] }): string[] {
|
|
56
|
+
const out = [DEFAULT_TEAM_ID];
|
|
57
|
+
for (const t of agent.teams) if (t !== DEFAULT_TEAM_ID && !out.includes(t)) out.push(t);
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { VerificationVerdict } from "./trace";
|
|
3
|
+
|
|
4
|
+
/** Feedback attached to a SPECIFIC artifact version. Because the store is immutable-per-version, an
|
|
5
|
+
* annotation always pins to a concrete handle ("id@vN") — the feedback is anchored to the exact bytes
|
|
6
|
+
* it was written about. The author may be the human captain, a producing/reviewing agent, or the
|
|
7
|
+
* delegation checker (Plan 06): a verification verdict is an annotation like any other — it rides in
|
|
8
|
+
* the optional `verdict` field, so machine feedback and human feedback converge on ONE shape and ONE
|
|
9
|
+
* revision path.
|
|
10
|
+
*
|
|
11
|
+
* An OPEN annotation is the input to a REVISION run: run.ts surfaces open annotations under each input
|
|
12
|
+
* artifact handed to a child, the reviser addresses them and saves a NEW version linked back via
|
|
13
|
+
* `parents`, and (optionally) resolves the annotation against that revision. Mirrors schemas/policy.ts
|
|
14
|
+
* (a small typed record with a stable id + status lifecycle). */
|
|
15
|
+
export const AnnotationKind = z.enum(["feedback", "verification", "approval"]);
|
|
16
|
+
export type AnnotationKind = z.infer<typeof AnnotationKind>;
|
|
17
|
+
|
|
18
|
+
export const AnnotationStatus = z.enum(["open", "addressed", "dismissed"]);
|
|
19
|
+
export type AnnotationStatus = z.infer<typeof AnnotationStatus>;
|
|
20
|
+
|
|
21
|
+
export const Annotation = z.object({
|
|
22
|
+
id: z.string(), // ann_xxxx
|
|
23
|
+
target: z.string(), // the artifact handle this annotates — always "id@vN" (a concrete version)
|
|
24
|
+
author: z.string(), // "human" | agentId | "checker" — provenance of the feedback
|
|
25
|
+
kind: AnnotationKind.default("feedback"),
|
|
26
|
+
body: z.string(), // the feedback text (for a verdict: the reasons, human-readable)
|
|
27
|
+
verdict: VerificationVerdict.optional(), // Plan 06 hook: a verification verdict IS an annotation
|
|
28
|
+
status: AnnotationStatus.default("open"),
|
|
29
|
+
resolvedBy: z.string().optional(), // the revision handle "id@vN" that addressed this
|
|
30
|
+
created: z.string().datetime(),
|
|
31
|
+
});
|
|
32
|
+
export type Annotation = z.infer<typeof Annotation>;
|
|
33
|
+
|
|
34
|
+
/** A short, collision-resistant annotation id. Mirrors coaching's `pol_`/`ex_` id shape. */
|
|
35
|
+
export function mkAnnotationId(): string {
|
|
36
|
+
return `ann_${crypto.randomUUID().slice(0, 8)}`;
|
|
37
|
+
}
|
package/src/artifact.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/** Where an artifact's bytes live. PAYLOAD-AGNOSTIC — the store never interprets the body:
|
|
4
|
+
* - `file` — local bytes on disk under artifacts/<id>/ (path is absolute).
|
|
5
|
+
* - `external` — a locator into a system an MCP server fronts (a Notion page, a ClickUp task, a
|
|
6
|
+
* URL). Forcing a copy into artifacts/ would be wrong there; the envelope (provenance, versioning,
|
|
7
|
+
* summary, handle-based hand-off) works identically either way. See reference §5b. */
|
|
8
|
+
export const ArtifactLocation = z.discriminatedUnion("kind", [
|
|
9
|
+
z.object({ kind: z.literal("file"), path: z.string() }),
|
|
10
|
+
z.object({ kind: z.literal("external"), uri: z.string() }),
|
|
11
|
+
]);
|
|
12
|
+
export type ArtifactLocation = z.infer<typeof ArtifactLocation>;
|
|
13
|
+
|
|
14
|
+
/** A structured hand-off artifact — the ENVELOPE only; the body is opaque bytes (or an external
|
|
15
|
+
* ref). Addressable by a stable logical `id` (a slug shared across versions), versioned and
|
|
16
|
+
* IMMUTABLE-PER-VERSION (a revision is a new version, never an overwrite). Provenance (producer
|
|
17
|
+
* agent + run) and lineage (`parents`) give a hand-off graph. Mirrors schemas/knowledge.ts. */
|
|
18
|
+
export const Artifact = z.object({
|
|
19
|
+
id: z.string(), // stable logical id (slug), shared across versions
|
|
20
|
+
version: z.number().int().positive().default(1), // increments per revision; (id,version) is immutable
|
|
21
|
+
title: z.string(),
|
|
22
|
+
type: z.string().default("document"), // FREE-FORM tag, never an enforced taxonomy
|
|
23
|
+
role: z.enum(["output", "input", "resource"]).default("output"), // §3c: one store, role-tagged
|
|
24
|
+
producer: z.string(), // agentId that produced it (provenance)
|
|
25
|
+
runId: z.string(), // run that produced it (provenance)
|
|
26
|
+
parents: z.array(z.string()).default([]), // parent artifact handles (lineage)
|
|
27
|
+
summary: z.string().optional(), // summary-first read; never the whole body
|
|
28
|
+
location: ArtifactLocation, // local file OR external ref (payload-agnostic)
|
|
29
|
+
created: z.string().datetime(),
|
|
30
|
+
});
|
|
31
|
+
export type Artifact = z.infer<typeof Artifact>;
|
|
32
|
+
|
|
33
|
+
/** Parse a handle: "id" (⇒ latest version) or "id@vN" (⇒ that version). Malformed suffixes fall
|
|
34
|
+
* back to treating the whole thing as an id (no version), so a stray "@" never throws. */
|
|
35
|
+
export function parseHandle(handle: string): { id: string; version?: number } {
|
|
36
|
+
const at = handle.lastIndexOf("@v");
|
|
37
|
+
if (at <= 0) return { id: handle };
|
|
38
|
+
const v = Number(handle.slice(at + 2));
|
|
39
|
+
if (!Number.isInteger(v) || v <= 0) return { id: handle };
|
|
40
|
+
return { id: handle.slice(0, at), version: v };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The canonical addressable handle for a specific version: "id@vN". */
|
|
44
|
+
export function artifactHandle(a: { id: string; version: number }): string {
|
|
45
|
+
return `${a.id}@v${a.version}`;
|
|
46
|
+
}
|
package/src/brief.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/** Delegation brief — sender writes goal+context; receiver supplies its own identity & policies. */
|
|
4
|
+
export const Brief = z.object({
|
|
5
|
+
to: z.string(),
|
|
6
|
+
goal: z.string(),
|
|
7
|
+
context: z.string().optional(),
|
|
8
|
+
criteria: z.string().optional(), // acceptance criteria the output must meet (rides into the prompt + drives the checker)
|
|
9
|
+
from: z.string(), // sending agent id
|
|
10
|
+
fromRun: z.string(),
|
|
11
|
+
});
|
|
12
|
+
export type Brief = z.infer<typeof Brief>;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from "./agent";
|
|
2
|
+
export * from "./annotation";
|
|
3
|
+
export * from "./artifact";
|
|
4
|
+
export * from "./brief";
|
|
5
|
+
export * from "./knowledge";
|
|
6
|
+
export * from "./plan";
|
|
7
|
+
export * from "./policy";
|
|
8
|
+
export * from "./schedule";
|
|
9
|
+
export * from "./skill";
|
|
10
|
+
export * from "./team";
|
|
11
|
+
export * from "./trace";
|
package/src/knowledge.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/** A typed, directed edge from one knowledge node to another (stored in the source node's frontmatter). */
|
|
4
|
+
export const KbEdge = z.object({
|
|
5
|
+
to: z.string(), // target node id
|
|
6
|
+
rel: z.string().default("relates_to"), // relates_to|depends_on|part_of|contradicts|derived_from|<free>
|
|
7
|
+
weight: z.number().default(1),
|
|
8
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
9
|
+
});
|
|
10
|
+
export type KbEdge = z.infer<typeof KbEdge>;
|
|
11
|
+
|
|
12
|
+
/** Plan 19: the squad-wide scope was spelled "deck" through Plan 18. Accept the legacy value when
|
|
13
|
+
* parsing an existing kb/nodes/*.md and normalize it to "squad", so a workspace written by an older
|
|
14
|
+
* taicho still loads. `reconcileKbScope` (store/knowledge.ts, called at boot) rewrites those files
|
|
15
|
+
* once; this preprocess is what keeps them readable until it does. Ph8 adds "team" to the enum. */
|
|
16
|
+
export const KbScope = z
|
|
17
|
+
.preprocess((v) => (v === "deck" ? "squad" : v), z.enum(["squad"]))
|
|
18
|
+
.default("squad");
|
|
19
|
+
|
|
20
|
+
/** A squad-shared knowledge node — one file per node at kb/nodes/<kb_id>.md
|
|
21
|
+
* (YAML frontmatter = the node minus `content`, body = the content). Mirrors schemas/policy.ts. */
|
|
22
|
+
export const KbNode = z.object({
|
|
23
|
+
id: z.string(), // kb_xxxx
|
|
24
|
+
kind: z.string().default("fact"), // fact|entity|decision|doc|... (open vocab)
|
|
25
|
+
title: z.string(),
|
|
26
|
+
summary: z.string().optional(),
|
|
27
|
+
content: z.string(), // body of the .md
|
|
28
|
+
source: z.string().optional(), // provenance: "agentId:runId" / url
|
|
29
|
+
scope: KbScope,
|
|
30
|
+
edges: z.array(KbEdge).default([]), // outgoing typed edges
|
|
31
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
32
|
+
created: z.string().datetime(),
|
|
33
|
+
updated: z.string().datetime().optional(),
|
|
34
|
+
});
|
|
35
|
+
export type KbNode = z.infer<typeof KbNode>;
|
package/src/plan.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/** Plan 18. A plan separates STRUCTURE from STATE, because they answer different questions and change
|
|
4
|
+
* at wildly different rates:
|
|
5
|
+
*
|
|
6
|
+
* · a plan VERSION (v1, v2, …) is an immutable snapshot of the item SET — the intent. Minted only
|
|
7
|
+
* when the shape changes: an item added, removed, reworded, reordered. That is a replan, and it
|
|
8
|
+
* deserves a version. (This is store/artifacts.ts.)
|
|
9
|
+
* · an item TRANSITION is an append-only line in plans/<id>/events.jsonl. Ticking a box appends; it
|
|
10
|
+
* never mints a version. (This is store/annotations.ts.)
|
|
11
|
+
*
|
|
12
|
+
* Current state is fold(events) over the latest version — the same ledger-is-truth, cache-is-derived
|
|
13
|
+
* discipline that already governs ledger.jsonl → thread.jsonl. Had every tick minted a version, a
|
|
14
|
+
* twelve-item plan with three transitions each would produce thirty-six versions of an object whose
|
|
15
|
+
* shape never changed, and "when did this agent change its mind?" would be buried under bookkeeping. */
|
|
16
|
+
|
|
17
|
+
export const PlanItemStatus = z.enum([
|
|
18
|
+
"pending",
|
|
19
|
+
"in_progress",
|
|
20
|
+
"done",
|
|
21
|
+
"failed",
|
|
22
|
+
"blocked",
|
|
23
|
+
"interrupted", // the process died while this item's bound run was in flight (boot reconcile)
|
|
24
|
+
"dropped", // the agent abandoned it on purpose; requires a note
|
|
25
|
+
]);
|
|
26
|
+
export type PlanItemStatus = z.infer<typeof PlanItemStatus>;
|
|
27
|
+
|
|
28
|
+
/** Terminal for the purposes of "is this item still open". `dropped` counts as settled. */
|
|
29
|
+
export const TERMINAL_ITEM_STATUS: ReadonlySet<PlanItemStatus> = new Set<PlanItemStatus>([
|
|
30
|
+
"done", "failed", "blocked", "interrupted", "dropped",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/** An item id is stable ACROSS versions. That is what lets an event logged against v1 still resolve
|
|
34
|
+
* after a replan mints v2 — the fold matches on item id, not on position. */
|
|
35
|
+
export const PlanItem = z.object({
|
|
36
|
+
id: z.string().regex(/^[a-z0-9][a-z0-9_-]*$/, "lowercase, digits, underscore, hyphen"),
|
|
37
|
+
text: z.string(),
|
|
38
|
+
assignee: z.string().optional(), // the agent or team this item is meant for
|
|
39
|
+
});
|
|
40
|
+
export type PlanItem = z.infer<typeof PlanItem>;
|
|
41
|
+
|
|
42
|
+
export const Plan = z.object({
|
|
43
|
+
id: z.string(), // p_<slug>
|
|
44
|
+
version: z.number().int().positive(),
|
|
45
|
+
owner: z.string(), // the agent whose plan this is
|
|
46
|
+
goal: z.string(),
|
|
47
|
+
items: z.array(PlanItem),
|
|
48
|
+
parents: z.array(z.string()).default([]), // lineage: ["p_ship@v1"] — a replan's provenance
|
|
49
|
+
producer: z.string(),
|
|
50
|
+
runId: z.string(),
|
|
51
|
+
created: z.string().datetime(),
|
|
52
|
+
});
|
|
53
|
+
export type Plan = z.infer<typeof Plan>;
|
|
54
|
+
|
|
55
|
+
/** One line per transition. The LATEST line for an item wins the fold. */
|
|
56
|
+
export const PlanEvent = z.object({
|
|
57
|
+
item: z.string(),
|
|
58
|
+
status: PlanItemStatus,
|
|
59
|
+
/** Who wrote it. An `engine` event is the truth about what a run actually did; a `model` event is a
|
|
60
|
+
* claim. Both are recorded — a model trying to mark a failed delegation `done` is exactly the
|
|
61
|
+
* behaviour you want visible in a trace, not silently swallowed. */
|
|
62
|
+
by: z.enum(["model", "engine"]),
|
|
63
|
+
runId: z.string(), // the run that wrote the event
|
|
64
|
+
/** The child run (or background taskId) this item is bound to. Once set, ONLY the engine may set the
|
|
65
|
+
* item's terminal status: the checkbox reflects what happened, not what the model claims. */
|
|
66
|
+
boundRunId: z.string().optional(),
|
|
67
|
+
note: z.string().optional(), // e.g. a failed verdict's reasons
|
|
68
|
+
/** A recorded-but-refused attempt: the model tried to set a terminal status on an engine-owned item.
|
|
69
|
+
* The fold SKIPS these — otherwise the attempt would be the last line for that item and would win,
|
|
70
|
+
* which is precisely the lie the engine-owns rule exists to prevent. It stays in the log because a
|
|
71
|
+
* model marking a failed delegation `done` is a fact worth having. */
|
|
72
|
+
rejected: z.boolean().optional(),
|
|
73
|
+
ts: z.string().datetime(),
|
|
74
|
+
});
|
|
75
|
+
export type PlanEvent = z.infer<typeof PlanEvent>;
|
|
76
|
+
|
|
77
|
+
/** An item with its folded state — what the panel renders and what the model is shown. */
|
|
78
|
+
export interface FoldedItem extends PlanItem {
|
|
79
|
+
status: PlanItemStatus;
|
|
80
|
+
boundRunId?: string;
|
|
81
|
+
note?: string;
|
|
82
|
+
/** When this item last changed, for elapsed-in-state. */
|
|
83
|
+
updated?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface PlanState {
|
|
87
|
+
plan: Plan;
|
|
88
|
+
handle: string;
|
|
89
|
+
items: FoldedItem[];
|
|
90
|
+
counts: { total: number; done: number; open: number; failed: number };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const planHandle = (p: { id: string; version: number }): string => `${p.id}@v${p.version}`;
|
|
94
|
+
|
|
95
|
+
/** `p_ship` (latest) or `p_ship@v2` (a concrete version). Mirrors schemas/artifact.ts parseHandle. */
|
|
96
|
+
export function parsePlanHandle(handle: string): { id: string; version?: number } {
|
|
97
|
+
const at = handle.lastIndexOf("@v");
|
|
98
|
+
if (at < 0) return { id: handle };
|
|
99
|
+
const v = Number(handle.slice(at + 2));
|
|
100
|
+
return Number.isInteger(v) && v > 0 ? { id: handle.slice(0, at), version: v } : { id: handle };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A stable id from a goal. Deterministic, so re-stating the same goal continues the same plan rather
|
|
104
|
+
* than forking a second one the agent then has to reconcile against. */
|
|
105
|
+
export function planIdForGoal(goal: string): string {
|
|
106
|
+
const slug = goal
|
|
107
|
+
.toLowerCase()
|
|
108
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
109
|
+
.replace(/^-+|-+$/g, "")
|
|
110
|
+
.slice(0, 40)
|
|
111
|
+
.replace(/-+$/g, "");
|
|
112
|
+
return `p_${slug || "plan"}`;
|
|
113
|
+
}
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/** A coaching note — one file per note under agents/<id>/policies/. */
|
|
4
|
+
export const PolicyNote = z.object({
|
|
5
|
+
id: z.string(), // pol_xxxx
|
|
6
|
+
agent: z.string(),
|
|
7
|
+
when: z.string(), // condition, enforced at retrieval
|
|
8
|
+
do: z.string(), // instruction
|
|
9
|
+
scope: z.enum(["agent", "global"]).default("agent"),
|
|
10
|
+
status: z.enum(["proposed", "approved", "rejected", "superseded"]),
|
|
11
|
+
supersedes: z.string().optional(), // id of the note this replaces
|
|
12
|
+
taughtBy: z.string(),
|
|
13
|
+
fromRun: z.string().optional(), // run id the coaching came from
|
|
14
|
+
created: z.string().datetime(),
|
|
15
|
+
expanded: z.array(z.string()).default([]), // write-time paraphrases for recall
|
|
16
|
+
});
|
|
17
|
+
export type PolicyNote = z.infer<typeof PolicyNote>;
|
|
18
|
+
|
|
19
|
+
/** Approved output bound to a request pattern. */
|
|
20
|
+
export const Exemplar = z.object({
|
|
21
|
+
id: z.string(), // ex_xxxx
|
|
22
|
+
agent: z.string(),
|
|
23
|
+
pattern: z.string(), // request pattern this answers
|
|
24
|
+
artifact: z.string(), // artifact HANDLE ("id" ⇒ latest, or "id@vN") — resolves via the
|
|
25
|
+
// artifact store (store/artifacts.ts), NOT a raw filesystem path.
|
|
26
|
+
// Plan 01 Ph4c: keying on the stable, versioned, relocatable id
|
|
27
|
+
// (never an absolute path) is what lets an exemplar survive a
|
|
28
|
+
// revision/workspace move — same addressing as delegate hand-off.
|
|
29
|
+
mode: z.enum(["serve", "imitate"]).default("imitate"),
|
|
30
|
+
status: z.enum(["proposed", "approved", "rejected", "superseded"]),
|
|
31
|
+
taughtBy: z.string(),
|
|
32
|
+
created: z.string().datetime(),
|
|
33
|
+
});
|
|
34
|
+
export type Exemplar = z.infer<typeof Exemplar>;
|
package/src/schedule.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Plan 04 Phase 6 — a Schedule fires an UNATTENDED run on a schedule (cron-style) or a trigger
|
|
2
|
+
* (a file watch), driving the goal through the headless `executeRun` path (Plan 03's seam). A
|
|
3
|
+
* schedule persists to `schedules/<id>.json` (files are canon) so it survives restarts and is
|
|
4
|
+
* reconciled/armed on boot.
|
|
5
|
+
*
|
|
6
|
+
* APPROVALS: a scheduled run is unattended — there is no captain watching an approval card — so the
|
|
7
|
+
* approval channel is restricted to `reject` (the safe default: decline every privileged action) or
|
|
8
|
+
* `approve` (scripted/trusted only). `prompt` is meaningless with no stdin and is not allowed here. */
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
/** Unattended approval modes only. `reject` = the safe default (decline privileged tools); `approve`
|
|
12
|
+
* = trusted/scripted (approve everything). No `prompt` — nobody is there to answer. */
|
|
13
|
+
export const ScheduleApprove = z.enum(["reject", "approve"]);
|
|
14
|
+
export type ScheduleApprove = z.infer<typeof ScheduleApprove>;
|
|
15
|
+
|
|
16
|
+
/** What makes a schedule due:
|
|
17
|
+
* - `cron` — a 5-field cron expression, evaluated in **UTC** (deterministic; no DST ambiguity).
|
|
18
|
+
* - `interval` — fire every `everyMs` milliseconds.
|
|
19
|
+
* - `watch` — fire when the file/dir at `path` changes (its mtime moves). Polled on each tick. */
|
|
20
|
+
export const Trigger = z.discriminatedUnion("kind", [
|
|
21
|
+
z.object({ kind: z.literal("cron"), expr: z.string().min(1) }),
|
|
22
|
+
z.object({ kind: z.literal("interval"), everyMs: z.number().int().positive() }),
|
|
23
|
+
z.object({ kind: z.literal("watch"), path: z.string().min(1) }),
|
|
24
|
+
]);
|
|
25
|
+
export type Trigger = z.infer<typeof Trigger>;
|
|
26
|
+
|
|
27
|
+
export const Schedule = z.object({
|
|
28
|
+
id: z.string().min(1),
|
|
29
|
+
goal: z.string().min(1),
|
|
30
|
+
agent: z.string().min(1).default("root"),
|
|
31
|
+
trigger: Trigger,
|
|
32
|
+
approve: ScheduleApprove.default("reject"),
|
|
33
|
+
enabled: z.boolean().default(true),
|
|
34
|
+
created: z.string(),
|
|
35
|
+
updated: z.string(),
|
|
36
|
+
// Mutable scheduling state, advanced by the runner on each fire and persisted so cadence survives
|
|
37
|
+
// a restart. `nextDueAt` is the next computed fire time (cron/interval); `lastMtimeMs` the last-seen
|
|
38
|
+
// mtime (watch). `lastRunId`/`lastStatus` record the outcome of the most recent fire.
|
|
39
|
+
runCount: z.number().int().nonnegative().default(0),
|
|
40
|
+
lastRunAt: z.string().optional(),
|
|
41
|
+
lastRunId: z.string().optional(),
|
|
42
|
+
lastStatus: z.string().optional(),
|
|
43
|
+
nextDueAt: z.string().optional(),
|
|
44
|
+
lastMtimeMs: z.number().optional(),
|
|
45
|
+
});
|
|
46
|
+
export type Schedule = z.infer<typeof Schedule>;
|
|
47
|
+
|
|
48
|
+
/** The fields a caller supplies to create a schedule (the rest are defaulted / assigned). */
|
|
49
|
+
export interface ScheduleSpec {
|
|
50
|
+
id?: string;
|
|
51
|
+
goal: string;
|
|
52
|
+
agent?: string;
|
|
53
|
+
trigger: Trigger;
|
|
54
|
+
approve?: ScheduleApprove;
|
|
55
|
+
}
|
package/src/skill.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/** A reusable procedure document — one file per skill at skills/<id>.md
|
|
4
|
+
* (YAML frontmatter = the skill minus `body`, body = the procedure). Mirrors schemas/policy.ts. */
|
|
5
|
+
export const Skill = z.object({
|
|
6
|
+
id: z.string(), // skill_xxxx
|
|
7
|
+
name: z.string(), // short, unique; used by use_skill + display
|
|
8
|
+
description: z.string(), // WHEN to use it — drives discovery + injection
|
|
9
|
+
tags: z.array(z.string()).default([]),
|
|
10
|
+
status: z.enum(["active", "draft"]).default("active"), // only `active` are injected/usable
|
|
11
|
+
body: z.string(), // the procedure (markdown)
|
|
12
|
+
created: z.string().datetime(),
|
|
13
|
+
updated: z.string().datetime().optional(),
|
|
14
|
+
});
|
|
15
|
+
export type Skill = z.infer<typeof Skill>;
|
package/src/team.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/** Plan 22: the universal team. Every agent belongs to `default` — it IS the squad, expressed as a
|
|
4
|
+
* team. It is seeded on boot, cannot be deleted, and is IMPLICIT: an agent never has to list it in its
|
|
5
|
+
* own `teams:` frontmatter (the derived index adds it). Root leads it. Explicit teams (news, trading…)
|
|
6
|
+
* are additive overlays on top; joining one never removes an agent from default. */
|
|
7
|
+
export const DEFAULT_TEAM_ID = "default";
|
|
8
|
+
|
|
9
|
+
/** A team's tool policy, applied in toolsForAgent to every member.
|
|
10
|
+
* `grant` ADDS to a member's own toolset; `deny` REMOVES from it and wins over the member's own grant.
|
|
11
|
+
* A `deny` list that intersects DEFAULT_WORKER_TOOLS is rejected when the team loads (store/teams.ts):
|
|
12
|
+
* Plan 14's capability FLOOR — a worker can always produce and hand off an artifact — is not a team's
|
|
13
|
+
* to punch through. `grant` may name privileged tools; that is safe precisely because teams are
|
|
14
|
+
* captain-authored files and there is deliberately no create_team tool. */
|
|
15
|
+
export const TeamTools = z
|
|
16
|
+
.object({
|
|
17
|
+
grant: z.array(z.string()).default([]),
|
|
18
|
+
deny: z.array(z.string()).default([]),
|
|
19
|
+
})
|
|
20
|
+
.prefault({});
|
|
21
|
+
export type TeamTools = z.infer<typeof TeamTools>;
|
|
22
|
+
|
|
23
|
+
/** Plan 19: a team is a functional group within the squad — news, trading, programming.
|
|
24
|
+
*
|
|
25
|
+
* Canonical form is teams/<id>/team.md: YAML frontmatter (this schema minus `charterBody`) plus a
|
|
26
|
+
* markdown body that becomes the team's standing instruction, injected into every member's system
|
|
27
|
+
* prompt as a context-tier section. Mirrors agents/<id>/agent.md exactly.
|
|
28
|
+
*
|
|
29
|
+
* MEMBERSHIP IS NOT HERE. An agent declares `team: <id>` in its own frontmatter — one source of truth —
|
|
30
|
+
* and a team's roster is derived by grouping the registry. Listing members here too would let the two
|
|
31
|
+
* drift. `lead` names an agent that must itself declare this team; a mismatch is a boot error. */
|
|
32
|
+
export const TeamDef = z.object({
|
|
33
|
+
id: z.string().regex(/^[a-z][a-z0-9-]*$/),
|
|
34
|
+
/** One line, shown in root's roster in place of the members it stands for. */
|
|
35
|
+
charter: z.string(),
|
|
36
|
+
/** Optional. Present ⇒ delegate_task(to:"<id>") routes to this agent, which then delegates within
|
|
37
|
+
* its team; it costs one delegation level and one model call. Absent ⇒ the engine routes to the
|
|
38
|
+
* best-ranked member directly, for free. Leadless is the right default. */
|
|
39
|
+
lead: z.string().optional(),
|
|
40
|
+
tools: TeamTools,
|
|
41
|
+
/** Body of team.md — the team's standing instruction. Culture is configuration. */
|
|
42
|
+
charterBody: z.string().default(""),
|
|
43
|
+
created: z.string().datetime(),
|
|
44
|
+
});
|
|
45
|
+
export type TeamDef = z.infer<typeof TeamDef>;
|
|
46
|
+
|
|
47
|
+
/** A member's effective toolset: its own grant, minus the team's `deny`, plus the team's `grant`.
|
|
48
|
+
*
|
|
49
|
+
* `deny` wins over BOTH the member's own grant and the team's own `grant` — a team that lists a tool in
|
|
50
|
+
* both is unambiguous rather than order-dependent. The DEFAULT_WORKER_TOOLS floor is protected earlier,
|
|
51
|
+
* when the team file loads (store/teams.ts assertPolicyRespectsFloor), so nothing here can strip a
|
|
52
|
+
* worker's ability to produce and hand off an artifact. */
|
|
53
|
+
export function effectiveTools(agentTools: string[], policy?: TeamTools): string[] {
|
|
54
|
+
if (!policy || (!policy.grant.length && !policy.deny.length)) return agentTools;
|
|
55
|
+
const denied = new Set(policy.deny);
|
|
56
|
+
const out: string[] = [];
|
|
57
|
+
const seen = new Set<string>();
|
|
58
|
+
for (const t of [...agentTools, ...policy.grant])
|
|
59
|
+
if (!denied.has(t) && !seen.has(t)) { seen.add(t); out.push(t); }
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Fold several teams' tool policies into one (Plan 22: an agent may sit on many teams). Grants union,
|
|
64
|
+
* denies union — and since effectiveTools lets deny win over grant, a tool ANY of the agent's teams
|
|
65
|
+
* denies is denied for it, even if another grants it. The DEFAULT_WORKER_TOOLS floor is protected
|
|
66
|
+
* per-team at load (assertPolicyRespectsFloor), so the union can never strip a worker's artifact tools. */
|
|
67
|
+
export function mergeTeamPolicies(policies: (TeamTools | undefined)[]): TeamTools {
|
|
68
|
+
const grant: string[] = [];
|
|
69
|
+
const deny: string[] = [];
|
|
70
|
+
for (const p of policies) {
|
|
71
|
+
if (!p) continue;
|
|
72
|
+
for (const g of p.grant) if (!grant.includes(g)) grant.push(g);
|
|
73
|
+
for (const d of p.deny) if (!deny.includes(d)) deny.push(d);
|
|
74
|
+
}
|
|
75
|
+
return { grant, deny };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The ACL grammar's team production (core/registry.ts). An entry in canSee/canDelegateTo is `"*"`,
|
|
79
|
+
* an exact agent id, or `team:<id>`. No existing id contains a colon, so this is purely additive. */
|
|
80
|
+
export const TEAM_ACL_PREFIX = "team:";
|
|
81
|
+
|
|
82
|
+
export const teamAclEntry = (teamId: string): string => `${TEAM_ACL_PREFIX}${teamId}`;
|
|
83
|
+
|
|
84
|
+
/** The team named by an ACL entry, or null when the entry is `"*"` or an exact agent id. */
|
|
85
|
+
export function parseTeamAcl(entry: string): string | null {
|
|
86
|
+
return entry.startsWith(TEAM_ACL_PREFIX) ? entry.slice(TEAM_ACL_PREFIX.length) : null;
|
|
87
|
+
}
|
package/src/trace.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const CoachingLedger = z.object({
|
|
4
|
+
retrieved: z.array(z.string()), // policy ids
|
|
5
|
+
applied: z.array(z.string()),
|
|
6
|
+
skipped: z.array(z.object({ id: z.string(), reason: z.string() })),
|
|
7
|
+
knowledge: z.array(z.string()).default([]), // kb node ids injected into context (default keeps old traces parseable)
|
|
8
|
+
skills: z.array(z.string()).default([]), // skill ids injected into context (default keeps old traces parseable)
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
/** An independent checker's judgement of a delegated output against its acceptance criteria. */
|
|
12
|
+
export const VerificationVerdict = z.object({
|
|
13
|
+
pass: z.boolean(),
|
|
14
|
+
reasons: z.array(z.string()).default([]), // when pass=false, the unmet criteria; may be empty on pass
|
|
15
|
+
});
|
|
16
|
+
export type VerificationVerdict = z.infer<typeof VerificationVerdict>;
|
|
17
|
+
|
|
18
|
+
/** One criteria→verdict record for a delegation that carried acceptance criteria. A failed initial
|
|
19
|
+
* verdict followed by a retry yields two records (retried:false, then retried:true). */
|
|
20
|
+
export const VerificationRecord = z.object({
|
|
21
|
+
criteria: z.string(),
|
|
22
|
+
verdict: VerificationVerdict,
|
|
23
|
+
runId: z.string(), // the child run whose output was checked
|
|
24
|
+
retried: z.boolean().default(false),
|
|
25
|
+
tokens: z.number().default(0), // the checker call's own spend (advisory; not a hard budget)
|
|
26
|
+
costUsd: z.number().nullable().default(0), // null on a subscription checker (no measurable USD) — never a fabricated 0
|
|
27
|
+
costNote: z.string().optional(), // "subscription" when the checker ran on an unpriced subscription model
|
|
28
|
+
checkerError: z.boolean().optional(), // Plan 20: the checker itself never ran (outage/cancel) — the verdict means "unverified", not a judged fail
|
|
29
|
+
});
|
|
30
|
+
export type VerificationRecord = z.infer<typeof VerificationRecord>;
|
|
31
|
+
|
|
32
|
+
/** One file per run under runs/<agent>/. Written by agents, read by humans + coaching flow only. */
|
|
33
|
+
export const RunTrace = z.object({
|
|
34
|
+
id: z.string(), // <agent>/<date>-run<n>
|
|
35
|
+
agent: z.string(),
|
|
36
|
+
task: z.string(),
|
|
37
|
+
triggeredBy: z.string(), // "user" | run id of delegating run
|
|
38
|
+
ledger: CoachingLedger,
|
|
39
|
+
toolCalls: z.array(z.object({ tool: z.string(), count: z.number() })),
|
|
40
|
+
artifacts: z.array(z.string()), // artifact references (handles/paths) THIS run produced
|
|
41
|
+
// Hand-off graph (parent-side, mirrors delegatedOut): what flowed across this run's delegation
|
|
42
|
+
// edges. inputArtifacts = handles handed DOWN to children; outputArtifacts = handles received UP.
|
|
43
|
+
// Defaults keep pre-Plan-01 traces parseable.
|
|
44
|
+
inputArtifacts: z.array(z.string()).default([]),
|
|
45
|
+
outputArtifacts: z.array(z.string()).default([]),
|
|
46
|
+
delegatedOut: z.array(z.string()), // run ids
|
|
47
|
+
verification: z.array(VerificationRecord).default([]), // criteria→verdict records for this run's delegations (default keeps old traces parseable)
|
|
48
|
+
outcome: z.enum(["completed", "blocked", "failed", "interrupted"]),
|
|
49
|
+
tokens: z.number().default(0),
|
|
50
|
+
// Plan 05: peak estimated context size (system + messages, chars/4) actually sent to the model this
|
|
51
|
+
// run — post-compaction. Advisory (a gate estimate, not billed tokens). Default keeps old traces parseable.
|
|
52
|
+
contextTokens: z.number().default(0),
|
|
53
|
+
costUsd: z.number().nullable().default(0),
|
|
54
|
+
costNote: z.string().optional(),
|
|
55
|
+
// This run's OWN delegation-checker (Plan 06) spend, kept separate from `tokens`/`costUsd` (the
|
|
56
|
+
// primary loop) because the verifier call creates NO child trace. /costs adds these to the run's
|
|
57
|
+
// own-spend sum so verifier tokens/USD surface exactly ONCE (no child double-count). USD is 0 for a
|
|
58
|
+
// subscription/unmeasurable run (mirrors costUsd:null there) — never a fabricated price. Defaults
|
|
59
|
+
// keep pre-Plan-09 traces parseable.
|
|
60
|
+
verifierTokens: z.number().default(0),
|
|
61
|
+
verifierCostUsd: z.number().default(0),
|
|
62
|
+
model: z.string().optional(), // resolved model id — the /costs "by provider" dimension (default keeps old traces parseable)
|
|
63
|
+
aggregate: z.object({ tokens: z.number(), costUsd: z.number().nullable() }).optional(),
|
|
64
|
+
// Plan 18: the agent's plan handle at run end, and how many transitions this run wrote. Both OPTIONAL
|
|
65
|
+
// (not .default(0)) so every trace written before this plan parses AND so the hand-built RunTrace
|
|
66
|
+
// fixtures across the suite need no edit — absent simply means "this run had no plan".
|
|
67
|
+
plan: z.string().optional(),
|
|
68
|
+
planEvents: z.number().optional(),
|
|
69
|
+
notes: z.array(z.string()).default([]),
|
|
70
|
+
durationMs: z.number().default(0),
|
|
71
|
+
started: z.string().datetime(),
|
|
72
|
+
});
|
|
73
|
+
export type RunTrace = z.infer<typeof RunTrace>;
|