pi-chalin 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 +264 -0
- package/agents/conflict-resolver.md +28 -0
- package/agents/context-builder.md +31 -0
- package/agents/delegate.md +28 -0
- package/agents/oracle.md +28 -0
- package/agents/planner.md +28 -0
- package/agents/researcher.md +29 -0
- package/agents/reviewer.md +30 -0
- package/agents/scout.md +32 -0
- package/agents/worker.md +29 -0
- package/package.json +91 -0
- package/src/agent-overrides.ts +12 -0
- package/src/agents.ts +274 -0
- package/src/artifacts.ts +326 -0
- package/src/autoroute.ts +274 -0
- package/src/budget.ts +333 -0
- package/src/child-sessions.ts +108 -0
- package/src/child-tools.ts +796 -0
- package/src/commands.ts +140 -0
- package/src/config.ts +189 -0
- package/src/discovery.ts +190 -0
- package/src/index.ts +40 -0
- package/src/interview.ts +202 -0
- package/src/kernel.ts +254 -0
- package/src/memory.ts +945 -0
- package/src/model-resolution.ts +106 -0
- package/src/orchestration.ts +99 -0
- package/src/paths.ts +50 -0
- package/src/route-format.ts +149 -0
- package/src/route-guards.ts +92 -0
- package/src/route-widget.ts +219 -0
- package/src/runner-prompt.ts +346 -0
- package/src/runner-state.ts +105 -0
- package/src/runner.ts +1185 -0
- package/src/runtime-state.ts +175 -0
- package/src/schemas.ts +316 -0
- package/src/snapshot.ts +282 -0
- package/src/sql-js-fts5.d.ts +4 -0
- package/src/tools.ts +558 -0
- package/src/ui-agents.ts +338 -0
- package/src/ui-status.ts +87 -0
- package/src/ui.ts +875 -0
- package/src/webfetch.ts +294 -0
- package/src/worktrees.ts +113 -0
package/src/agents.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { resolveChalinPaths, type ChalinPathsOptions } from "./paths.ts";
|
|
4
|
+
import {
|
|
5
|
+
type AgentCatalogDiagnostics,
|
|
6
|
+
type AgentCapability,
|
|
7
|
+
type AgentConcern,
|
|
8
|
+
type AgentDefinition,
|
|
9
|
+
type AgentMemoryPolicy,
|
|
10
|
+
type AgentMemoryWritePolicy,
|
|
11
|
+
type AgentScope,
|
|
12
|
+
isAgentConcern,
|
|
13
|
+
isAgentCapability,
|
|
14
|
+
isAgentScope,
|
|
15
|
+
isAgentThinkingLevel,
|
|
16
|
+
} from "./schemas.ts";
|
|
17
|
+
|
|
18
|
+
interface ParsedFrontmatter {
|
|
19
|
+
frontmatter: Record<string, string>;
|
|
20
|
+
body: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AgentCatalogLoadOptions extends ChalinPathsOptions {}
|
|
24
|
+
|
|
25
|
+
export interface AgentResolution {
|
|
26
|
+
agent?: AgentDefinition;
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class AgentCatalog {
|
|
31
|
+
private readonly byScope: Record<AgentScope, Map<string, AgentDefinition>>;
|
|
32
|
+
readonly diagnostics: AgentCatalogDiagnostics;
|
|
33
|
+
|
|
34
|
+
private constructor(
|
|
35
|
+
byScope: Record<AgentScope, Map<string, AgentDefinition>>,
|
|
36
|
+
diagnostics: AgentCatalogDiagnostics,
|
|
37
|
+
) {
|
|
38
|
+
this.byScope = byScope;
|
|
39
|
+
this.diagnostics = diagnostics;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static load(options: AgentCatalogLoadOptions): AgentCatalog {
|
|
43
|
+
const paths = resolveChalinPaths(options);
|
|
44
|
+
const diagnostics: AgentCatalogDiagnostics = { warnings: [], errors: [] };
|
|
45
|
+
const byScope: Record<AgentScope, Map<string, AgentDefinition>> = {
|
|
46
|
+
"built-in": new Map(),
|
|
47
|
+
project: new Map(),
|
|
48
|
+
user: new Map(),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
loadAgentDir(paths.builtInAgentsDir, "built-in", byScope["built-in"], diagnostics);
|
|
52
|
+
loadAgentDir(paths.userAgentsDir, "user", byScope.user, diagnostics);
|
|
53
|
+
loadAgentDir(paths.projectAgentsDir, "project", byScope.project, diagnostics);
|
|
54
|
+
|
|
55
|
+
return new AgentCatalog(byScope, diagnostics);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
list(scope?: AgentScope): AgentDefinition[] {
|
|
59
|
+
const scopes = scope ? [scope] : (["project", "user", "built-in"] as const);
|
|
60
|
+
return scopes.flatMap((currentScope) => [...this.byScope[currentScope].values()]).sort(compareAgents);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
listExecutable(): AgentDefinition[] {
|
|
64
|
+
return this.list().filter((agent) => !agent.diagnostics.some((diag) => diag.startsWith("invalid:")));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
resolve(reference: string): AgentResolution {
|
|
68
|
+
const trimmed = reference.trim();
|
|
69
|
+
if (!trimmed) return { error: "Agent reference must not be empty." };
|
|
70
|
+
|
|
71
|
+
const slashIndex = trimmed.indexOf("/");
|
|
72
|
+
if (slashIndex !== -1) {
|
|
73
|
+
const maybeScope = trimmed.slice(0, slashIndex);
|
|
74
|
+
const name = trimmed.slice(slashIndex + 1);
|
|
75
|
+
if (!isAgentScope(maybeScope)) {
|
|
76
|
+
return { error: `Unknown agent scope '${maybeScope}'. Use project/name, user/name, or built-in/name.` };
|
|
77
|
+
}
|
|
78
|
+
const agent = this.byScope[maybeScope].get(name);
|
|
79
|
+
return agent ? { agent } : { error: `Agent '${name}' not found in ${maybeScope} scope.` };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const scope of ["project", "user", "built-in"] as const) {
|
|
83
|
+
const agent = this.byScope[scope].get(trimmed);
|
|
84
|
+
if (agent) return { agent };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { error: `Agent '${trimmed}' not found. Available agents: ${this.list().map((agent) => `${agent.scope}/${agent.name}`).join(", ") || "none"}.` };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
resolveMany(references: string[]): { agents: AgentDefinition[]; errors: string[] } {
|
|
91
|
+
const agents: AgentDefinition[] = [];
|
|
92
|
+
const errors: string[] = [];
|
|
93
|
+
for (const reference of references) {
|
|
94
|
+
const result = this.resolve(reference);
|
|
95
|
+
if (result.agent) agents.push(result.agent);
|
|
96
|
+
if (result.error) errors.push(result.error);
|
|
97
|
+
}
|
|
98
|
+
return { agents, errors };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function compareAgents(a: AgentDefinition, b: AgentDefinition): number {
|
|
103
|
+
const scopeRank: Record<AgentScope, number> = { project: 0, user: 1, "built-in": 2 };
|
|
104
|
+
return scopeRank[a.scope] - scopeRank[b.scope] || a.name.localeCompare(b.name);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function loadAgentDir(
|
|
108
|
+
dir: string,
|
|
109
|
+
scope: AgentScope,
|
|
110
|
+
target: Map<string, AgentDefinition>,
|
|
111
|
+
diagnostics: AgentCatalogDiagnostics,
|
|
112
|
+
): void {
|
|
113
|
+
if (!fs.existsSync(dir)) return;
|
|
114
|
+
let entries: fs.Dirent[];
|
|
115
|
+
try {
|
|
116
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
117
|
+
} catch (error) {
|
|
118
|
+
diagnostics.errors.push(`Failed to read ${scope} agents dir '${dir}': ${errorMessage(error)}`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
|
124
|
+
const filePath = path.join(dir, entry.name);
|
|
125
|
+
const loaded = loadAgentFile(filePath, scope);
|
|
126
|
+
for (const warning of loaded.diagnostics) diagnostics.warnings.push(`${filePath}: ${warning}`);
|
|
127
|
+
if (target.has(loaded.name)) {
|
|
128
|
+
diagnostics.warnings.push(`${filePath}: duplicate agent '${loaded.name}' in ${scope} scope; later file overwrote earlier definition.`);
|
|
129
|
+
}
|
|
130
|
+
target.set(loaded.name, loaded);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function loadAgentFile(filePath: string, scope: AgentScope): AgentDefinition {
|
|
135
|
+
const diagnostics: string[] = [];
|
|
136
|
+
let raw = "";
|
|
137
|
+
try {
|
|
138
|
+
raw = fs.readFileSync(filePath, "utf-8");
|
|
139
|
+
} catch (error) {
|
|
140
|
+
diagnostics.push(`invalid: failed to read agent file: ${errorMessage(error)}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const parsed = parseFrontmatter(raw);
|
|
144
|
+
const fileName = path.basename(filePath, ".md");
|
|
145
|
+
const name = (parsed.frontmatter.name || fileName).trim();
|
|
146
|
+
const concern = parseConcern(parsed.frontmatter.concern, diagnostics, filePath);
|
|
147
|
+
const capabilities = parseCapabilities(parsed.frontmatter.capabilities, concern, parsed.frontmatter.tools, diagnostics);
|
|
148
|
+
const memory = parseMemoryPolicy(parsed.frontmatter, diagnostics);
|
|
149
|
+
const model = (parsed.frontmatter.model || "inherit").trim() || "inherit";
|
|
150
|
+
const thinking = parseThinkingLevel(parsed.frontmatter.thinking ?? parsed.frontmatter["thinking-level"], diagnostics);
|
|
151
|
+
const tools = parseStringList(parsed.frontmatter.tools);
|
|
152
|
+
const description = (parsed.frontmatter.description || `${name} agent`).trim();
|
|
153
|
+
|
|
154
|
+
if (!name) diagnostics.push("invalid: agent name must not be empty.");
|
|
155
|
+
if (!parsed.body.trim()) diagnostics.push("invalid: agent system prompt body must not be empty.");
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
name,
|
|
159
|
+
scope,
|
|
160
|
+
concern,
|
|
161
|
+
capabilities,
|
|
162
|
+
description,
|
|
163
|
+
model,
|
|
164
|
+
thinking,
|
|
165
|
+
tools,
|
|
166
|
+
memory,
|
|
167
|
+
systemPrompt: parsed.body.trim(),
|
|
168
|
+
sourcePath: filePath,
|
|
169
|
+
diagnostics,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseThinkingLevel(value: string | undefined, diagnostics: string[]) {
|
|
174
|
+
const thinking = (value || "inherit").trim();
|
|
175
|
+
if (isAgentThinkingLevel(thinking)) return thinking;
|
|
176
|
+
diagnostics.push(`invalid: unknown thinking level '${thinking}'. Use inherit, off, minimal, low, medium, high, or xhigh.`);
|
|
177
|
+
return "inherit";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function parseCapabilities(value: string | undefined, concern: AgentConcern, tools: string | undefined, diagnostics: string[]): AgentCapability[] {
|
|
181
|
+
const raw = parseStringList(value);
|
|
182
|
+
const capabilities = raw.length > 0 ? raw : defaultCapabilities(concern, parseStringList(tools));
|
|
183
|
+
const valid: AgentCapability[] = [];
|
|
184
|
+
for (const capability of capabilities) {
|
|
185
|
+
if (isAgentCapability(capability)) valid.push(capability);
|
|
186
|
+
else diagnostics.push(`invalid: unknown capability '${capability}'.`);
|
|
187
|
+
}
|
|
188
|
+
return [...new Set(valid)];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function defaultCapabilities(concern: AgentConcern, tools: string[]): AgentCapability[] {
|
|
192
|
+
const fromTools: AgentCapability[] = [];
|
|
193
|
+
if (tools.some((tool) => ["read", "ls"].includes(tool))) fromTools.push("inspect-files");
|
|
194
|
+
if (tools.some((tool) => ["grep", "find"].includes(tool))) fromTools.push("search-files");
|
|
195
|
+
if (tools.includes("bash")) fromTools.push("run-safe-bash");
|
|
196
|
+
if (tools.includes("edit")) fromTools.push("edit-files");
|
|
197
|
+
if (tools.includes("write")) fromTools.push("write-new-files");
|
|
198
|
+
const byConcern: Partial<Record<AgentConcern, AgentCapability[]>> = {
|
|
199
|
+
recon: ["inspect-files", "search-files", "memory-read"],
|
|
200
|
+
research: ["inspect-files", "search-files", "external-context", "memory-read"],
|
|
201
|
+
"context-building": ["inspect-files", "search-files", "memory-read", "memory-write"],
|
|
202
|
+
planning: ["inspect-files", "search-files", "memory-read", "coordinate"],
|
|
203
|
+
implementation: ["inspect-files", "search-files", "run-safe-bash", "validate", "edit-files", "write-new-files", "memory-read", "memory-write"],
|
|
204
|
+
review: ["inspect-files", "search-files", "run-safe-bash", "validate", "memory-read", "memory-write"],
|
|
205
|
+
"conflict-resolution": ["inspect-files", "search-files", "run-safe-bash", "validate", "edit-files", "memory-read", "memory-write"],
|
|
206
|
+
"decision-consistency": ["inspect-files", "search-files", "memory-read", "coordinate"],
|
|
207
|
+
delegation: ["inspect-files", "search-files", "coordinate"],
|
|
208
|
+
"memory-curation": ["memory-read", "memory-write"],
|
|
209
|
+
};
|
|
210
|
+
return [...new Set([...(byConcern[concern] ?? []), ...fromTools])];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function parseFrontmatter(content: string): ParsedFrontmatter {
|
|
214
|
+
const normalized = content.replace(/\r\n/g, "\n");
|
|
215
|
+
if (!normalized.startsWith("---\n")) return { frontmatter: {}, body: normalized };
|
|
216
|
+
|
|
217
|
+
const endIndex = normalized.indexOf("\n---", 4);
|
|
218
|
+
if (endIndex === -1) return { frontmatter: {}, body: normalized };
|
|
219
|
+
|
|
220
|
+
const block = normalized.slice(4, endIndex);
|
|
221
|
+
const body = normalized.slice(endIndex + 4).trim();
|
|
222
|
+
const frontmatter: Record<string, string> = {};
|
|
223
|
+
|
|
224
|
+
for (const line of block.split("\n")) {
|
|
225
|
+
const trimmed = line.trim();
|
|
226
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
227
|
+
const match = trimmed.match(/^([A-Za-z0-9_.-]+):\s*(.*)$/);
|
|
228
|
+
if (!match) continue;
|
|
229
|
+
let value = match[2]?.trim() ?? "";
|
|
230
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
231
|
+
value = value.slice(1, -1);
|
|
232
|
+
}
|
|
233
|
+
frontmatter[match[1]!] = value;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { frontmatter, body };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function parseConcern(value: string | undefined, diagnostics: string[], filePath: string): AgentConcern {
|
|
240
|
+
const concern = (value || "delegation").trim();
|
|
241
|
+
if (isAgentConcern(concern)) return concern;
|
|
242
|
+
diagnostics.push(`invalid: unknown concern '${concern}' in '${filePath}'.`);
|
|
243
|
+
return "delegation";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function parseMemoryPolicy(frontmatter: Record<string, string>, diagnostics: string[]): AgentMemoryPolicy {
|
|
247
|
+
const rawWrite = (frontmatter["memory-write"] || "candidate").trim();
|
|
248
|
+
const write: AgentMemoryWritePolicy = rawWrite === "never" || rawWrite === "candidate" || rawWrite === "approved" ? rawWrite : "candidate";
|
|
249
|
+
if (write !== rawWrite) diagnostics.push(`Invalid memory-write '${rawWrite}'; using 'candidate'.`);
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
read: parseBoolean(frontmatter["memory-read"], true),
|
|
253
|
+
write,
|
|
254
|
+
categories: parseStringList(frontmatter["memory-categories"]),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
|
|
259
|
+
if (value === undefined) return fallback;
|
|
260
|
+
if (["true", "yes", "1"].includes(value.toLowerCase())) return true;
|
|
261
|
+
if (["false", "no", "0"].includes(value.toLowerCase())) return false;
|
|
262
|
+
return fallback;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function parseStringList(value: string | undefined): string[] {
|
|
266
|
+
if (!value) return [];
|
|
267
|
+
const trimmed = value.trim();
|
|
268
|
+
const unwrapped = trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
|
|
269
|
+
return unwrapped.split(",").map((item) => item.trim().replace(/^['\"]|['\"]$/g, "")).filter(Boolean);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function errorMessage(error: unknown): string {
|
|
273
|
+
return error instanceof Error ? error.message : String(error);
|
|
274
|
+
}
|
package/src/artifacts.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { resolveChalinPaths, type ChalinPathsOptions } from "./paths.ts";
|
|
4
|
+
import type { RunState } from "./schemas.ts";
|
|
5
|
+
|
|
6
|
+
export type ArtifactFeatureStatus = "active" | "complete" | "failed" | "paused";
|
|
7
|
+
|
|
8
|
+
export interface ArtifactCheckpointInput {
|
|
9
|
+
agent: string;
|
|
10
|
+
title: string;
|
|
11
|
+
summary: string;
|
|
12
|
+
status: ArtifactFeatureStatus;
|
|
13
|
+
stage?: string;
|
|
14
|
+
validationRefs?: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ArtifactCheckpoint extends ArtifactCheckpointInput {
|
|
18
|
+
id: string;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ValidationContract {
|
|
23
|
+
id: string;
|
|
24
|
+
title: string;
|
|
25
|
+
commands: string[];
|
|
26
|
+
successCriteria: string[];
|
|
27
|
+
files?: string[];
|
|
28
|
+
createdAt?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface WorkerSkillInput {
|
|
32
|
+
name: string;
|
|
33
|
+
summary: string;
|
|
34
|
+
rules: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface WorkerSkillArtifact extends WorkerSkillInput {
|
|
38
|
+
path: string;
|
|
39
|
+
createdAt: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ValidationContractArtifact extends ValidationContract {
|
|
43
|
+
createdAt: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface InterviewAnswerArtifact {
|
|
47
|
+
questionId: string;
|
|
48
|
+
question: string;
|
|
49
|
+
answer: string;
|
|
50
|
+
custom?: boolean;
|
|
51
|
+
recommended?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface InterviewDecisionInput {
|
|
55
|
+
task: string;
|
|
56
|
+
reason: string;
|
|
57
|
+
status: "answered" | "cancelled" | "non-interactive";
|
|
58
|
+
answers: InterviewAnswerArtifact[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface InterviewDecisionArtifact extends InterviewDecisionInput {
|
|
62
|
+
id: string;
|
|
63
|
+
createdAt: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface FeatureArtifactState {
|
|
67
|
+
featureId: string;
|
|
68
|
+
goal: string;
|
|
69
|
+
status: ArtifactFeatureStatus;
|
|
70
|
+
chain: string[];
|
|
71
|
+
currentStep?: string;
|
|
72
|
+
checkpoints: ArtifactCheckpoint[];
|
|
73
|
+
validationContracts: ValidationContractArtifact[];
|
|
74
|
+
workerSkills: WorkerSkillArtifact[];
|
|
75
|
+
interviewDecisions: InterviewDecisionArtifact[];
|
|
76
|
+
updatedAt: string;
|
|
77
|
+
createdAt: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface RunArtifactSummary {
|
|
81
|
+
runId: string;
|
|
82
|
+
routeKind: RunState["route"]["kind"];
|
|
83
|
+
agents: string[];
|
|
84
|
+
status: RunState["status"];
|
|
85
|
+
startedAt: string;
|
|
86
|
+
endedAt?: string;
|
|
87
|
+
durationMs?: number;
|
|
88
|
+
handoffs: Array<{ agent: string; summary: string; status: string }>;
|
|
89
|
+
warnings: string[];
|
|
90
|
+
metrics?: RunState["metrics"];
|
|
91
|
+
createdAt: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class ArtifactStore {
|
|
95
|
+
private readonly root: string;
|
|
96
|
+
|
|
97
|
+
constructor(options: ChalinPathsOptions) {
|
|
98
|
+
this.root = path.join(resolveChalinPaths(options).projectRoot, ".pi-chalin", "artifacts");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async initFeature(input: { featureId: string; goal: string; chain?: string[]; currentStep?: string }): Promise<FeatureArtifactState> {
|
|
102
|
+
const now = new Date().toISOString();
|
|
103
|
+
const existing = await this.loadFeature(input.featureId);
|
|
104
|
+
const state: FeatureArtifactState = existing ? {
|
|
105
|
+
...existing,
|
|
106
|
+
goal: input.goal,
|
|
107
|
+
chain: input.chain ?? existing.chain,
|
|
108
|
+
currentStep: input.currentStep ?? existing.currentStep,
|
|
109
|
+
status: existing.status === "complete" ? "active" : existing.status,
|
|
110
|
+
updatedAt: now,
|
|
111
|
+
} : {
|
|
112
|
+
featureId: safeId(input.featureId),
|
|
113
|
+
goal: input.goal,
|
|
114
|
+
status: "active",
|
|
115
|
+
chain: input.chain ?? [],
|
|
116
|
+
currentStep: input.currentStep,
|
|
117
|
+
checkpoints: [],
|
|
118
|
+
validationContracts: [],
|
|
119
|
+
workerSkills: [],
|
|
120
|
+
interviewDecisions: [],
|
|
121
|
+
createdAt: now,
|
|
122
|
+
updatedAt: now,
|
|
123
|
+
};
|
|
124
|
+
await this.writeFeatureState(state);
|
|
125
|
+
return state;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async loadFeature(featureId: string): Promise<FeatureArtifactState | undefined> {
|
|
129
|
+
const file = this.featureStatePath(featureId);
|
|
130
|
+
if (!fs.existsSync(file)) return undefined;
|
|
131
|
+
return normalizeFeatureState(JSON.parse(fs.readFileSync(file, "utf-8")) as Partial<FeatureArtifactState>);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async appendCheckpoint(featureId: string, input: ArtifactCheckpointInput): Promise<ArtifactCheckpoint> {
|
|
135
|
+
const state = await this.ensureFeature(featureId);
|
|
136
|
+
const now = new Date().toISOString();
|
|
137
|
+
const checkpoint: ArtifactCheckpoint = { ...input, id: `checkpoint-${Date.now().toString(36)}`, createdAt: now };
|
|
138
|
+
state.checkpoints.push(checkpoint);
|
|
139
|
+
state.currentStep = input.title;
|
|
140
|
+
state.status = input.status;
|
|
141
|
+
state.updatedAt = now;
|
|
142
|
+
appendJsonLine(this.featurePath(featureId, "checkpoints.jsonl"), checkpoint);
|
|
143
|
+
await this.writeFeatureState(state);
|
|
144
|
+
return checkpoint;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async saveValidationContract(featureId: string, input: ValidationContract): Promise<ValidationContractArtifact> {
|
|
148
|
+
const state = await this.ensureFeature(featureId);
|
|
149
|
+
const contract = { ...input, id: safeId(input.id), createdAt: input.createdAt ?? new Date().toISOString() };
|
|
150
|
+
state.validationContracts = [...state.validationContracts.filter((item) => item.id !== contract.id), contract];
|
|
151
|
+
state.updatedAt = contract.createdAt;
|
|
152
|
+
await this.writeFeatureState(state);
|
|
153
|
+
atomicWriteJson(this.featurePath(featureId, "validations", `${contract.id}.json`), contract);
|
|
154
|
+
return contract;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async saveWorkerSkill(featureId: string, input: WorkerSkillInput): Promise<WorkerSkillArtifact> {
|
|
158
|
+
const state = await this.ensureFeature(featureId);
|
|
159
|
+
const name = safeId(input.name);
|
|
160
|
+
const skillPath = this.featurePath(featureId, "skills", name, "SKILL.md");
|
|
161
|
+
const createdAt = new Date().toISOString();
|
|
162
|
+
const artifact: WorkerSkillArtifact = { ...input, name, path: skillPath, createdAt };
|
|
163
|
+
atomicWriteText(skillPath, formatWorkerSkill(artifact));
|
|
164
|
+
state.workerSkills = [...state.workerSkills.filter((item) => item.name !== name), artifact];
|
|
165
|
+
state.updatedAt = createdAt;
|
|
166
|
+
await this.writeFeatureState(state);
|
|
167
|
+
return artifact;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async appendInterviewDecision(featureId: string, input: InterviewDecisionInput): Promise<InterviewDecisionArtifact> {
|
|
171
|
+
const state = await this.ensureFeature(featureId);
|
|
172
|
+
const createdAt = new Date().toISOString();
|
|
173
|
+
const artifact: InterviewDecisionArtifact = { ...input, id: `interview-${Date.now().toString(36)}`, createdAt };
|
|
174
|
+
state.interviewDecisions.push(artifact);
|
|
175
|
+
state.currentStep = input.status === "answered" ? "Interview answered" : "Interview pending";
|
|
176
|
+
state.status = input.status === "answered" ? state.status : "paused";
|
|
177
|
+
state.updatedAt = createdAt;
|
|
178
|
+
appendJsonLine(this.featurePath(featureId, "interviews.jsonl"), artifact);
|
|
179
|
+
await this.writeFeatureState(state);
|
|
180
|
+
return artifact;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async resumeContext(featureId: string): Promise<string> {
|
|
184
|
+
const state = await this.loadFeature(featureId);
|
|
185
|
+
if (!state) return `No pi-chalin artifacts found for feature '${featureId}'.`;
|
|
186
|
+
const latest = state.checkpoints.slice(-5).map((checkpoint) => `- ${checkpoint.title} (${checkpoint.agent}, ${checkpoint.status}): ${checkpoint.summary}`);
|
|
187
|
+
const validations = state.validationContracts.map((contract) => `- ${contract.id}: ${contract.successCriteria.join("; ")}`);
|
|
188
|
+
const interviews = state.interviewDecisions.slice(-5).flatMap((decision) => [
|
|
189
|
+
`- ${decision.status}: ${decision.reason}`,
|
|
190
|
+
...decision.answers.map((answer) => ` - ${answer.question}: ${answer.answer}`),
|
|
191
|
+
]);
|
|
192
|
+
const skills = state.workerSkills.map((skill) => `- ${skill.name}: ${skill.summary}`);
|
|
193
|
+
return [
|
|
194
|
+
`Feature: ${state.featureId}`,
|
|
195
|
+
`Goal: ${state.goal}`,
|
|
196
|
+
`Status: ${state.status}`,
|
|
197
|
+
state.chain.length ? `Chain: ${state.chain.join(" → ")}` : undefined,
|
|
198
|
+
state.currentStep ? `Current step: ${state.currentStep}` : undefined,
|
|
199
|
+
latest.length ? "Recent checkpoints:" : undefined,
|
|
200
|
+
...latest,
|
|
201
|
+
validations.length ? "Validation contracts:" : undefined,
|
|
202
|
+
...validations,
|
|
203
|
+
interviews.length ? "Interview decisions:" : undefined,
|
|
204
|
+
...interviews,
|
|
205
|
+
skills.length ? "Worker skills:" : undefined,
|
|
206
|
+
...skills,
|
|
207
|
+
].filter((line): line is string => Boolean(line)).join("\n");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async recordRun(run: RunState): Promise<RunArtifactSummary> {
|
|
211
|
+
const createdAt = new Date().toISOString();
|
|
212
|
+
const summary: RunArtifactSummary = {
|
|
213
|
+
runId: run.id,
|
|
214
|
+
routeKind: run.route.kind,
|
|
215
|
+
agents: run.route.agents,
|
|
216
|
+
status: run.status,
|
|
217
|
+
startedAt: run.startedAt,
|
|
218
|
+
endedAt: run.endedAt,
|
|
219
|
+
durationMs: run.metrics?.durationMs,
|
|
220
|
+
handoffs: run.steps.map((step) => ({
|
|
221
|
+
agent: step.agent,
|
|
222
|
+
status: step.status,
|
|
223
|
+
summary: compact(step.output?.handoff || step.output?.text || step.error || "", 600),
|
|
224
|
+
})).filter((item) => item.summary.length > 0),
|
|
225
|
+
warnings: run.warnings,
|
|
226
|
+
metrics: run.metrics,
|
|
227
|
+
createdAt,
|
|
228
|
+
};
|
|
229
|
+
atomicWriteJson(this.runPath(run.id, "summary.json"), summary);
|
|
230
|
+
return summary;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async loadRun(runId: string): Promise<RunArtifactSummary | undefined> {
|
|
234
|
+
const file = this.runPath(runId, "summary.json");
|
|
235
|
+
if (!fs.existsSync(file)) return undefined;
|
|
236
|
+
return JSON.parse(fs.readFileSync(file, "utf-8")) as RunArtifactSummary;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async listFeatures(): Promise<FeatureArtifactState[]> {
|
|
240
|
+
const dir = path.join(this.root, "features");
|
|
241
|
+
if (!fs.existsSync(dir)) return [];
|
|
242
|
+
return fs.readdirSync(dir)
|
|
243
|
+
.map((name) => path.join(dir, name, "state.json"))
|
|
244
|
+
.filter((file) => fs.existsSync(file))
|
|
245
|
+
.map((file) => normalizeFeatureState(JSON.parse(fs.readFileSync(file, "utf-8")) as Partial<FeatureArtifactState>))
|
|
246
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private async ensureFeature(featureId: string): Promise<FeatureArtifactState> {
|
|
250
|
+
return await this.loadFeature(featureId) ?? await this.initFeature({ featureId, goal: `Continue ${featureId}` });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private async writeFeatureState(state: FeatureArtifactState): Promise<void> {
|
|
254
|
+
atomicWriteJson(this.featureStatePath(state.featureId), state);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private featureStatePath(featureId: string): string {
|
|
258
|
+
return this.featurePath(featureId, "state.json");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private featurePath(featureId: string, ...parts: string[]): string {
|
|
262
|
+
return path.join(this.root, "features", safeId(featureId), ...parts);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private runPath(runId: string, ...parts: string[]): string {
|
|
266
|
+
return path.join(this.root, "runs", safeId(runId), ...parts);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function safeId(value: string): string {
|
|
271
|
+
return value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 96) || "artifact";
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function atomicWriteJson(file: string, value: unknown): void {
|
|
275
|
+
atomicWriteText(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function atomicWriteText(file: string, value: string): void {
|
|
279
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
280
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
281
|
+
fs.writeFileSync(tmp, value);
|
|
282
|
+
fs.renameSync(tmp, file);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function appendJsonLine(file: string, value: unknown): void {
|
|
286
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
287
|
+
fs.appendFileSync(file, `${JSON.stringify(value)}\n`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function formatWorkerSkill(skill: WorkerSkillArtifact): string {
|
|
291
|
+
return [
|
|
292
|
+
"---",
|
|
293
|
+
`name: ${skill.name}`,
|
|
294
|
+
`description: ${skill.summary}`,
|
|
295
|
+
"---",
|
|
296
|
+
"",
|
|
297
|
+
"## Purpose",
|
|
298
|
+
skill.summary,
|
|
299
|
+
"",
|
|
300
|
+
"## Rules",
|
|
301
|
+
...skill.rules.map((rule) => `- ${rule}`),
|
|
302
|
+
"",
|
|
303
|
+
].join("\n");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function compact(text: string, max: number): string {
|
|
307
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
308
|
+
return normalized.length <= max ? normalized : `${normalized.slice(0, max - 1)}…`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function normalizeFeatureState(raw: Partial<FeatureArtifactState>): FeatureArtifactState {
|
|
312
|
+
const now = new Date().toISOString();
|
|
313
|
+
return {
|
|
314
|
+
featureId: raw.featureId ?? "artifact",
|
|
315
|
+
goal: raw.goal ?? "Continue artifact",
|
|
316
|
+
status: raw.status ?? "active",
|
|
317
|
+
chain: raw.chain ?? [],
|
|
318
|
+
currentStep: raw.currentStep,
|
|
319
|
+
checkpoints: raw.checkpoints ?? [],
|
|
320
|
+
validationContracts: raw.validationContracts ?? [],
|
|
321
|
+
workerSkills: raw.workerSkills ?? [],
|
|
322
|
+
interviewDecisions: raw.interviewDecisions ?? [],
|
|
323
|
+
createdAt: raw.createdAt ?? now,
|
|
324
|
+
updatedAt: raw.updatedAt ?? now,
|
|
325
|
+
};
|
|
326
|
+
}
|