@amistio/cli 0.1.0 → 0.1.1

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.
Files changed (45) hide show
  1. package/dist/index.d.ts +0 -1
  2. package/dist/index.js +2451 -642
  3. package/dist/index.js.map +7 -1
  4. package/package.json +9 -6
  5. package/dist/api-client.d.ts +0 -175
  6. package/dist/api-client.d.ts.map +0 -1
  7. package/dist/api-client.js +0 -116
  8. package/dist/api-client.js.map +0 -1
  9. package/dist/bootstrap.d.ts +0 -11
  10. package/dist/bootstrap.d.ts.map +0 -1
  11. package/dist/bootstrap.js +0 -66
  12. package/dist/bootstrap.js.map +0 -1
  13. package/dist/control-plane.d.ts +0 -11
  14. package/dist/control-plane.d.ts.map +0 -1
  15. package/dist/control-plane.js +0 -91
  16. package/dist/control-plane.js.map +0 -1
  17. package/dist/credential-store.d.ts +0 -9
  18. package/dist/credential-store.d.ts.map +0 -1
  19. package/dist/credential-store.js +0 -32
  20. package/dist/credential-store.js.map +0 -1
  21. package/dist/index.d.ts.map +0 -1
  22. package/dist/local-tool-runner.d.ts +0 -93
  23. package/dist/local-tool-runner.d.ts.map +0 -1
  24. package/dist/local-tool-runner.js +0 -472
  25. package/dist/local-tool-runner.js.map +0 -1
  26. package/dist/orchestrator.d.ts +0 -7
  27. package/dist/orchestrator.d.ts.map +0 -1
  28. package/dist/orchestrator.js +0 -61
  29. package/dist/orchestrator.js.map +0 -1
  30. package/dist/session-policy.d.ts +0 -20
  31. package/dist/session-policy.d.ts.map +0 -1
  32. package/dist/session-policy.js +0 -125
  33. package/dist/session-policy.js.map +0 -1
  34. package/dist/sync.d.ts +0 -33
  35. package/dist/sync.d.ts.map +0 -1
  36. package/dist/sync.js +0 -279
  37. package/dist/sync.js.map +0 -1
  38. package/dist/tool-session-store.d.ts +0 -8
  39. package/dist/tool-session-store.d.ts.map +0 -1
  40. package/dist/tool-session-store.js +0 -38
  41. package/dist/tool-session-store.js.map +0 -1
  42. package/dist/work-runner.d.ts +0 -4
  43. package/dist/work-runner.d.ts.map +0 -1
  44. package/dist/work-runner.js +0 -100
  45. package/dist/work-runner.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,698 +1,2507 @@
1
1
  #!/usr/bin/env node
2
- import { createHash, randomUUID } from "node:crypto";
3
- import { writeFile } from "node:fs/promises";
4
- import path from "node:path";
2
+
3
+ // src/index.ts
4
+ import { createHash as createHash2, randomUUID } from "node:crypto";
5
+ import { writeFile as writeFile7 } from "node:fs/promises";
6
+ import path8 from "node:path";
5
7
  import { Command } from "commander";
6
- import { AMISTIO_API_URL_ENV, isOfficialAmistioApiUrl, officialAmistioApiUrl, parseRepositoryCloneUrl } from "@amistio/shared";
7
- import { cloneOrValidateRepository } from "./bootstrap.js";
8
- import { credentialKey, LocalCredentialStore } from "./credential-store.js";
9
- import { initControlPlane, inspectControlPlane, readProjectLink, writeProjectLink } from "./control-plane.js";
10
- import { ApiClient } from "./api-client.js";
11
- import { createOrchestrationPrompt, writePromptFile } from "./orchestrator.js";
12
- import { createToolRunPreview, detectLocalTools, runLocalTool } from "./local-tool-runner.js";
13
- import { normalizeSessionPolicy, selectToolSession } from "./session-policy.js";
14
- import { collectDirtyDocumentsForPush, collectSyncStatus, materializeBrainDocuments } from "./sync.js";
15
- import { LocalToolSessionStore } from "./tool-session-store.js";
16
- import { createWorkExecutionPrompt, parseBrainGenerationArtifacts } from "./work-runner.js";
17
- const program = new Command();
18
- const defaultRoot = process.env.INIT_CWD ?? process.cwd();
19
- const apiUrlOptionDescription = `Amistio API URL override (or ${AMISTIO_API_URL_ENV})`;
20
- program.name("amistio").description("Amistio project brain CLI").version("0.1.0");
21
- const CLI_VERSION = "0.1.0";
22
- program
23
- .command("init")
24
- .description("Create Amistio control-plane folders for a new project")
25
- .option("--root <path>", "Repository root", defaultRoot)
26
- .action(async (options) => {
27
- const created = await initControlPlane(options.root);
28
- console.log(created.length ? `Created ${created.length} control-plane folders.` : "Control-plane folders already exist.");
29
- });
30
- program
31
- .command("onboard")
32
- .description("Inspect and prepare an existing repository for Amistio")
33
- .option("--root <path>", "Repository root", defaultRoot)
34
- .action(async (options) => {
35
- const result = await inspectControlPlane(options.root);
36
- console.log(`Present: ${result.present.length ? result.present.join(", ") : "none"}`);
37
- console.log(`Missing: ${result.missing.length ? result.missing.join(", ") : "none"}`);
38
- if (result.missing.length) {
39
- console.log("Run `amistio init` to create missing control-plane folders without overwriting existing files.");
40
- }
41
- });
42
- program
43
- .command("bootstrap")
44
- .description("Clone a linked repository locally, prepare the control plane, and pair it with Amistio")
45
- .requiredOption("--repo-url <url>", "Linked repository clone URL")
46
- .requiredOption("--target <path>", "Local checkout target path")
47
- .requiredOption("--account <accountId>", "Amistio account ID")
48
- .requiredOption("--project <projectId>", "Amistio project ID")
49
- .requiredOption("--repository-link <repositoryLinkId>", "Existing repository link ID")
50
- .option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl())
51
- .requiredOption("--pairing-code <code>", "Short-lived pairing code from the Amistio app")
52
- .option("--default-branch <branch>", "Default branch", "main")
53
- .action(async (options) => {
54
- const parsedRepoUrl = parseRepositoryCloneUrl(options.repoUrl);
55
- const checkout = await cloneOrValidateRepository({ repoUrl: parsedRepoUrl.cloneUrl, targetDir: options.target });
56
- await initControlPlane(checkout.targetDir);
57
- const pairing = await new ApiClient({
58
- apiUrl: options.apiUrl,
59
- accountId: options.account
60
- }).consumePairingSession({
61
- projectId: options.project,
62
- pairingCode: options.pairingCode,
63
- repositoryLinkId: options.repositoryLink,
64
- repoName: parsedRepoUrl.repoName,
65
- repoFingerprint: createRepoFingerprint(options.account, options.project, options.repositoryLink),
66
- defaultBranch: options.defaultBranch
67
- });
68
- const filePath = await writeProjectLink(checkout.targetDir, {
69
- amistioAccountId: options.account,
70
- amistioProjectId: options.project,
71
- repositoryLinkId: pairing.repositoryLink.repositoryLinkId,
72
- defaultBranch: options.defaultBranch,
73
- lastSyncedRevision: 0
74
- });
75
- await new LocalCredentialStore().set(credentialKey(options.account, options.project, pairing.repositoryLink.repositoryLinkId), pairing.token);
76
- console.log(checkout.status === "cloned" ? `Cloned repository to ${checkout.targetDir}.` : `Validated existing checkout at ${checkout.targetDir}.`);
77
- console.log(`Pairing confirmed for ${pairing.repositoryLink.repoName}.`);
78
- console.log(`Wrote non-secret project metadata to ${filePath}.`);
79
- console.log(`Next: cd ${formatShellArg(checkout.targetDir)} && amistio run${formatApiUrlFlag(options.apiUrl)} --watch`);
80
- });
81
- program
82
- .command("pair")
83
- .description("Pair this repository with an Amistio web project")
84
- .requiredOption("--account <accountId>", "Amistio account ID")
85
- .requiredOption("--project <projectId>", "Amistio project ID")
86
- .option("--repository-link <repositoryLinkId>", "Existing repository link ID")
87
- .option("--default-branch <branch>", "Default branch", "main")
88
- .option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl())
89
- .option("--pairing-code <code>", "Short-lived pairing code from the Amistio app")
90
- .option("--token <token>", "Runner/device credential to store outside the repository")
91
- .option("--root <path>", "Repository root", defaultRoot)
92
- .action(async (options, command) => {
93
- let repositoryLinkId = options.repositoryLink ?? `repo_${randomUUID()}`;
94
- let credential = options.token;
95
- if (options.pairingCode) {
96
- const pairing = await new ApiClient({
97
- apiUrl: options.apiUrl,
98
- accountId: options.account,
99
- ...(credential ? { token: credential } : {})
100
- }).consumePairingSession({
101
- projectId: options.project,
102
- pairingCode: options.pairingCode,
103
- repositoryLinkId,
104
- repoName: inferRepoName(options.root),
105
- repoFingerprint: createRepoFingerprint(options.account, options.project, repositoryLinkId),
106
- defaultBranch: options.defaultBranch
107
- });
108
- repositoryLinkId = pairing.repositoryLink.repositoryLinkId;
109
- credential = credential ?? pairing.token;
110
- console.log(`Pairing confirmed for ${pairing.repositoryLink.repoName}.`);
111
- }
112
- const filePath = await writeProjectLink(options.root, {
113
- amistioAccountId: options.account,
114
- amistioProjectId: options.project,
115
- repositoryLinkId,
116
- defaultBranch: options.defaultBranch,
117
- lastSyncedRevision: 0
118
- });
119
- if (credential) {
120
- await new LocalCredentialStore().set(credentialKey(options.account, options.project, repositoryLinkId), credential);
121
- }
122
- if (!options.pairingCode && apiUrlOverrideWasRequested(command)) {
123
- const session = await new ApiClient({
124
- apiUrl: options.apiUrl,
125
- accountId: options.account,
126
- ...(credential ? { token: credential } : {})
127
- }).createPairingSession(options.project);
128
- console.log(`Pairing code: ${session.pairingCode}`);
129
- console.log(`Expires at: ${session.expiresAt}`);
130
- }
131
- console.log(`Wrote non-secret project metadata to ${filePath}.`);
132
- });
133
- const sync = program.command("sync").description("Inspect or move project brain changes between the repo and Amistio");
134
- sync
135
- .command("status")
136
- .description("Show local sync status")
137
- .option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl())
138
- .option("--root <path>", "Repository root", defaultRoot)
139
- .action(async (options) => {
140
- const metadata = await readProjectLink(options.root);
141
- if (!metadata) {
142
- console.log("Repository is not paired. Run `amistio pair` first.");
143
- return;
144
- }
145
- const context = await loadPairedApiContext(options.root, options.apiUrl);
146
- const webDocuments = context ? await context.client.listBrainDocuments(metadata.amistioProjectId).then((result) => result.documents).catch(() => []) : [];
147
- const report = await collectSyncStatus(options.root, webDocuments);
148
- console.log(`Paired project: ${metadata.amistioProjectId}`);
149
- console.log(`Repository link: ${metadata.repositoryLinkId}`);
150
- console.log(`Last synced revision: ${metadata.lastSyncedRevision}`);
151
- console.log(`Sync status: ${report.status}`);
152
- console.log(`Clean: ${report.counts.clean}; Pending: ${report.counts.pending}; Dirty: ${report.counts.dirty}; Conflicted: ${report.counts.conflicted}`);
153
- for (const item of report.items.filter((entry) => entry.status !== "clean")) {
154
- console.log(`${item.status}: ${item.repoPath} - ${item.reason}`);
155
- }
156
- });
157
- sync
158
- .command("pull")
159
- .description("Pull approved web changes into the repository")
160
- .option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl())
161
- .option("--root <path>", "Repository root", defaultRoot)
162
- .action(async (options) => {
163
- const context = await loadPairedApiContext(options.root, options.apiUrl);
164
- if (!context) {
165
- console.log("Repository is not paired. Run `amistio pair` first.");
166
- return;
167
- }
168
- const { documents } = await context.client.listBrainDocuments(context.metadata.amistioProjectId);
169
- const approvedDocuments = documents.filter((document) => document.syncState === "approved" || document.syncState === "synced");
170
- const result = await materializeBrainDocuments(options.root, approvedDocuments);
171
- for (const conflict of result.conflicts) {
172
- console.log(`conflicted: ${conflict}`);
173
- }
174
- if (result.conflicts.length) {
175
- process.exitCode = 1;
176
- return;
177
- }
178
- const latestRevision = Math.max(context.metadata.lastSyncedRevision, ...approvedDocuments.map((document) => document.revision));
179
- if (latestRevision !== context.metadata.lastSyncedRevision) {
180
- await writeProjectLink(options.root, { ...context.metadata, lastSyncedRevision: latestRevision });
181
- }
182
- console.log(result.written.length ? `Pulled ${result.written.length} approved document${result.written.length === 1 ? "" : "s"}.` : "No approved web changes were pulled.");
183
- });
184
- sync
185
- .command("push")
186
- .description("Push local brain changes to Amistio for review")
187
- .option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl())
188
- .option("--root <path>", "Repository root", defaultRoot)
189
- .action(async (options) => {
190
- const context = await loadPairedApiContext(options.root, options.apiUrl);
191
- if (!context) {
192
- console.log("Repository is not paired. Run `amistio pair` first.");
193
- return;
194
- }
195
- const dirtyDocuments = await collectDirtyDocumentsForPush(options.root, context.metadata);
196
- if (!dirtyDocuments.length) {
197
- console.log("No local brain changes were pushed.");
198
- return;
199
- }
200
- const { documents } = await context.client.pushBrainDocuments(context.metadata.amistioProjectId, dirtyDocuments);
201
- const conflictedDocuments = documents.filter((document) => document.syncState === "conflicted" || document.status === "conflicted");
202
- if (conflictedDocuments.length) {
203
- for (const document of conflictedDocuments) {
204
- console.log(`conflicted: ${document.repoPath} - web and repository revisions both changed`);
205
- }
206
- process.exitCode = 1;
207
- return;
208
- }
209
- await materializeBrainDocuments(options.root, documents, { allowDirtyOverwrite: true });
210
- console.log(`Pushed ${documents.length} local document${documents.length === 1 ? "" : "s"} for web review.`);
211
- });
212
- const work = program.command("work").description("Inspect approved work items");
213
- work
214
- .command("list")
215
- .description("List queued work without claiming it")
216
- .option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl())
217
- .option("--root <path>", "Repository root", defaultRoot)
218
- .action(async (options) => {
219
- const context = await loadPairedApiContext(options.root, options.apiUrl);
220
- if (!context) {
221
- console.log("Repository is not paired. Run `amistio pair` first.");
222
- return;
223
- }
224
- const { workItems } = await context.client.listWorkItems(context.metadata.amistioProjectId);
225
- if (!workItems.length) {
226
- console.log("No work items are queued for this project.");
227
- return;
228
- }
229
- for (const item of workItems) {
230
- console.log(`${item.workItemId} [${item.status}] ${item.title}`);
231
- }
232
- });
233
- work
234
- .command("prompt")
235
- .description("Print or write an approved work prompt without claiming a runner lease")
236
- .argument("[workItemId]", "Work item ID. Defaults to the newest approved work item.")
237
- .option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl())
238
- .option("--root <path>", "Repository root", defaultRoot)
239
- .option("--out <path>", "Write the prompt to a file instead of stdout")
240
- .action(async (workItemId, options) => {
241
- const context = await loadPairedApiContext(options.root, options.apiUrl);
242
- if (!context) {
243
- console.log("Repository is not paired. Run `amistio pair` first.");
244
- return;
245
- }
246
- const { workItems } = await context.client.listWorkItems(context.metadata.amistioProjectId);
247
- const workItem = selectPromptWorkItem(workItems, workItemId);
248
- if (!workItem) {
249
- console.log(workItemId ? `No work item found for ${workItemId}.` : "No approved work item is ready for prompt export.");
250
- return;
251
- }
252
- const prompt = createWorkExecutionPrompt(workItem);
253
- if (options.out) {
254
- await writeFile(options.out, prompt, "utf8");
255
- console.log(`Wrote work prompt to ${options.out}.`);
256
- }
257
- else {
258
- console.log(prompt);
259
- }
260
- });
261
- program
262
- .command("tools")
263
- .description("List local AI coding tools that the Amistio CLI can use")
264
- .action(async () => {
265
- const tools = await detectLocalTools();
266
- for (const tool of tools) {
267
- const mode = tool.execution === "sdk" ? "sdk" : tool.execution === "command" ? "cli" : "--";
268
- console.log(`${tool.available ? "yes" : "no "} ${mode} ${tool.name} - ${tool.description}`);
269
- }
270
- console.log("custom - pass --tool-command to use any other local runner command.");
271
- });
272
- program
273
- .command("orchestrate")
274
- .description("Update the Amistio control plane through a user-installed local AI tool")
275
- .argument("[goal...]", "Goal or next-step instruction for the orchestration pass")
276
- .option("--root <path>", "Repository root", defaultRoot)
277
- .option("--tool <name>", "Local tool to use: auto, none, opencode, claude, codex, copilot, gemini, aider, cursor-agent", "auto")
278
- .option("--tool-command <command>", "Custom local command. Use {promptFile} and {root} placeholders when supported")
279
- .option("--session <policy>", "Tool session policy: auto, new, continue:<toolSessionId>, or none", "auto")
280
- .option("--prompt-out <path>", "Write the generated orchestration prompt to a file before running")
281
- .option("--dry-run", "Print the generated orchestration prompt without running a tool")
282
- .option("--no-stream", "Capture local tool output instead of streaming it")
283
- .action(async (goalParts, options) => {
284
- const goal = goalParts?.join(" ").trim() || "Review the current repository state and update the Amistio control plane with the next useful orchestration steps.";
285
- const prompt = await createOrchestrationPrompt({ rootDir: options.root, goal });
286
- if (options.promptOut) {
287
- const promptPath = await writePromptFile(options.promptOut, prompt);
288
- console.log(`Wrote orchestration prompt to ${promptPath}.`);
289
- }
290
- if (options.dryRun || options.tool === "none") {
291
- console.log(prompt);
292
- return;
293
- }
294
- const sessionPolicy = normalizeSessionPolicy(options.session);
295
- const preview = await createToolRunPreview({ rootDir: options.root, prompt, tool: options.tool, ...(options.toolCommand ? { toolCommand: options.toolCommand } : {}) });
296
- console.log(`Running ${preview.toolName}: ${preview.displayCommand}`);
297
- const result = await runLocalTool({
298
- rootDir: options.root,
299
- prompt,
300
- tool: options.tool,
301
- ...(options.toolCommand ? { toolCommand: options.toolCommand } : {}),
302
- streamOutput: options.stream,
303
- ...(sessionPolicy === "none" ? {} : { session: { toolSessionId: `local_orchestration_${randomUUID()}`, policy: sessionPolicy, decision: localSessionDecision(sessionPolicy) } })
304
- });
305
- if (!options.stream && result.stdout.trim()) {
306
- console.log(result.stdout.trim());
8
+
9
+ // ../shared/src/schemas.ts
10
+ import { z } from "zod";
11
+ var isoDateTimeSchema = z.string().datetime({ offset: true });
12
+ var itemTypeSchema = z.enum([
13
+ "account",
14
+ "accountUser",
15
+ "accountInvite",
16
+ "accountSettings",
17
+ "user",
18
+ "project",
19
+ "repositoryLink",
20
+ "brainDocument",
21
+ "generatedDraft",
22
+ "syncCursor",
23
+ "syncConflict",
24
+ "workItem",
25
+ "runnerHeartbeat",
26
+ "runnerExecutionLog",
27
+ "toolSession",
28
+ "activityEvent",
29
+ "pairingSession"
30
+ ]);
31
+ var documentTypeSchema = z.enum([
32
+ "architecture",
33
+ "context",
34
+ "decision",
35
+ "feature",
36
+ "memory",
37
+ "plan",
38
+ "prompt",
39
+ "workflow"
40
+ ]);
41
+ var syncStateSchema = z.enum([
42
+ "draft",
43
+ "approved",
44
+ "synced",
45
+ "dirtyInRepo",
46
+ "dirtyInWeb",
47
+ "conflicted",
48
+ "archived"
49
+ ]);
50
+ var workStatusSchema = z.enum([
51
+ "drafted",
52
+ "approved",
53
+ "synced",
54
+ "running",
55
+ "blocked",
56
+ "completed",
57
+ "changesRequested",
58
+ "failed",
59
+ "conflicted"
60
+ ]);
61
+ var sourceSchema = z.enum(["web", "repo", "generated", "runner"]);
62
+ var executionModeSchema = z.enum(["localRunner", "cloudSandbox"]);
63
+ var workKindSchema = z.enum(["brainGeneration", "implementation"]);
64
+ var generatedDraftStatusSchema = z.enum(["queued", "generating", "reviewing", "approved", "rejected", "changesRequested", "failed"]);
65
+ var generatedBrainArtifactSchema = z.object({
66
+ documentType: documentTypeSchema,
67
+ title: z.string().trim().min(1),
68
+ repoPath: z.string().trim().min(1),
69
+ content: z.string().min(1)
70
+ });
71
+ var brainGenerationResultSchema = z.object({
72
+ artifacts: z.array(generatedBrainArtifactSchema).min(1),
73
+ summary: z.string().optional()
74
+ });
75
+ var sessionPolicySchema = z.union([z.enum(["auto", "new", "none"]), z.string().regex(/^continue:[A-Za-z0-9_.:-]+$/)]);
76
+ var sessionDecisionSchema = z.enum(["created", "continued", "forcedNew", "forcedContinue", "notSupported", "skipped"]);
77
+ var toolSessionStatusSchema = z.enum(["open", "active", "closed", "archived", "blocked", "unavailable"]);
78
+ var sessionResumabilityScopeSchema = z.enum(["none", "localMachine", "repository", "account", "providerCloud"]);
79
+ var repositoryLinkSourceSchema = z.enum(["web", "cli"]);
80
+ var repositoryCloneStatusSchema = z.enum(["notCloned", "cloned", "validated", "failed"]);
81
+ var baseItemSchema = z.object({
82
+ id: z.string().min(1),
83
+ type: itemTypeSchema,
84
+ schemaVersion: z.number().int().positive().default(1),
85
+ accountId: z.string().min(1),
86
+ projectId: z.string().min(1).optional(),
87
+ createdAt: isoDateTimeSchema,
88
+ updatedAt: isoDateTimeSchema
89
+ });
90
+ var accountItemSchema = baseItemSchema.extend({
91
+ type: z.literal("account"),
92
+ name: z.string().min(1),
93
+ slug: z.string().min(1)
94
+ });
95
+ var userItemSchema = baseItemSchema.extend({
96
+ type: z.literal("user"),
97
+ userId: z.string().min(1),
98
+ email: z.string().email(),
99
+ firstName: z.string(),
100
+ lastName: z.string()
101
+ });
102
+ var projectItemSchema = baseItemSchema.extend({
103
+ type: z.literal("project"),
104
+ projectId: z.string().min(1),
105
+ name: z.string().min(1),
106
+ slug: z.string().min(1),
107
+ description: z.string().optional()
108
+ });
109
+ var repositoryLinkItemSchema = baseItemSchema.extend({
110
+ type: z.literal("repositoryLink"),
111
+ projectId: z.string().min(1),
112
+ repositoryLinkId: z.string().min(1),
113
+ repoName: z.string().min(1),
114
+ repoFingerprint: z.string().min(1).optional(),
115
+ defaultBranch: z.string().min(1),
116
+ cloneUrl: z.string().min(1).optional(),
117
+ provider: z.string().min(1).optional(),
118
+ repoOwner: z.string().min(1).optional(),
119
+ repoFullName: z.string().min(1).optional(),
120
+ linkedByUserId: z.string().min(1).optional(),
121
+ linkSource: repositoryLinkSourceSchema.optional(),
122
+ cloneStatus: repositoryCloneStatusSchema.optional(),
123
+ lastValidatedAt: isoDateTimeSchema.optional(),
124
+ status: z.enum(["active", "revoked"]).default("active")
125
+ });
126
+ var brainDocumentItemSchema = baseItemSchema.extend({
127
+ type: z.literal("brainDocument"),
128
+ projectId: z.string().min(1),
129
+ documentId: z.string().min(1),
130
+ documentType: documentTypeSchema,
131
+ title: z.string().min(1),
132
+ status: z.string().min(1),
133
+ repoPath: z.string().min(1),
134
+ content: z.string(),
135
+ contentHash: z.string().min(1),
136
+ frontmatter: z.record(z.string(), z.unknown()).default({}),
137
+ revision: z.number().int().nonnegative(),
138
+ approvedRevision: z.number().int().nonnegative().optional(),
139
+ source: sourceSchema,
140
+ syncState: syncStateSchema
141
+ });
142
+ var generatedDraftItemSchema = baseItemSchema.extend({
143
+ type: z.literal("generatedDraft"),
144
+ projectId: z.string().min(1),
145
+ wishId: z.string().min(1),
146
+ title: z.string().min(1),
147
+ status: generatedDraftStatusSchema,
148
+ documentIds: z.array(z.string()).default([]),
149
+ wish: z.string().min(1).optional(),
150
+ requestedByUserId: z.string().min(1).optional(),
151
+ workItemId: z.string().min(1).optional(),
152
+ runnerId: z.string().min(1).optional(),
153
+ generationAttempt: z.number().int().nonnegative().optional(),
154
+ lastGenerationError: z.string().optional()
155
+ });
156
+ var syncCursorItemSchema = baseItemSchema.extend({
157
+ type: z.literal("syncCursor"),
158
+ projectId: z.string().min(1),
159
+ repositoryLinkId: z.string().min(1),
160
+ lastSyncedRevision: z.number().int().nonnegative(),
161
+ lastSyncedAt: isoDateTimeSchema.optional()
162
+ });
163
+ var syncConflictItemSchema = baseItemSchema.extend({
164
+ type: z.literal("syncConflict"),
165
+ projectId: z.string().min(1),
166
+ conflictId: z.string().min(1),
167
+ documentId: z.string().min(1),
168
+ repoPath: z.string().min(1),
169
+ webRevision: z.number().int().nonnegative(),
170
+ repoContentHash: z.string().min(1),
171
+ status: z.enum(["open", "resolved", "ignored"])
172
+ });
173
+ var workItemSchema = baseItemSchema.extend({
174
+ type: z.literal("workItem"),
175
+ projectId: z.string().min(1),
176
+ workItemId: z.string().min(1),
177
+ workKind: workKindSchema.optional(),
178
+ status: workStatusSchema,
179
+ executionMode: executionModeSchema.optional(),
180
+ title: z.string().min(1),
181
+ requestedBy: z.string().min(1),
182
+ approvedBy: z.string().min(1).optional(),
183
+ sourceWish: z.string().min(1).optional(),
184
+ generatedDraftId: z.string().min(1).optional(),
185
+ claimedByRunnerId: z.string().optional(),
186
+ leaseExpiresAt: isoDateTimeSchema.optional(),
187
+ attempt: z.number().int().nonnegative().default(0),
188
+ idempotencyKey: z.string().min(1),
189
+ sessionPolicy: sessionPolicySchema.optional(),
190
+ sessionGroupKey: z.string().min(1).optional(),
191
+ toolSessionId: z.string().min(1).optional(),
192
+ sessionDecision: sessionDecisionSchema.optional(),
193
+ sessionDecisionReason: z.string().min(1).optional(),
194
+ lastStatusMessage: z.string().optional(),
195
+ lastStatusAt: isoDateTimeSchema
196
+ });
197
+ var runnerHeartbeatItemSchema = baseItemSchema.extend({
198
+ type: z.literal("runnerHeartbeat"),
199
+ projectId: z.string().min(1),
200
+ runnerId: z.string().min(1),
201
+ repositoryLinkId: z.string().min(1),
202
+ status: z.enum(["online", "offline", "running", "blocked"]),
203
+ version: z.string().optional(),
204
+ lastSeenAt: isoDateTimeSchema
205
+ });
206
+ var runnerExecutionLogItemSchema = baseItemSchema.extend({
207
+ type: z.literal("runnerExecutionLog"),
208
+ projectId: z.string().min(1),
209
+ runnerLogId: z.string().min(1),
210
+ runnerId: z.string().min(1),
211
+ workKind: workKindSchema.optional(),
212
+ workItemId: z.string().min(1).optional(),
213
+ workTitle: z.string().min(1).optional(),
214
+ initiatedBy: z.string().min(1).optional(),
215
+ approvedBy: z.string().min(1).optional(),
216
+ status: z.enum(["claimed", "running", "completed", "failed", "blocked", "idle"]),
217
+ executionMode: executionModeSchema.optional(),
218
+ tool: z.string().min(1).optional(),
219
+ model: z.string().min(1).optional(),
220
+ attempt: z.number().int().nonnegative().optional(),
221
+ startedAt: isoDateTimeSchema.optional(),
222
+ endedAt: isoDateTimeSchema.optional(),
223
+ durationMs: z.number().int().nonnegative().optional(),
224
+ costUsd: z.number().nonnegative().optional(),
225
+ tokensIn: z.number().int().nonnegative().optional(),
226
+ tokensOut: z.number().int().nonnegative().optional(),
227
+ sessionPolicy: sessionPolicySchema.optional(),
228
+ sessionGroupKey: z.string().min(1).optional(),
229
+ toolSessionId: z.string().min(1).optional(),
230
+ sessionDecision: sessionDecisionSchema.optional(),
231
+ sessionDecisionReason: z.string().min(1).optional(),
232
+ message: z.string().optional(),
233
+ error: z.string().optional()
234
+ });
235
+ var toolSessionItemSchema = baseItemSchema.extend({
236
+ type: z.literal("toolSession"),
237
+ projectId: z.string().min(1),
238
+ toolSessionId: z.string().min(1),
239
+ repositoryLinkId: z.string().min(1).optional(),
240
+ tool: z.string().min(1),
241
+ provider: z.string().min(1).optional(),
242
+ model: z.string().min(1).optional(),
243
+ resumabilityScope: sessionResumabilityScopeSchema,
244
+ title: z.string().min(1),
245
+ summary: z.string().optional(),
246
+ tags: z.array(z.string().min(1)).default([]),
247
+ relatedDocumentIds: z.array(z.string().min(1)).default([]),
248
+ status: toolSessionStatusSchema,
249
+ createdByUserId: z.string().min(1).optional(),
250
+ runnerId: z.string().min(1).optional(),
251
+ lastWorkItemId: z.string().min(1).optional(),
252
+ lastActivityAt: isoDateTimeSchema,
253
+ messageCount: z.number().int().nonnegative().optional(),
254
+ estimatedInputTokens: z.number().int().nonnegative().optional(),
255
+ estimatedOutputTokens: z.number().int().nonnegative().optional(),
256
+ costUsd: z.number().nonnegative().optional(),
257
+ sessionGroupKey: z.string().min(1).optional(),
258
+ reusePolicy: sessionPolicySchema.optional(),
259
+ closedReason: z.string().optional()
260
+ });
261
+ var pairingSessionItemSchema = baseItemSchema.extend({
262
+ type: z.literal("pairingSession"),
263
+ pairingCodeHash: z.string().min(1),
264
+ projectId: z.string().min(1),
265
+ createdByUserId: z.string().min(1),
266
+ expiresAt: isoDateTimeSchema,
267
+ status: z.enum(["pending", "confirmed", "expired", "revoked"])
268
+ });
269
+ var projectItemUnionSchema = z.discriminatedUnion("type", [
270
+ projectItemSchema,
271
+ repositoryLinkItemSchema,
272
+ brainDocumentItemSchema,
273
+ generatedDraftItemSchema,
274
+ syncCursorItemSchema,
275
+ syncConflictItemSchema,
276
+ workItemSchema,
277
+ runnerHeartbeatItemSchema,
278
+ runnerExecutionLogItemSchema,
279
+ toolSessionItemSchema
280
+ ]);
281
+
282
+ // ../shared/src/api-url.ts
283
+ var AMISTIO_API_URL_ENV = "AMISTIO_API_URL";
284
+ var OFFICIAL_AMISTIO_WEB_ORIGIN = "https://www.amistio.com";
285
+ var API_PATH = "/api";
286
+ var officialAmistioApiUrl = normalizeAmistioApiUrl(new URL(API_PATH, OFFICIAL_AMISTIO_WEB_ORIGIN).toString());
287
+ function normalizeAmistioApiUrl(value) {
288
+ const parsed = new URL(value);
289
+ parsed.hash = "";
290
+ parsed.search = "";
291
+ if (parsed.pathname.length > 1 && parsed.pathname.endsWith("/")) {
292
+ parsed.pathname = parsed.pathname.slice(0, -1);
293
+ }
294
+ return parsed.toString().replace(/\/$/, "");
295
+ }
296
+ function isOfficialAmistioApiUrl(value) {
297
+ try {
298
+ return normalizeAmistioApiUrl(value) === officialAmistioApiUrl;
299
+ } catch {
300
+ return false;
301
+ }
302
+ }
303
+
304
+ // ../shared/src/repo-metadata.ts
305
+ import { z as z2 } from "zod";
306
+ var repoLinkMetadataSchema = z2.object({
307
+ amistioProjectId: z2.string().min(1),
308
+ amistioAccountId: z2.string().min(1),
309
+ repositoryLinkId: z2.string().min(1),
310
+ defaultBranch: z2.string().min(1).default("main"),
311
+ lastSyncedRevision: z2.coerce.number().int().nonnegative().default(0)
312
+ });
313
+ var syncedDocumentFrontmatterSchema = z2.object({
314
+ amistioDocumentId: z2.string().min(1),
315
+ amistioDocumentType: z2.string().min(1),
316
+ amistioRevision: z2.coerce.number().int().nonnegative(),
317
+ amistioContentHash: z2.string().min(1),
318
+ status: z2.string().optional()
319
+ });
320
+ function createProjectLinkMarkdown(metadata) {
321
+ return [
322
+ "---",
323
+ `amistioProjectId: ${metadata.amistioProjectId}`,
324
+ `amistioAccountId: ${metadata.amistioAccountId}`,
325
+ `repositoryLinkId: ${metadata.repositoryLinkId}`,
326
+ `defaultBranch: ${metadata.defaultBranch}`,
327
+ `lastSyncedRevision: ${metadata.lastSyncedRevision}`,
328
+ "---",
329
+ "",
330
+ "# Amistio Project Link",
331
+ "",
332
+ "This repository is paired with an Amistio project. Credentials are stored outside the repository on each user's machine.",
333
+ ""
334
+ ].join("\n");
335
+ }
336
+
337
+ // ../shared/src/repository-link.ts
338
+ var scpStyleSshPattern = /^([A-Za-z0-9._-]+)@([A-Za-z0-9.-]+):(.+)$/;
339
+ function parseRepositoryCloneUrl(input) {
340
+ const cloneUrl = input.trim();
341
+ if (!cloneUrl) {
342
+ throw new Error("Repository URL is required.");
343
+ }
344
+ if (isLocalPathLike(cloneUrl)) {
345
+ throw new Error("Use an HTTPS or SSH repository clone URL, not a local path.");
346
+ }
347
+ const scpMatch = scpStyleSshPattern.exec(cloneUrl);
348
+ if (scpMatch) {
349
+ const host = scpMatch[2];
350
+ const rawPath = scpMatch[3];
351
+ if (!host || !rawPath) {
352
+ throw new Error("Repository URL must include an owner and repository name.");
307
353
  }
308
- if (!options.stream && result.stderr.trim()) {
309
- console.error(result.stderr.trim());
354
+ return buildParsedRepositoryCloneUrl({ cloneUrl, protocol: "ssh", host, rawPath });
355
+ }
356
+ let url;
357
+ try {
358
+ url = new URL(cloneUrl);
359
+ } catch {
360
+ throw new Error("Repository URL must be a valid HTTPS or SSH clone URL.");
361
+ }
362
+ if (url.protocol === "file:") {
363
+ throw new Error("Use an HTTPS or SSH repository clone URL, not a local path.");
364
+ }
365
+ if (url.protocol !== "https:" && url.protocol !== "ssh:") {
366
+ throw new Error("Repository URL must use HTTPS or SSH.");
367
+ }
368
+ if (url.protocol === "https:" && (url.username || url.password)) {
369
+ throw new Error("Repository URL must not include embedded credentials.");
370
+ }
371
+ if (url.protocol === "ssh:" && url.password) {
372
+ throw new Error("Repository SSH URL must not include a password.");
373
+ }
374
+ if (url.search || url.hash) {
375
+ throw new Error("Repository URL must not include query strings or fragments.");
376
+ }
377
+ return buildParsedRepositoryCloneUrl({
378
+ cloneUrl,
379
+ protocol: url.protocol === "https:" ? "https" : "ssh",
380
+ host: url.hostname,
381
+ rawPath: url.pathname
382
+ });
383
+ }
384
+ function repositoryCloneUrlsMatch(firstUrl, secondUrl) {
385
+ try {
386
+ return parseRepositoryCloneUrl(firstUrl).normalizedKey === parseRepositoryCloneUrl(secondUrl).normalizedKey;
387
+ } catch {
388
+ return false;
389
+ }
390
+ }
391
+ function buildParsedRepositoryCloneUrl({ cloneUrl, host, protocol, rawPath }) {
392
+ const normalizedHost = host.trim().toLowerCase();
393
+ if (!normalizedHost) {
394
+ throw new Error("Repository URL must include a host.");
395
+ }
396
+ const pathWithoutSlash = normalizeRepoPath(rawPath);
397
+ const segments = pathWithoutSlash.split("/").filter(Boolean);
398
+ if (segments.length < 2) {
399
+ throw new Error("Repository URL must include an owner and repository name.");
400
+ }
401
+ const repoSegment = segments[segments.length - 1];
402
+ const ownerSegment = segments[segments.length - 2];
403
+ const repoName = stripGitSuffix(repoSegment ?? "");
404
+ if (!repoName || !ownerSegment) {
405
+ throw new Error("Repository URL must include an owner and repository name.");
406
+ }
407
+ const normalizedSegments = [...segments.slice(0, -1), repoName].map((segment) => segment.toLowerCase());
408
+ const provider = inferRepositoryProvider(normalizedHost);
409
+ const repoOwner = ownerSegment;
410
+ const repoFullName = `${repoOwner}/${repoName}`;
411
+ return {
412
+ cloneUrl,
413
+ protocol,
414
+ host: normalizedHost,
415
+ path: pathWithoutSlash,
416
+ repoName,
417
+ normalizedKey: `${normalizedHost}/${normalizedSegments.join("/")}`,
418
+ ...provider ? { provider } : {},
419
+ repoOwner,
420
+ repoFullName
421
+ };
422
+ }
423
+ function normalizeRepoPath(rawPath) {
424
+ const withoutLeadingSlash = rawPath.trim().replace(/^\/+/, "").replace(/\/+$/, "");
425
+ if (!withoutLeadingSlash || withoutLeadingSlash.includes("..")) {
426
+ throw new Error("Repository URL path is not supported.");
427
+ }
428
+ return withoutLeadingSlash;
429
+ }
430
+ function stripGitSuffix(value) {
431
+ return value.replace(/\.git$/i, "");
432
+ }
433
+ function inferRepositoryProvider(host) {
434
+ if (host === "github.com" || host.endsWith(".github.com")) return "github";
435
+ if (host === "gitlab.com" || host.endsWith(".gitlab.com")) return "gitlab";
436
+ if (host === "bitbucket.org" || host.endsWith(".bitbucket.org")) return "bitbucket";
437
+ if (host.endsWith("dev.azure.com") || host.endsWith("visualstudio.com")) return "azureDevOps";
438
+ return "other";
439
+ }
440
+ function isLocalPathLike(value) {
441
+ return value.startsWith("/") || value.startsWith("./") || value.startsWith("../") || value.startsWith("~/") || /^[A-Za-z]:[\\/]/.test(value);
442
+ }
443
+
444
+ // ../shared/src/sync.ts
445
+ import { createHash } from "node:crypto";
446
+ function sha256ContentHash(content) {
447
+ return createHash("sha256").update(content, "utf8").digest("hex");
448
+ }
449
+ function decideSyncState(state) {
450
+ const webChanged = Boolean(state.webHash && state.webHash !== state.lastSyncedHash);
451
+ const repoChanged = Boolean(state.repoHash && state.repoHash !== state.lastSyncedHash);
452
+ if (webChanged && repoChanged && state.webHash !== state.repoHash) {
453
+ return "conflict";
454
+ }
455
+ if (webChanged) {
456
+ return "pullWeb";
457
+ }
458
+ if (repoChanged) {
459
+ return "pushRepo";
460
+ }
461
+ return "clean";
462
+ }
463
+
464
+ // src/bootstrap.ts
465
+ import { execFile } from "node:child_process";
466
+ import { mkdir, readdir, stat } from "node:fs/promises";
467
+ import path from "node:path";
468
+ import { promisify } from "node:util";
469
+ var execFileAsync = promisify(execFile);
470
+ function buildGitCloneArgs(repoUrl, targetDir) {
471
+ return ["clone", repoUrl, targetDir];
472
+ }
473
+ async function cloneOrValidateRepository({ repoUrl, targetDir }) {
474
+ const resolvedTarget = path.resolve(targetDir);
475
+ const targetStat = await stat(resolvedTarget).catch((error) => {
476
+ if (isNotFoundError(error)) return void 0;
477
+ throw error;
478
+ });
479
+ if (!targetStat) {
480
+ await mkdir(path.dirname(resolvedTarget), { recursive: true });
481
+ await runGit(buildGitCloneArgs(repoUrl, resolvedTarget), "clone");
482
+ return { status: "cloned", targetDir: resolvedTarget };
483
+ }
484
+ if (!targetStat.isDirectory()) {
485
+ throw new Error("Bootstrap target exists and is not a directory.");
486
+ }
487
+ if (await isGitCheckout(resolvedTarget)) {
488
+ const originUrl = await runGit(["-C", resolvedTarget, "remote", "get-url", "origin"], "remote");
489
+ if (!repositoryCloneUrlsMatch(repoUrl, originUrl)) {
490
+ throw new Error("Bootstrap target is a Git checkout for a different repository.");
310
491
  }
311
- if (result.exitCode !== 0) {
312
- process.exitCode = result.exitCode;
492
+ return { status: "validated", targetDir: resolvedTarget, originUrl };
493
+ }
494
+ const entries = await readdir(resolvedTarget);
495
+ if (entries.length > 0) {
496
+ throw new Error("Bootstrap target exists, is not empty, and is not the linked repository checkout.");
497
+ }
498
+ await runGit(buildGitCloneArgs(repoUrl, resolvedTarget), "clone");
499
+ return { status: "cloned", targetDir: resolvedTarget };
500
+ }
501
+ async function isGitCheckout(targetDir) {
502
+ try {
503
+ const result = await runGit(["-C", targetDir, "rev-parse", "--is-inside-work-tree"], "rev-parse");
504
+ return result.trim() === "true";
505
+ } catch {
506
+ return false;
507
+ }
508
+ }
509
+ async function runGit(args, operation) {
510
+ try {
511
+ const { stdout } = await execFileAsync("git", args, { maxBuffer: 1024 * 1024 });
512
+ return stdout.trim();
513
+ } catch {
514
+ if (operation === "clone") {
515
+ throw new Error("git clone failed. Confirm local Git credentials can access the repository.");
313
516
  }
314
- });
315
- program
316
- .command("run")
317
- .description("Claim and run approved Amistio work locally")
318
- .option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl())
319
- .option("--runner-id <runnerId>", "Stable runner ID", `runner_${randomUUID()}`)
320
- .option("--root <path>", "Repository root", defaultRoot)
321
- .option("--tool <name>", "Local tool to use: auto, none, opencode, claude, codex, copilot, gemini, aider, cursor-agent", "auto")
322
- .option("--tool-command <command>", "Custom local command. Use {promptFile} and {root} placeholders when supported")
323
- .option("--session <policy>", "Tool session policy: auto, new, continue:<toolSessionId>, or none", "auto")
324
- .option("--dry-run", "Claim work and print the generated execution prompt without running a tool")
325
- .option("--watch", "Keep polling for approved work until stopped")
326
- .option("--interval-seconds <seconds>", "Polling interval for --watch", parsePositiveInteger, 10)
327
- .option("--max-iterations <count>", "Stop watch mode after this many polling attempts", parsePositiveInteger)
328
- .option("--no-stream", "Capture local tool output instead of streaming it")
329
- .action(async (options) => {
330
- const context = await loadPairedApiContext(options.root, options.apiUrl);
331
- if (!context) {
332
- console.log("Repository is not paired. Run `amistio pair` first.");
333
- return;
334
- }
335
- if (!context.token) {
336
- console.log("No local runner credential found. Run `amistio pair --pairing-code <code>` to store this machine credential.");
337
- process.exitCode = 1;
338
- return;
339
- }
340
- if (options.watch) {
341
- console.log(`Runner ${options.runnerId} is watching ${context.metadata.amistioProjectId} every ${options.intervalSeconds}s. Press Ctrl+C to stop.`);
342
- }
343
- let iterations = 0;
344
- while (true) {
345
- iterations += 1;
346
- const result = await runNextWorkItem({
347
- apiClient: context.client,
348
- projectId: context.metadata.amistioProjectId,
349
- repositoryLinkId: context.metadata.repositoryLinkId,
350
- runnerId: options.runnerId,
351
- root: options.root,
352
- sessionPolicy: normalizeSessionPolicy(options.session),
353
- tool: options.tool,
354
- ...(options.toolCommand ? { toolCommand: options.toolCommand } : {}),
355
- dryRun: Boolean(options.dryRun),
356
- stream: options.stream
357
- });
358
- if (!options.watch || options.dryRun) {
359
- if (result.exitCode !== 0) {
360
- process.exitCode = result.exitCode;
361
- }
362
- return;
363
- }
364
- if (options.maxIterations !== undefined && iterations >= options.maxIterations) {
365
- console.log(`Runner stopped after ${iterations} polling attempt${iterations === 1 ? "" : "s"}.`);
366
- return;
367
- }
368
- if (result.status === "idle") {
369
- console.log(`No approved work item is available. Checking again in ${options.intervalSeconds}s.`);
370
- }
371
- await delay(options.intervalSeconds * 1000);
517
+ if (operation === "remote") {
518
+ throw new Error("Could not read the target checkout origin remote.");
372
519
  }
373
- });
374
- async function runNextWorkItem({ apiClient, dryRun, projectId, repositoryLinkId, root, runnerId, sessionPolicy, stream, tool, toolCommand }) {
375
- await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", CLI_VERSION);
376
- const result = await apiClient.claimWork(projectId, runnerId, repositoryLinkId);
377
- if (!result.workItem) {
378
- console.log("No approved work item is available.");
379
- return { status: "idle", exitCode: 0 };
380
- }
381
- await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "running", CLI_VERSION);
382
- const prompt = createWorkExecutionPrompt(result.workItem);
383
- if (dryRun || tool === "none") {
384
- console.log(prompt);
385
- await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", CLI_VERSION);
386
- return { status: "preview", exitCode: 0 };
387
- }
388
- const preview = await createToolRunPreview({ rootDir: root, prompt, tool, ...(toolCommand ? { toolCommand } : {}) });
389
- const sessionContext = await prepareToolSession({
390
- apiClient,
391
- projectId,
392
- repositoryLinkId,
393
- runnerId,
394
- sessionPolicy: result.workItem.sessionPolicy ?? sessionPolicy,
395
- toolName: preview.toolName,
396
- supportsSessionReuse: preview.supportsSessionReuse,
397
- resumabilityScope: preview.resumabilityScope,
398
- workItem: result.workItem
520
+ throw new Error("Could not inspect the target Git checkout.");
521
+ }
522
+ }
523
+ function isNotFoundError(error) {
524
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
525
+ }
526
+
527
+ // src/credential-store.ts
528
+ import { chmod, mkdir as mkdir2, readFile, writeFile } from "node:fs/promises";
529
+ import os from "node:os";
530
+ import path2 from "node:path";
531
+ var LocalCredentialStore = class {
532
+ constructor(filePath = path2.join(os.homedir(), ".config", "amistio", "credentials.json")) {
533
+ this.filePath = filePath;
534
+ }
535
+ filePath;
536
+ async set(key, value) {
537
+ const data = await this.read();
538
+ data.credentials[key] = value;
539
+ await mkdir2(path2.dirname(this.filePath), { recursive: true });
540
+ await writeFile(this.filePath, JSON.stringify(data, null, 2), { encoding: "utf8", mode: 384 });
541
+ await chmod(this.filePath, 384);
542
+ }
543
+ async get(key) {
544
+ const data = await this.read();
545
+ return data.credentials[key];
546
+ }
547
+ async read() {
548
+ try {
549
+ return JSON.parse(await readFile(this.filePath, "utf8"));
550
+ } catch {
551
+ return { credentials: {} };
552
+ }
553
+ }
554
+ };
555
+ function credentialKey(accountId, projectId, repositoryLinkId) {
556
+ return `${accountId}:${projectId}:${repositoryLinkId}`;
557
+ }
558
+
559
+ // src/control-plane.ts
560
+ import { mkdir as mkdir3, readFile as readFile2, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
561
+ import path3 from "node:path";
562
+ var controlPlaneFolders = [
563
+ "architecture",
564
+ "context",
565
+ "decisions",
566
+ "features",
567
+ "memory",
568
+ "plans",
569
+ path3.join("prompts", "shared"),
570
+ "workflows"
571
+ ];
572
+ async function initControlPlane(rootDir) {
573
+ const created = [];
574
+ for (const folder of controlPlaneFolders) {
575
+ const fullPath = path3.join(rootDir, folder);
576
+ if (!await exists(fullPath)) {
577
+ await mkdir3(fullPath, { recursive: true });
578
+ created.push(folder);
579
+ }
580
+ }
581
+ await writeIfMissing(
582
+ path3.join(rootDir, "AGENTS.md"),
583
+ "# Agents\n\nRoot control-plane folders are the project brain. Keep architecture, context, decisions, features, memory, plans, prompts, and workflows in sync with product intent.\n"
584
+ );
585
+ await writeIfMissing(path3.join(rootDir, "context", "product.md"), "# Product Context\n\nDescribe the product direction here.\n");
586
+ await writeIfMissing(path3.join(rootDir, "architecture", "overview.md"), "# Architecture Overview\n\nDescribe the system shape here.\n");
587
+ return created;
588
+ }
589
+ async function inspectControlPlane(rootDir) {
590
+ const present = [];
591
+ const missing = [];
592
+ for (const folder of controlPlaneFolders) {
593
+ if (await exists(path3.join(rootDir, folder))) {
594
+ present.push(folder);
595
+ } else {
596
+ missing.push(folder);
597
+ }
598
+ }
599
+ return { present, missing };
600
+ }
601
+ async function writeProjectLink(rootDir, metadata) {
602
+ const contextDir = path3.join(rootDir, "context");
603
+ await mkdir3(contextDir, { recursive: true });
604
+ const filePath = path3.join(contextDir, "amistio-project.md");
605
+ await writeFile2(filePath, createProjectLinkMarkdown(metadata), "utf8");
606
+ return filePath;
607
+ }
608
+ async function readProjectLink(rootDir) {
609
+ const filePath = path3.join(rootDir, "context", "amistio-project.md");
610
+ if (!await exists(filePath)) {
611
+ return void 0;
612
+ }
613
+ const content = await readFile2(filePath, "utf8");
614
+ const frontmatter = parseFrontmatter(content);
615
+ return repoLinkMetadataSchema.parse(frontmatter);
616
+ }
617
+ function parseFrontmatter(content) {
618
+ if (!content.startsWith("---\n")) {
619
+ return {};
620
+ }
621
+ const end = content.indexOf("\n---", 4);
622
+ if (end === -1) {
623
+ return {};
624
+ }
625
+ const lines = content.slice(4, end).split("\n");
626
+ return Object.fromEntries(
627
+ lines.map((line) => {
628
+ const separator = line.indexOf(":");
629
+ if (separator === -1) {
630
+ return void 0;
631
+ }
632
+ return [line.slice(0, separator).trim(), line.slice(separator + 1).trim()];
633
+ }).filter((entry) => Boolean(entry))
634
+ );
635
+ }
636
+ async function writeIfMissing(filePath, content) {
637
+ if (await exists(filePath)) {
638
+ return;
639
+ }
640
+ await writeFile2(filePath, content, "utf8");
641
+ }
642
+ async function exists(filePath) {
643
+ try {
644
+ await stat2(filePath);
645
+ return true;
646
+ } catch {
647
+ return false;
648
+ }
649
+ }
650
+
651
+ // src/api-client.ts
652
+ import { z as z3 } from "zod";
653
+ var ApiClient = class {
654
+ constructor(options) {
655
+ this.options = options;
656
+ }
657
+ options;
658
+ async createPairingSession(projectId) {
659
+ return this.request(
660
+ "pairing-sessions",
661
+ z3.object({ pairingCode: z3.string(), expiresAt: z3.string() }),
662
+ {
663
+ method: "POST",
664
+ body: JSON.stringify({ projectId })
665
+ }
666
+ );
667
+ }
668
+ async consumePairingSession(input) {
669
+ return this.request(
670
+ "pairing-sessions",
671
+ z3.object({ repositoryLink: repositoryLinkItemSchema, token: z3.string().min(1) }),
672
+ {
673
+ method: "PATCH",
674
+ body: JSON.stringify(input)
675
+ }
676
+ );
677
+ }
678
+ async claimWork(projectId, runnerId, repositoryLinkId, leaseSeconds = 300) {
679
+ return this.request(
680
+ `/projects/${projectId}/work-items/claim`,
681
+ z3.object({ workItem: workItemSchema.optional() }).transform(({ workItem }) => ({ workItem })),
682
+ {
683
+ method: "POST",
684
+ body: JSON.stringify({ runnerId, repositoryLinkId, leaseSeconds })
685
+ }
686
+ );
687
+ }
688
+ async listWorkItems(projectId) {
689
+ return this.request(
690
+ `/projects/${projectId}/work-items`,
691
+ z3.object({ workItems: z3.array(workItemSchema) }),
692
+ { method: "GET" }
693
+ );
694
+ }
695
+ async listBrainDocuments(projectId) {
696
+ return this.request(
697
+ `/projects/${projectId}/brain-documents`,
698
+ z3.object({ documents: z3.array(brainDocumentItemSchema) }),
699
+ { method: "GET" }
700
+ );
701
+ }
702
+ async pushBrainDocuments(projectId, documents) {
703
+ return this.request(
704
+ `/projects/${projectId}/brain-documents`,
705
+ z3.object({ documents: z3.array(brainDocumentItemSchema) }),
706
+ {
707
+ method: "POST",
708
+ body: JSON.stringify({ documents })
709
+ }
710
+ );
711
+ }
712
+ async sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, status, version) {
713
+ return this.request(
714
+ `/projects/${projectId}/runners`,
715
+ z3.object({ runner: runnerHeartbeatItemSchema }),
716
+ {
717
+ method: "POST",
718
+ body: JSON.stringify({ runnerId, repositoryLinkId, status, ...version ? { version } : {}, lastSeenAt: (/* @__PURE__ */ new Date()).toISOString() })
719
+ }
720
+ );
721
+ }
722
+ async listToolSessions(projectId) {
723
+ return this.request(
724
+ `/projects/${projectId}/tool-sessions`,
725
+ z3.object({ toolSessions: z3.array(toolSessionItemSchema) }),
726
+ { method: "GET" }
727
+ );
728
+ }
729
+ async createToolSession(projectId, session) {
730
+ return this.request(
731
+ `/projects/${projectId}/tool-sessions`,
732
+ z3.object({ toolSession: toolSessionItemSchema }),
733
+ {
734
+ method: "POST",
735
+ body: JSON.stringify(session)
736
+ }
737
+ );
738
+ }
739
+ async updateToolSession(projectId, toolSessionId, session) {
740
+ return this.request(
741
+ `/projects/${projectId}/tool-sessions/${toolSessionId}`,
742
+ z3.object({ toolSession: toolSessionItemSchema }),
743
+ {
744
+ method: "PATCH",
745
+ body: JSON.stringify(session)
746
+ }
747
+ );
748
+ }
749
+ async updateWorkStatus(projectId, workItemId, status, idempotencyKey, runnerId, telemetry = {}) {
750
+ return this.request(
751
+ `/projects/${projectId}/work-items/${workItemId}/status`,
752
+ z3.object({ workItem: workItemSchema }),
753
+ {
754
+ method: "PATCH",
755
+ body: JSON.stringify({ status, idempotencyKey, ...runnerId ? { runnerId } : {}, ...telemetry })
756
+ }
757
+ );
758
+ }
759
+ async submitBrainGenerationResult(projectId, workItemId, result) {
760
+ return this.request(
761
+ `/projects/${projectId}/work-items/${workItemId}/generation-result`,
762
+ z3.object({ draft: generatedDraftItemSchema, documents: z3.array(brainDocumentItemSchema), workItem: workItemSchema }),
763
+ {
764
+ method: "POST",
765
+ body: JSON.stringify(result)
766
+ }
767
+ );
768
+ }
769
+ async request(urlPath, schema, init) {
770
+ const response = await fetch(resolveApiUrl(this.options.apiUrl, urlPath), {
771
+ ...init,
772
+ headers: {
773
+ "content-type": "application/json",
774
+ "x-amistio-account-id": this.options.accountId,
775
+ ...this.options.token ? { authorization: `Bearer ${this.options.token}` } : {}
776
+ }
399
777
  });
400
- console.log(`Claimed ${result.workItem.workItemId}. Running ${preview.toolName}: ${preview.displayCommand}`);
401
- const startedAt = Date.now();
402
- const providerSessionStore = new LocalToolSessionStore();
403
- const providerSessionId = sessionContext.toolSession
404
- ? await providerSessionStore.getProviderSessionId(sessionContext.toolSession.toolSessionId, preview.toolName)
405
- : undefined;
406
- const toolResult = await runLocalTool({
407
- rootDir: root,
408
- prompt,
409
- tool,
410
- ...(toolCommand ? { toolCommand } : {}),
411
- streamOutput: stream,
412
- ...(sessionContext.toolSession
413
- ? {
414
- session: {
415
- toolSessionId: sessionContext.toolSession.toolSessionId,
416
- policy: sessionContext.policy,
417
- decision: sessionContext.decision,
418
- ...(providerSessionId ? { providerSessionId } : {})
419
- }
420
- }
421
- : {})
422
- }).catch(async (error) => {
423
- await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "blocked", CLI_VERSION);
424
- await markToolSessionBlocked(apiClient, projectId, sessionContext.toolSession, errorMessage(error));
425
- throw error;
778
+ if (!response.ok) {
779
+ const detail = await response.text().catch(() => "");
780
+ throw new Error(`Amistio API request failed: ${response.status} ${response.statusText}${detail ? ` - ${detail}` : ""}`);
781
+ }
782
+ return schema.parse(await response.json());
783
+ }
784
+ };
785
+ var toolSessionMutationSchema = z3.object({
786
+ toolSessionId: z3.string().min(1).optional(),
787
+ repositoryLinkId: z3.string().min(1).optional(),
788
+ tool: z3.string().min(1).optional(),
789
+ provider: z3.string().min(1).optional(),
790
+ model: z3.string().min(1).optional(),
791
+ resumabilityScope: sessionResumabilityScopeSchema.optional(),
792
+ title: z3.string().min(1).optional(),
793
+ summary: z3.string().optional(),
794
+ tags: z3.array(z3.string()).optional(),
795
+ relatedDocumentIds: z3.array(z3.string()).optional(),
796
+ status: toolSessionStatusSchema.optional(),
797
+ createdByUserId: z3.string().min(1).optional(),
798
+ runnerId: z3.string().min(1).optional(),
799
+ lastWorkItemId: z3.string().min(1).optional(),
800
+ messageCount: z3.number().int().nonnegative().optional(),
801
+ estimatedInputTokens: z3.number().int().nonnegative().optional(),
802
+ estimatedOutputTokens: z3.number().int().nonnegative().optional(),
803
+ costUsd: z3.number().nonnegative().optional(),
804
+ sessionGroupKey: z3.string().min(1).optional(),
805
+ reusePolicy: sessionPolicySchema.optional(),
806
+ sessionDecision: sessionDecisionSchema.optional(),
807
+ closedReason: z3.string().optional()
808
+ });
809
+ function resolveApiUrl(apiUrl, urlPath) {
810
+ const base = apiUrl.endsWith("/") ? apiUrl.slice(0, -1) : apiUrl;
811
+ const path9 = urlPath.startsWith("/") ? urlPath : `/${urlPath}`;
812
+ return new URL(`${base}${path9}`);
813
+ }
814
+
815
+ // src/orchestrator.ts
816
+ import { mkdir as mkdir4, writeFile as writeFile3 } from "node:fs/promises";
817
+ import path4 from "node:path";
818
+ async function createOrchestrationPrompt(options) {
819
+ const controlPlane = await inspectControlPlane(options.rootDir);
820
+ return [
821
+ "# Amistio Orchestration Task",
822
+ "",
823
+ "You are running locally through the Amistio CLI inside the user's repository.",
824
+ "Your job is to keep future agents on the same track by updating the Amistio control plane, not by rewriting the project from scratch.",
825
+ "",
826
+ "## Goal",
827
+ "",
828
+ options.goal,
829
+ "",
830
+ "## Orchestrator Principle",
831
+ "",
832
+ "The root control-plane files are the project brain. They exist so agentic coding sessions can continue from established context, decisions, memory, plans, workflows, and prompts instead of drifting into a fresh interpretation each time.",
833
+ "",
834
+ "## Repository Rules",
835
+ "",
836
+ "- Read AGENTS.md first when it exists.",
837
+ "- Read existing architecture, context, decisions, features, memory, plans, prompts, and workflows before proposing changes.",
838
+ "- Preserve existing intent. Add the next useful orchestration layer instead of replacing old content wholesale.",
839
+ "- Prefer small additive edits, dated notes, explicit decisions, focused plans, and executable prompts.",
840
+ "- Do not modify product source code unless the user's goal explicitly asks for implementation.",
841
+ "- Do not create a repo-local .amistio folder.",
842
+ "- Do not write access tokens, API keys, refresh tokens, local filesystem secrets, or machine-specific credentials into the repository.",
843
+ "- Do not commit changes.",
844
+ "",
845
+ "## Control-Plane Areas",
846
+ "",
847
+ "- AGENTS.md for agent operating rules.",
848
+ "- architecture/**/*.md for system shape and boundaries.",
849
+ "- context/**/*.md for product, stack, and implementation context.",
850
+ "- decisions/**/*.md for ADRs and durable choices.",
851
+ "- features/**/*.md for behavior specs and acceptance criteria.",
852
+ "- memory/**/*.md for lessons, patterns, and known pitfalls.",
853
+ "- plans/**/*.md for near-term execution plans.",
854
+ "- prompts/**/*.md for model-agnostic implementation prompts.",
855
+ "- workflows/**/*.md for repeatable procedures.",
856
+ "",
857
+ "## Current Control-Plane Folder Status",
858
+ "",
859
+ `Present: ${controlPlane.present.length ? controlPlane.present.join(", ") : "none"}`,
860
+ `Missing: ${controlPlane.missing.length ? controlPlane.missing.join(", ") : "none"}`,
861
+ "",
862
+ "## Expected Result",
863
+ "",
864
+ "- Update the smallest useful set of control-plane files needed to orient the next coding agent.",
865
+ "- Record any new decision, plan, prompt, workflow, memory, or context that prevents future drift.",
866
+ "- Keep old decisions visible. If something is superseded, say so explicitly instead of silently deleting it.",
867
+ "- Summarize changed files, why they changed, and the recommended next agent action."
868
+ ].join("\n");
869
+ }
870
+ async function writePromptFile(filePath, prompt) {
871
+ await mkdir4(path4.dirname(filePath), { recursive: true });
872
+ await writeFile3(filePath, prompt, "utf8");
873
+ return filePath;
874
+ }
875
+
876
+ // src/local-tool-runner.ts
877
+ import { spawn } from "node:child_process";
878
+ import { mkdtemp, rm, writeFile as writeFile4 } from "node:fs/promises";
879
+ import os2 from "node:os";
880
+ import path5 from "node:path";
881
+ var localToolNames = ["opencode", "claude", "codex", "copilot", "gemini", "aider", "cursor-agent"];
882
+ var localToolAdapters = [
883
+ {
884
+ name: "opencode",
885
+ description: "opencode SDK adapter using @opencode-ai/sdk, with CLI fallback.",
886
+ sdkPackageName: "@opencode-ai/sdk",
887
+ sdkDisplayCommand: "@opencode-ai/sdk createOpencode().client.session.prompt()",
888
+ sdkRequiresExecutable: true,
889
+ executable: "opencode",
890
+ supportsSessionReuse: true,
891
+ resumabilityScope: "localMachine",
892
+ runWithSdk: runOpencodeSdk,
893
+ buildInvocation: ({ prompt }) => ({
894
+ command: "opencode",
895
+ args: ["run", prompt],
896
+ displayCommand: "opencode run <generated prompt>"
897
+ })
898
+ },
899
+ {
900
+ name: "claude",
901
+ description: "Claude Agent SDK adapter using @anthropic-ai/claude-agent-sdk, with CLI fallback.",
902
+ sdkPackageName: "@anthropic-ai/claude-agent-sdk",
903
+ sdkDisplayCommand: "@anthropic-ai/claude-agent-sdk query()",
904
+ executable: "claude",
905
+ supportsSessionReuse: false,
906
+ resumabilityScope: "none",
907
+ runWithSdk: runClaudeSdk,
908
+ buildInvocation: ({ prompt }) => ({
909
+ command: "claude",
910
+ args: ["-p", prompt],
911
+ displayCommand: "claude -p <generated prompt>"
912
+ })
913
+ },
914
+ {
915
+ name: "codex",
916
+ description: "Codex SDK adapter using @openai/codex-sdk, with CLI fallback.",
917
+ sdkPackageName: "@openai/codex-sdk",
918
+ sdkDisplayCommand: "@openai/codex-sdk Codex.startThread().run()",
919
+ executable: "codex",
920
+ supportsSessionReuse: false,
921
+ resumabilityScope: "none",
922
+ runWithSdk: runCodexSdk,
923
+ buildInvocation: ({ prompt }) => ({
924
+ command: "codex",
925
+ args: ["exec", prompt],
926
+ displayCommand: "codex exec <generated prompt>"
927
+ })
928
+ },
929
+ {
930
+ name: "copilot",
931
+ description: "GitHub Copilot SDK adapter using @github/copilot-sdk.",
932
+ sdkPackageName: "@github/copilot-sdk",
933
+ sdkDisplayCommand: "@github/copilot-sdk CopilotClient.createSession().sendAndWait()",
934
+ supportsSessionReuse: false,
935
+ resumabilityScope: "none",
936
+ runWithSdk: runCopilotSdk
937
+ },
938
+ {
939
+ name: "gemini",
940
+ description: "Gemini CLI adapter using prompt mode.",
941
+ executable: "gemini",
942
+ supportsSessionReuse: false,
943
+ resumabilityScope: "none",
944
+ buildInvocation: ({ prompt }) => ({
945
+ command: "gemini",
946
+ args: ["-p", prompt],
947
+ displayCommand: "gemini -p <generated prompt>"
948
+ })
949
+ },
950
+ {
951
+ name: "aider",
952
+ description: "Aider CLI adapter using a one-shot message.",
953
+ executable: "aider",
954
+ supportsSessionReuse: false,
955
+ resumabilityScope: "none",
956
+ buildInvocation: ({ prompt }) => ({
957
+ command: "aider",
958
+ args: ["--yes", "--message", prompt],
959
+ displayCommand: "aider --yes --message <generated prompt>"
960
+ })
961
+ },
962
+ {
963
+ name: "cursor-agent",
964
+ description: "Cursor agent CLI adapter using prompt mode when installed.",
965
+ executable: "cursor-agent",
966
+ supportsSessionReuse: false,
967
+ resumabilityScope: "none",
968
+ buildInvocation: ({ prompt }) => ({
969
+ command: "cursor-agent",
970
+ args: ["-p", prompt],
971
+ displayCommand: "cursor-agent -p <generated prompt>"
972
+ })
973
+ }
974
+ ];
975
+ function isLocalToolName(value) {
976
+ return localToolNames.includes(value);
977
+ }
978
+ async function detectLocalTools() {
979
+ return Promise.all(
980
+ localToolAdapters.map(async (adapter) => {
981
+ const sdkAvailable = await isSdkAvailable(adapter);
982
+ const commandAvailable = adapter.executable ? await commandExists(adapter.executable) : false;
983
+ return {
984
+ name: adapter.name,
985
+ description: adapter.description,
986
+ available: sdkAvailable || commandAvailable,
987
+ sdkAvailable,
988
+ commandAvailable,
989
+ execution: sdkAvailable ? "sdk" : commandAvailable ? "command" : "unavailable",
990
+ supportsSessionReuse: Boolean(adapter.supportsSessionReuse),
991
+ resumabilityScope: adapter.resumabilityScope ?? "none"
992
+ };
993
+ })
994
+ );
995
+ }
996
+ async function runLocalTool(options) {
997
+ const promptTempDir = await mkdtemp(path5.join(os2.tmpdir(), "amistio-prompt-"));
998
+ const promptFilePath = path5.join(promptTempDir, "prompt.md");
999
+ await writeFile4(promptFilePath, options.prompt, "utf8");
1000
+ try {
1001
+ const runnerOptions = {
1002
+ rootDir: options.rootDir,
1003
+ prompt: options.prompt,
1004
+ promptFilePath,
1005
+ tool: options.tool ?? "auto"
1006
+ };
1007
+ if (options.toolCommand) {
1008
+ runnerOptions.toolCommand = options.toolCommand;
1009
+ }
1010
+ const runner = await createToolRunner(runnerOptions);
1011
+ const result = await executeToolRunner(runner, {
1012
+ rootDir: options.rootDir,
1013
+ prompt: options.prompt,
1014
+ promptFilePath,
1015
+ streamOutput: Boolean(options.streamOutput),
1016
+ ...options.session ? { session: options.session } : {}
426
1017
  });
427
- if (sessionContext.toolSession && toolResult.providerSessionId) {
428
- await providerSessionStore.setProviderSessionId(sessionContext.toolSession.toolSessionId, preview.toolName, toolResult.providerSessionId);
429
- }
430
- if (!stream && toolResult.stdout.trim()) {
431
- console.log(toolResult.stdout.trim());
432
- }
433
- if (!stream && toolResult.stderr.trim()) {
434
- console.error(toolResult.stderr.trim());
435
- }
436
- if (result.workItem.workKind === "brainGeneration") {
437
- return finalizeBrainGenerationWork({
438
- apiClient,
439
- durationMs: Date.now() - startedAt,
440
- projectId,
441
- repositoryLinkId,
442
- runnerId,
443
- sessionContext,
444
- toolName: preview.toolName,
445
- toolResult,
446
- workItem: result.workItem
447
- });
1018
+ return {
1019
+ toolName: runner.toolName,
1020
+ displayCommand: runner.kind === "sdk" ? runner.displayCommand : runner.invocation.displayCommand,
1021
+ supportsSessionReuse: runner.kind === "sdk" ? Boolean(runner.adapter.supportsSessionReuse) : false,
1022
+ resumabilityScope: runner.kind === "sdk" ? runner.adapter.resumabilityScope ?? "none" : "none",
1023
+ ...result
1024
+ };
1025
+ } finally {
1026
+ await rm(promptTempDir, { recursive: true, force: true });
1027
+ }
1028
+ }
1029
+ async function createToolRunPreview(options) {
1030
+ const promptFilePath = path5.join(os2.tmpdir(), "amistio-generated-prompt.md");
1031
+ const runnerOptions = {
1032
+ rootDir: options.rootDir,
1033
+ prompt: options.prompt,
1034
+ promptFilePath,
1035
+ tool: options.tool ?? "auto"
1036
+ };
1037
+ if (options.toolCommand) {
1038
+ runnerOptions.toolCommand = options.toolCommand;
1039
+ }
1040
+ const runner = await createToolRunner(runnerOptions);
1041
+ return {
1042
+ toolName: runner.toolName,
1043
+ displayCommand: runner.kind === "sdk" ? runner.displayCommand : runner.invocation.displayCommand,
1044
+ supportsSessionReuse: runner.kind === "sdk" ? Boolean(runner.adapter.supportsSessionReuse) : false,
1045
+ resumabilityScope: runner.kind === "sdk" ? runner.adapter.resumabilityScope ?? "none" : "none"
1046
+ };
1047
+ }
1048
+ function createCustomToolInvocation(commandTemplate, input) {
1049
+ const displayCommand = commandTemplate.replaceAll("{promptFile}", shellQuote(input.promptFilePath)).replaceAll("{root}", shellQuote(input.rootDir));
1050
+ return {
1051
+ command: displayCommand,
1052
+ args: [],
1053
+ displayCommand,
1054
+ shell: true,
1055
+ ...commandTemplate.includes("{promptFile}") ? {} : { stdin: input.prompt }
1056
+ };
1057
+ }
1058
+ async function createToolRunner(options) {
1059
+ if (options.toolCommand) {
1060
+ return {
1061
+ toolName: "custom",
1062
+ kind: "command",
1063
+ invocation: createCustomToolInvocation(options.toolCommand, options)
1064
+ };
1065
+ }
1066
+ const tool = normalizeToolRequest(options.tool);
1067
+ if (tool === "none") {
1068
+ throw new Error("No local tool selected. Use --tool auto, a supported tool name, or --tool-command.");
1069
+ }
1070
+ const adapter = tool === "auto" ? await selectFirstAvailableAdapter() : await selectRequestedAdapter(tool);
1071
+ if (adapter.runWithSdk && await isSdkAvailable(adapter)) {
1072
+ return {
1073
+ toolName: adapter.name,
1074
+ kind: "sdk",
1075
+ displayCommand: adapter.sdkDisplayCommand ?? `${adapter.name} SDK`,
1076
+ adapter
1077
+ };
1078
+ }
1079
+ if (adapter.buildInvocation && adapter.executable && await commandExists(adapter.executable)) {
1080
+ return {
1081
+ toolName: adapter.name,
1082
+ kind: "command",
1083
+ invocation: adapter.buildInvocation(options)
1084
+ };
1085
+ }
1086
+ throw new Error(`The ${adapter.name} SDK or executable was not found. Install the SDK/runtime or pass --tool-command.`);
1087
+ }
1088
+ async function executeToolRunner(runner, input) {
1089
+ if (runner.kind === "command") {
1090
+ return executeToolInvocation(runner.invocation, input.rootDir, input.streamOutput);
1091
+ }
1092
+ try {
1093
+ return await runner.adapter.runWithSdk(input);
1094
+ } catch (error) {
1095
+ if (runner.adapter.buildInvocation && runner.adapter.executable && await commandExists(runner.adapter.executable)) {
1096
+ const fallback = runner.adapter.buildInvocation(input);
1097
+ const result = await executeToolInvocation(fallback, input.rootDir, input.streamOutput);
1098
+ const sdkFailure = `SDK execution for ${runner.adapter.name} failed, fell back to ${fallback.displayCommand}: ${errorMessage(error)}`;
1099
+ return {
1100
+ ...result,
1101
+ stderr: result.stderr ? `${sdkFailure}
1102
+ ${result.stderr}` : sdkFailure
1103
+ };
1104
+ }
1105
+ throw error;
1106
+ }
1107
+ }
1108
+ function normalizeToolRequest(value) {
1109
+ if (value === "auto" || value === "none" || isLocalToolName(value)) {
1110
+ return value;
1111
+ }
1112
+ throw new Error(`Unsupported local tool: ${value}. Supported tools: auto, none, ${localToolNames.join(", ")}.`);
1113
+ }
1114
+ async function selectFirstAvailableAdapter() {
1115
+ for (const adapter of localToolAdapters) {
1116
+ const sdkAvailable = await isSdkAvailable(adapter);
1117
+ const commandAvailable = adapter.executable ? await commandExists(adapter.executable) : false;
1118
+ if (sdkAvailable || commandAvailable) {
1119
+ return adapter;
448
1120
  }
449
- const finalStatus = toolResult.exitCode === 0 ? "completed" : "failed";
450
- const durationMs = Date.now() - startedAt;
451
- const failureExcerpt = toolResult.exitCode === 0 ? undefined : truncateLogExcerpt(toolResult.stderr || toolResult.stdout);
452
- const updatedToolSession = await finalizeToolSession({
453
- apiClient,
454
- projectId,
455
- status: finalStatus,
456
- runnerId,
457
- workItemId: result.workItem.workItemId,
458
- stdout: toolResult.stdout,
459
- ...(sessionContext.toolSession ? { session: sessionContext.toolSession } : {}),
460
- ...(toolResult.messageCount !== undefined ? { messageCount: toolResult.messageCount } : {}),
461
- ...(toolResult.tokensIn !== undefined ? { tokensIn: toolResult.tokensIn } : {}),
462
- ...(toolResult.tokensOut !== undefined ? { tokensOut: toolResult.tokensOut } : {}),
463
- ...(toolResult.costUsd !== undefined ? { costUsd: toolResult.costUsd } : {})
1121
+ }
1122
+ throw new Error(
1123
+ `No supported local AI tool was found. Install one of ${localToolNames.join(", ")} or pass --tool-command "your-tool --prompt-file {promptFile}".`
1124
+ );
1125
+ }
1126
+ async function selectRequestedAdapter(tool) {
1127
+ const adapter = localToolAdapters.find((candidate) => candidate.name === tool);
1128
+ if (!adapter) {
1129
+ throw new Error(`Unsupported local tool: ${tool}.`);
1130
+ }
1131
+ if (await isSdkAvailable(adapter)) {
1132
+ return adapter;
1133
+ }
1134
+ if (adapter.executable && await commandExists(adapter.executable)) {
1135
+ return adapter;
1136
+ }
1137
+ throw new Error(`The ${tool} SDK or executable was not found. Install it or pass --tool-command.`);
1138
+ }
1139
+ async function isSdkAvailable(adapter) {
1140
+ if (!adapter.sdkPackageName || !adapter.runWithSdk) {
1141
+ return false;
1142
+ }
1143
+ if (adapter.sdkRequiresExecutable && adapter.executable && !await commandExists(adapter.executable)) {
1144
+ return false;
1145
+ }
1146
+ return packageAvailable(adapter.sdkPackageName);
1147
+ }
1148
+ async function packageAvailable(packageName) {
1149
+ try {
1150
+ await import(packageName);
1151
+ return true;
1152
+ } catch {
1153
+ return false;
1154
+ }
1155
+ }
1156
+ async function commandExists(command) {
1157
+ const lookupCommand = process.platform === "win32" ? "where" : "which";
1158
+ return new Promise((resolve) => {
1159
+ const lookup = spawn(lookupCommand, [command], { stdio: "ignore" });
1160
+ lookup.on("error", () => resolve(false));
1161
+ lookup.on("close", (exitCode) => resolve(exitCode === 0));
1162
+ });
1163
+ }
1164
+ async function executeToolInvocation(invocation, rootDir, streamOutput) {
1165
+ return new Promise((resolve, reject) => {
1166
+ const child = spawn(invocation.command, invocation.args, {
1167
+ cwd: rootDir,
1168
+ env: process.env,
1169
+ shell: invocation.shell ?? false,
1170
+ stdio: ["pipe", "pipe", "pipe"]
464
1171
  });
465
- const statusResult = await apiClient.updateWorkStatus(projectId, result.workItem.workItemId, finalStatus, `run_${result.workItem.workItemId}_${randomUUID()}`, runnerId, {
466
- tool: preview.toolName,
467
- durationMs,
468
- message: `${preview.toolName} exited with code ${toolResult.exitCode}.`,
469
- sessionPolicy: sessionContext.policy,
470
- sessionDecision: sessionContext.decision,
471
- sessionDecisionReason: sessionContext.reason,
472
- ...(updatedToolSession ? { toolSessionId: updatedToolSession.toolSessionId } : {}),
473
- ...(updatedToolSession?.sessionGroupKey ? { sessionGroupKey: updatedToolSession.sessionGroupKey } : {}),
474
- ...(toolResult.tokensIn !== undefined ? { tokensIn: toolResult.tokensIn } : {}),
475
- ...(toolResult.tokensOut !== undefined ? { tokensOut: toolResult.tokensOut } : {}),
476
- ...(toolResult.costUsd !== undefined ? { costUsd: toolResult.costUsd } : {}),
477
- ...(failureExcerpt ? { error: failureExcerpt } : {})
1172
+ let stdout = "";
1173
+ let stderr = "";
1174
+ child.on("error", reject);
1175
+ child.stdout.setEncoding("utf8");
1176
+ child.stderr.setEncoding("utf8");
1177
+ child.stdout.on("data", (chunk) => {
1178
+ stdout += chunk;
1179
+ if (streamOutput) {
1180
+ process.stdout.write(chunk);
1181
+ }
478
1182
  });
479
- await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", CLI_VERSION);
480
- const durationSeconds = Math.round(durationMs / 1000);
481
- console.log(`Marked ${statusResult.workItem.workItemId} ${statusResult.workItem.status} after ${durationSeconds}s.`);
482
- return { status: finalStatus, exitCode: toolResult.exitCode };
483
- }
484
- async function finalizeBrainGenerationWork({ apiClient, durationMs, projectId, repositoryLinkId, runnerId, sessionContext, toolName, toolResult, workItem }) {
485
- let artifacts;
486
- let generationError;
487
- if (toolResult.exitCode === 0) {
488
- try {
489
- artifacts = parseBrainGenerationArtifacts(`${toolResult.stdout}\n${toolResult.stderr}`);
1183
+ child.stderr.on("data", (chunk) => {
1184
+ stderr += chunk;
1185
+ if (streamOutput) {
1186
+ process.stderr.write(chunk);
1187
+ }
1188
+ });
1189
+ child.stdin.on("error", () => void 0);
1190
+ if (invocation.stdin) {
1191
+ child.stdin.write(invocation.stdin);
1192
+ }
1193
+ child.stdin.end();
1194
+ child.on("close", (exitCode) => {
1195
+ resolve({ exitCode: exitCode ?? 1, stdout, stderr });
1196
+ });
1197
+ });
1198
+ }
1199
+ async function runOpencodeSdk(input) {
1200
+ const { createOpencode } = await import("@opencode-ai/sdk");
1201
+ const previousDirectory = process.cwd();
1202
+ process.chdir(input.rootDir);
1203
+ try {
1204
+ const opencode = await createOpencode({ timeout: 1e4 });
1205
+ try {
1206
+ let providerSessionId = input.session?.providerSessionId;
1207
+ if (!providerSessionId) {
1208
+ const sessionResult = await opencode.client.session.create({
1209
+ query: { directory: input.rootDir },
1210
+ body: { title: "Amistio orchestration" }
1211
+ });
1212
+ if (sessionResult.error || !sessionResult.data) {
1213
+ throw new Error(`opencode session create failed: ${JSON.stringify(sessionResult.error ?? "missing data")}`);
490
1214
  }
491
- catch (error) {
492
- generationError = errorMessage(error);
1215
+ providerSessionId = sessionResult.data.id;
1216
+ }
1217
+ const promptResult = await opencode.client.session.prompt({
1218
+ path: { id: providerSessionId },
1219
+ query: { directory: input.rootDir },
1220
+ body: { parts: [{ type: "text", text: input.prompt }] }
1221
+ });
1222
+ if (promptResult.error || !promptResult.data) {
1223
+ throw new Error(`opencode prompt failed: ${JSON.stringify(promptResult.error ?? "missing data")}`);
1224
+ }
1225
+ const stdout = extractTextParts(promptResult.data.parts);
1226
+ if (input.streamOutput && stdout) {
1227
+ process.stdout.write(stdout);
1228
+ }
1229
+ return { exitCode: 0, stdout, stderr: "", providerSessionId, messageCount: 1 };
1230
+ } finally {
1231
+ opencode.server.close();
1232
+ }
1233
+ } finally {
1234
+ process.chdir(previousDirectory);
1235
+ }
1236
+ }
1237
+ async function runClaudeSdk(input) {
1238
+ const { query } = await import("@anthropic-ai/claude-agent-sdk");
1239
+ let stdout = "";
1240
+ let stderr = "";
1241
+ let exitCode = 0;
1242
+ for await (const message of query({
1243
+ prompt: input.prompt,
1244
+ options: {
1245
+ cwd: input.rootDir,
1246
+ permissionMode: "default",
1247
+ tools: { type: "preset", preset: "claude_code" },
1248
+ env: {
1249
+ ...process.env,
1250
+ CLAUDE_AGENT_SDK_CLIENT_APP: "amistio-cli/0.1.0"
1251
+ }
1252
+ }
1253
+ })) {
1254
+ if (isRecord(message) && message.type === "result") {
1255
+ if (message.subtype === "success" && typeof message.result === "string") {
1256
+ stdout += message.result;
1257
+ if (input.streamOutput) {
1258
+ process.stdout.write(message.result);
493
1259
  }
1260
+ } else {
1261
+ exitCode = 1;
1262
+ const resultMessage = message;
1263
+ const errors = Array.isArray(resultMessage.errors) ? resultMessage.errors.map(String).join("\n") : "Claude Agent SDK execution failed.";
1264
+ stderr += errors;
1265
+ }
494
1266
  }
495
- else {
496
- generationError = truncateLogExcerpt(toolResult.stderr || toolResult.stdout) || `${toolName} exited with code ${toolResult.exitCode}.`;
497
- }
498
- const finalStatus = artifacts ? "completed" : "failed";
499
- const updatedToolSession = await finalizeToolSession({
500
- apiClient,
501
- projectId,
502
- status: finalStatus,
503
- runnerId,
504
- workItemId: workItem.workItemId,
505
- stdout: toolResult.stdout,
506
- ...(sessionContext.toolSession ? { session: sessionContext.toolSession } : {}),
507
- ...(toolResult.messageCount !== undefined ? { messageCount: toolResult.messageCount } : {}),
508
- ...(toolResult.tokensIn !== undefined ? { tokensIn: toolResult.tokensIn } : {}),
509
- ...(toolResult.tokensOut !== undefined ? { tokensOut: toolResult.tokensOut } : {}),
510
- ...(toolResult.costUsd !== undefined ? { costUsd: toolResult.costUsd } : {})
1267
+ }
1268
+ return { exitCode, stdout, stderr };
1269
+ }
1270
+ async function runCodexSdk(input) {
1271
+ const { Codex } = await import("@openai/codex-sdk");
1272
+ const codex = new Codex();
1273
+ const thread = codex.startThread({
1274
+ workingDirectory: input.rootDir,
1275
+ sandboxMode: "workspace-write",
1276
+ approvalPolicy: "on-request",
1277
+ skipGitRepoCheck: true
1278
+ });
1279
+ const result = await thread.run(input.prompt);
1280
+ if (input.streamOutput && result.finalResponse) {
1281
+ process.stdout.write(result.finalResponse);
1282
+ }
1283
+ return { exitCode: 0, stdout: result.finalResponse, stderr: "" };
1284
+ }
1285
+ async function runCopilotSdk(input) {
1286
+ const { CopilotClient, approveAll } = await import("@github/copilot-sdk");
1287
+ const client = new CopilotClient({
1288
+ cwd: input.rootDir,
1289
+ logLevel: "error"
1290
+ });
1291
+ try {
1292
+ await client.start();
1293
+ const session = await client.createSession({
1294
+ clientName: "amistio-cli",
1295
+ model: process.env.AMISTIO_COPILOT_MODEL ?? "gpt-5",
1296
+ workingDirectory: input.rootDir,
1297
+ enableConfigDiscovery: true,
1298
+ streaming: input.streamOutput,
1299
+ onPermissionRequest: approveAll
511
1300
  });
512
- const sessionTelemetry = {
513
- sessionPolicy: sessionContext.policy,
514
- sessionDecision: sessionContext.decision,
515
- sessionDecisionReason: sessionContext.reason,
516
- ...(updatedToolSession ? { toolSessionId: updatedToolSession.toolSessionId } : {}),
517
- ...(updatedToolSession?.sessionGroupKey ? { sessionGroupKey: updatedToolSession.sessionGroupKey } : {})
1301
+ try {
1302
+ let streamedOutput = "";
1303
+ const unsubscribe = input.streamOutput ? session.on("assistant.message_delta", (event) => {
1304
+ streamedOutput += event.data.deltaContent;
1305
+ process.stdout.write(event.data.deltaContent);
1306
+ }) : void 0;
1307
+ try {
1308
+ const response = await session.sendAndWait({ prompt: input.prompt }, 10 * 60 * 1e3);
1309
+ const stdout = response?.data.content ?? streamedOutput;
1310
+ return { exitCode: 0, stdout, stderr: "" };
1311
+ } finally {
1312
+ unsubscribe?.();
1313
+ }
1314
+ } finally {
1315
+ await session.disconnect();
1316
+ }
1317
+ } finally {
1318
+ await client.stop();
1319
+ }
1320
+ }
1321
+ function extractTextParts(parts) {
1322
+ if (!Array.isArray(parts)) {
1323
+ return "";
1324
+ }
1325
+ return parts.filter(isRecord).map((part) => part.type === "text" && typeof part.text === "string" ? part.text : "").filter(Boolean).join("\n");
1326
+ }
1327
+ function isRecord(value) {
1328
+ return typeof value === "object" && value !== null;
1329
+ }
1330
+ function errorMessage(error) {
1331
+ return error instanceof Error ? error.message : String(error);
1332
+ }
1333
+ function shellQuote(value) {
1334
+ return `'${value.replaceAll("'", "'\\''")}'`;
1335
+ }
1336
+
1337
+ // src/session-policy.ts
1338
+ var maxIdleMs = 24 * 60 * 60 * 1e3;
1339
+ var maxTotalMs = 7 * 24 * 60 * 60 * 1e3;
1340
+ var maxMessageCount = 80;
1341
+ var maxEstimatedTokens = 12e4;
1342
+ var maxCostUsd = 25;
1343
+ var relatednessThreshold = 20;
1344
+ function normalizeSessionPolicy(value) {
1345
+ if (!value || value === "auto" || value === "new" || value === "none") {
1346
+ return value ?? "auto";
1347
+ }
1348
+ if (/^continue:[A-Za-z0-9_.:-]+$/.test(value)) {
1349
+ return value;
1350
+ }
1351
+ throw new Error(`Unsupported session policy: ${value}. Use auto, new, continue:<toolSessionId>, or none.`);
1352
+ }
1353
+ function selectToolSession(input) {
1354
+ if (input.policy === "none") {
1355
+ return { policy: input.policy, decision: "skipped", reason: "Session reuse was disabled for this run." };
1356
+ }
1357
+ if (!input.supportsSessionReuse) {
1358
+ return {
1359
+ policy: input.policy,
1360
+ decision: "notSupported",
1361
+ reason: `${input.toolName} does not expose reusable provider sessions; Amistio will record this as a one-shot tool session.`
518
1362
  };
519
- if (artifacts) {
520
- const result = await apiClient.submitBrainGenerationResult(projectId, workItem.workItemId, {
521
- status: "completed",
522
- runnerId,
523
- idempotencyKey: `generation_${workItem.workItemId}_${randomUUID()}`,
524
- artifacts,
525
- tool: toolName,
526
- durationMs,
527
- ...sessionTelemetry,
528
- message: `${toolName} generated ${artifacts.length} brain artifact${artifacts.length === 1 ? "" : "s"}.`
529
- });
530
- await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", CLI_VERSION);
531
- console.log(`Generated ${result.documents.length} brain artifact${result.documents.length === 1 ? "" : "s"} for review.`);
532
- return { status: "completed", exitCode: 0 };
533
- }
534
- await apiClient.submitBrainGenerationResult(projectId, workItem.workItemId, {
535
- status: "failed",
536
- runnerId,
537
- idempotencyKey: `generation_${workItem.workItemId}_${randomUUID()}`,
538
- tool: toolName,
539
- durationMs,
540
- ...sessionTelemetry,
541
- message: `${toolName} did not produce valid brain artifacts.`,
542
- ...(generationError ? { error: generationError } : {})
1363
+ }
1364
+ if (input.policy === "new") {
1365
+ return { policy: input.policy, decision: "forcedNew", reason: "The user requested a fresh tool session." };
1366
+ }
1367
+ const forcedSessionId = forcedContinueSessionId(input.policy);
1368
+ if (forcedSessionId) {
1369
+ const forced = input.sessions.find((session) => session.toolSessionId === forcedSessionId);
1370
+ if (!forced) {
1371
+ return { policy: input.policy, decision: "forcedNew", reason: `Requested session ${forcedSessionId} was not found; creating a fresh session.` };
1372
+ }
1373
+ const ineligibleReason = sessionIneligibleReason(forced, input, input.now ?? /* @__PURE__ */ new Date());
1374
+ if (ineligibleReason) {
1375
+ return { policy: input.policy, decision: "forcedNew", reason: `Requested session ${forcedSessionId} cannot be resumed: ${ineligibleReason}` };
1376
+ }
1377
+ return { policy: input.policy, decision: "forcedContinue", reason: `The user requested session ${forcedSessionId}.`, toolSession: forced };
1378
+ }
1379
+ const now = input.now ?? /* @__PURE__ */ new Date();
1380
+ const candidates = input.sessions.map((session) => ({ session, ineligibleReason: sessionIneligibleReason(session, input, now), score: relatednessScore(session, input.workItem) })).filter((candidate) => !candidate.ineligibleReason).sort((a, b) => b.score - a.score || Date.parse(b.session.lastActivityAt) - Date.parse(a.session.lastActivityAt));
1381
+ const best = candidates[0];
1382
+ if (!best || best.score < relatednessThreshold) {
1383
+ return {
1384
+ policy: input.policy,
1385
+ decision: "created",
1386
+ reason: best ? `Best related session score ${best.score} was below ${relatednessThreshold}; creating a fresh session.` : "No eligible related session was found."
1387
+ };
1388
+ }
1389
+ return {
1390
+ policy: input.policy,
1391
+ decision: "continued",
1392
+ reason: `Reusing ${best.session.toolSessionId}; relatedness score ${best.score}.`,
1393
+ toolSession: best.session
1394
+ };
1395
+ }
1396
+ function forcedContinueSessionId(policy) {
1397
+ return typeof policy === "string" && policy.startsWith("continue:") ? policy.slice("continue:".length) : void 0;
1398
+ }
1399
+ function sessionIneligibleReason(session, input, now) {
1400
+ if (session.tool !== input.toolName) {
1401
+ return `tool mismatch (${session.tool} != ${input.toolName})`;
1402
+ }
1403
+ if (session.repositoryLinkId && session.repositoryLinkId !== input.repositoryLinkId) {
1404
+ return "repository link mismatch";
1405
+ }
1406
+ if (session.resumabilityScope === "localMachine" && session.runnerId && session.runnerId !== input.runnerId) {
1407
+ return "session is scoped to another runner machine";
1408
+ }
1409
+ if (session.status === "closed" || session.status === "archived" || session.status === "blocked" || session.status === "unavailable") {
1410
+ return `session is ${session.status}`;
1411
+ }
1412
+ if (Date.parse(session.lastActivityAt) + maxIdleMs < now.getTime()) {
1413
+ return "session is idle past the reuse window";
1414
+ }
1415
+ if (Date.parse(session.createdAt) + maxTotalMs < now.getTime()) {
1416
+ return "session is older than the reuse window";
1417
+ }
1418
+ if ((session.messageCount ?? 0) >= maxMessageCount) {
1419
+ return "session message count is too high";
1420
+ }
1421
+ if ((session.estimatedInputTokens ?? 0) + (session.estimatedOutputTokens ?? 0) >= maxEstimatedTokens) {
1422
+ return "session token estimate is too high";
1423
+ }
1424
+ if ((session.costUsd ?? 0) >= maxCostUsd) {
1425
+ return "session cost is too high";
1426
+ }
1427
+ return void 0;
1428
+ }
1429
+ function relatednessScore(session, workItem) {
1430
+ let score = 0;
1431
+ if (session.sessionGroupKey && workItem.sessionGroupKey && session.sessionGroupKey === workItem.sessionGroupKey) {
1432
+ score += 60;
1433
+ }
1434
+ if (session.lastWorkItemId && session.lastWorkItemId === workItem.workItemId) {
1435
+ score += 40;
1436
+ }
1437
+ const titleOverlap = tokenOverlap(session.title, workItem.title);
1438
+ score += titleOverlap * 8;
1439
+ if (session.summary) {
1440
+ score += tokenOverlap(session.summary, workItem.title) * 4;
1441
+ }
1442
+ for (const tag of session.tags) {
1443
+ if (workItem.title.toLowerCase().includes(tag.toLowerCase())) {
1444
+ score += 10;
1445
+ }
1446
+ }
1447
+ return score;
1448
+ }
1449
+ function tokenOverlap(firstValue, secondValue) {
1450
+ const firstTokens = new Set(tokens(firstValue));
1451
+ return tokens(secondValue).filter((token) => firstTokens.has(token)).length;
1452
+ }
1453
+ function tokens(value) {
1454
+ return value.toLowerCase().split(/[^a-z0-9]+/).filter((token) => token.length >= 4);
1455
+ }
1456
+
1457
+ // src/sync.ts
1458
+ import { mkdir as mkdir5, readdir as readdir2, readFile as readFile3, stat as stat3, writeFile as writeFile5 } from "node:fs/promises";
1459
+ import path6 from "node:path";
1460
+ var syncRoots = ["architecture", "context", "decisions", "features", "memory", "plans", "prompts", "workflows"];
1461
+ async function collectSyncStatus(rootDir, webDocuments = []) {
1462
+ const localDocuments = await readLocalSyncedDocuments(rootDir);
1463
+ const webByDocumentId = new Map(webDocuments.map((document) => [document.documentId, document]));
1464
+ const pullableWebDocuments = webDocuments.filter((document) => document.status === "approved" || document.syncState === "approved" || document.syncState === "synced");
1465
+ const localByDocumentId = new Map(localDocuments.map((document) => [document.frontmatter.amistioDocumentId, document]));
1466
+ const items = [];
1467
+ for (const localDocument of localDocuments) {
1468
+ const webDocument = webByDocumentId.get(localDocument.frontmatter.amistioDocumentId);
1469
+ const localDirty = localDocument.contentHash !== localDocument.frontmatter.amistioContentHash;
1470
+ const webHash = webDocument?.contentHash;
1471
+ const decision = decideSyncState({
1472
+ lastSyncedHash: localDocument.frontmatter.amistioContentHash,
1473
+ repoHash: localDocument.contentHash,
1474
+ ...webHash ? { webHash } : {}
543
1475
  });
544
- await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", CLI_VERSION);
545
- console.error(generationError ?? "Local runner generation failed.");
546
- return { status: "failed", exitCode: toolResult.exitCode || 1 };
1476
+ if (decision === "conflict") {
1477
+ items.push({
1478
+ repoPath: localDocument.repoPath,
1479
+ documentId: localDocument.frontmatter.amistioDocumentId,
1480
+ status: "conflicted",
1481
+ reason: "web and repository revisions both changed"
1482
+ });
1483
+ } else if (localDirty || decision === "pushRepo") {
1484
+ items.push({
1485
+ repoPath: localDocument.repoPath,
1486
+ documentId: localDocument.frontmatter.amistioDocumentId,
1487
+ status: "dirty",
1488
+ reason: "repository content changed since last synced hash"
1489
+ });
1490
+ } else if (decision === "pullWeb") {
1491
+ items.push({
1492
+ repoPath: localDocument.repoPath,
1493
+ documentId: localDocument.frontmatter.amistioDocumentId,
1494
+ status: "pending",
1495
+ reason: "approved web revision is newer"
1496
+ });
1497
+ } else if (localDocument.frontmatter.status && !["approved", "synced"].includes(localDocument.frontmatter.status)) {
1498
+ items.push({
1499
+ repoPath: localDocument.repoPath,
1500
+ documentId: localDocument.frontmatter.amistioDocumentId,
1501
+ status: "pending",
1502
+ reason: `local document is ${localDocument.frontmatter.status}`
1503
+ });
1504
+ } else {
1505
+ items.push({
1506
+ repoPath: localDocument.repoPath,
1507
+ documentId: localDocument.frontmatter.amistioDocumentId,
1508
+ status: "clean",
1509
+ reason: "repository and web hashes match"
1510
+ });
1511
+ }
1512
+ }
1513
+ for (const webDocument of pullableWebDocuments) {
1514
+ if (!localByDocumentId.has(webDocument.documentId)) {
1515
+ items.push({
1516
+ repoPath: webDocument.repoPath,
1517
+ documentId: webDocument.documentId,
1518
+ status: "pending",
1519
+ reason: "approved web document has not been pulled"
1520
+ });
1521
+ }
1522
+ }
1523
+ const counts = {
1524
+ clean: items.filter((item) => item.status === "clean").length,
1525
+ pending: items.filter((item) => item.status === "pending").length,
1526
+ dirty: items.filter((item) => item.status === "dirty").length,
1527
+ conflicted: items.filter((item) => item.status === "conflicted").length
1528
+ };
1529
+ const status = counts.conflicted ? "conflicted" : counts.dirty ? "dirty" : counts.pending ? "pending" : "clean";
1530
+ return { status, items, counts };
547
1531
  }
548
- async function loadPairedApiContext(root, apiUrl) {
549
- const metadata = await readProjectLink(root);
550
- if (!metadata) {
551
- return undefined;
1532
+ async function readLocalSyncedDocuments(rootDir) {
1533
+ const markdownFiles = await findMarkdownFiles(rootDir);
1534
+ const documents = [];
1535
+ for (const fullPath of markdownFiles) {
1536
+ const raw = await readFile3(fullPath, "utf8");
1537
+ const parsed = parseSyncedMarkdown(raw);
1538
+ if (!parsed) {
1539
+ continue;
1540
+ }
1541
+ documents.push({
1542
+ fullPath,
1543
+ repoPath: toRepoPath(rootDir, fullPath),
1544
+ frontmatter: parsed.frontmatter,
1545
+ content: parsed.content,
1546
+ contentHash: sha256ContentHash(parsed.content)
1547
+ });
1548
+ }
1549
+ return documents;
1550
+ }
1551
+ async function materializeBrainDocuments(rootDir, documents, options = {}) {
1552
+ const result = { written: [], skipped: [], conflicts: [] };
1553
+ for (const inputDocument of documents) {
1554
+ const document = brainDocumentItemSchema.parse(inputDocument);
1555
+ const fullPath = safeRepoPath(rootDir, document.repoPath);
1556
+ if (!isControlPlanePath(document.repoPath)) {
1557
+ result.conflicts.push(`${document.repoPath}: refusing to write outside root control-plane folders`);
1558
+ continue;
1559
+ }
1560
+ const existing = await readExistingSyncedDocument(fullPath);
1561
+ if (existing.exists && !existing.document) {
1562
+ result.conflicts.push(`${document.repoPath}: file exists without Amistio document metadata`);
1563
+ continue;
1564
+ }
1565
+ if (existing.document && existing.document.frontmatter.amistioDocumentId !== document.documentId) {
1566
+ result.conflicts.push(`${document.repoPath}: file belongs to ${existing.document.frontmatter.amistioDocumentId}`);
1567
+ continue;
1568
+ }
1569
+ if (existing.document && existing.document.contentHash !== existing.document.frontmatter.amistioContentHash && !options.allowDirtyOverwrite) {
1570
+ result.conflicts.push(`${document.repoPath}: local edits would be overwritten`);
1571
+ continue;
1572
+ }
1573
+ if (existing.document && existing.document.frontmatter.amistioRevision >= document.revision && existing.document.contentHash === document.contentHash) {
1574
+ result.skipped.push(document.repoPath);
1575
+ continue;
1576
+ }
1577
+ await mkdir5(path6.dirname(fullPath), { recursive: true });
1578
+ await writeFile5(fullPath, createSyncedDocumentMarkdown(document), "utf8");
1579
+ result.written.push(document.repoPath);
1580
+ }
1581
+ return result;
1582
+ }
1583
+ async function collectDirtyDocumentsForPush(rootDir, metadata) {
1584
+ const localDocuments = await readLocalSyncedDocuments(rootDir);
1585
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1586
+ return localDocuments.filter((document) => document.contentHash !== document.frontmatter.amistioContentHash).map((document) => {
1587
+ const documentType = documentTypeSchema.parse(document.frontmatter.amistioDocumentType);
1588
+ return brainDocumentItemSchema.parse({
1589
+ id: document.frontmatter.amistioDocumentId,
1590
+ type: "brainDocument",
1591
+ schemaVersion: 1,
1592
+ accountId: metadata.amistioAccountId,
1593
+ projectId: metadata.amistioProjectId,
1594
+ documentId: document.frontmatter.amistioDocumentId,
1595
+ documentType,
1596
+ title: inferTitle(document.content, document.repoPath),
1597
+ status: "reviewing",
1598
+ repoPath: document.repoPath,
1599
+ content: document.content,
1600
+ contentHash: document.contentHash,
1601
+ frontmatter: parseFrontmatterFromSyncedDocument(document.frontmatter),
1602
+ revision: document.frontmatter.amistioRevision + 1,
1603
+ source: "repo",
1604
+ syncState: "dirtyInRepo",
1605
+ createdAt: now,
1606
+ updatedAt: now
1607
+ });
1608
+ });
1609
+ }
1610
+ function createSyncedDocumentMarkdown(document) {
1611
+ return [
1612
+ "---",
1613
+ `amistioDocumentId: ${document.documentId}`,
1614
+ `amistioDocumentType: ${document.documentType}`,
1615
+ `amistioRevision: ${document.revision}`,
1616
+ `amistioContentHash: ${document.contentHash}`,
1617
+ `status: ${document.status}`,
1618
+ "---",
1619
+ document.content,
1620
+ ""
1621
+ ].join("\n");
1622
+ }
1623
+ function parseSyncedMarkdown(content) {
1624
+ const rawFrontmatter = parseFrontmatter(content);
1625
+ const frontmatter = syncedDocumentFrontmatterSchema.safeParse(rawFrontmatter);
1626
+ if (!frontmatter.success) {
1627
+ return void 0;
1628
+ }
1629
+ const closingMarker = content.indexOf("\n---", 4);
1630
+ if (closingMarker === -1) {
1631
+ return void 0;
1632
+ }
1633
+ const closingLineEnd = content.indexOf("\n", closingMarker + 4);
1634
+ const bodyStart = closingLineEnd === -1 ? content.length : closingLineEnd + 1;
1635
+ return { frontmatter: frontmatter.data, content: content.slice(bodyStart).replace(/\n$/, "") };
1636
+ }
1637
+ async function readExistingSyncedDocument(fullPath) {
1638
+ try {
1639
+ const raw = await readFile3(fullPath, "utf8");
1640
+ const parsed = parseSyncedMarkdown(raw);
1641
+ if (!parsed) {
1642
+ return { exists: true };
552
1643
  }
553
- const token = await new LocalCredentialStore().get(credentialKey(metadata.amistioAccountId, metadata.amistioProjectId, metadata.repositoryLinkId));
554
1644
  return {
555
- metadata,
556
- ...(token ? { token } : {}),
557
- client: new ApiClient({
558
- apiUrl,
559
- accountId: metadata.amistioAccountId,
560
- ...(token ? { token } : {})
561
- })
1645
+ exists: true,
1646
+ document: {
1647
+ fullPath,
1648
+ repoPath: fullPath,
1649
+ frontmatter: parsed.frontmatter,
1650
+ content: parsed.content,
1651
+ contentHash: sha256ContentHash(parsed.content)
1652
+ }
562
1653
  };
1654
+ } catch (error) {
1655
+ if (error.code === "ENOENT") {
1656
+ return { exists: false };
1657
+ }
1658
+ throw error;
1659
+ }
563
1660
  }
564
- function selectPromptWorkItem(workItems, workItemId) {
565
- if (workItemId) {
566
- return workItems.find((item) => item.workItemId === workItemId || item.id === workItemId);
1661
+ async function findMarkdownFiles(rootDir) {
1662
+ const files = [];
1663
+ for (const syncRoot of syncRoots) {
1664
+ const fullRoot = path6.join(rootDir, syncRoot);
1665
+ if (!await exists2(fullRoot)) {
1666
+ continue;
567
1667
  }
568
- return workItems.find((item) => isPromptReadyStatus(item.status));
1668
+ await walkMarkdownFiles(fullRoot, files);
1669
+ }
1670
+ return files;
569
1671
  }
570
- function isPromptReadyStatus(status) {
571
- return status === "approved" || status === "running" || status === "blocked" || status === "changesRequested";
572
- }
573
- async function prepareToolSession({ apiClient, projectId, repositoryLinkId, runnerId, sessionPolicy, supportsSessionReuse, resumabilityScope, toolName, workItem }) {
574
- const { toolSessions } = await apiClient.listToolSessions(projectId);
575
- const selection = selectToolSession({
576
- policy: sessionPolicy,
577
- workItem,
578
- sessions: toolSessions,
579
- toolName,
580
- runnerId,
581
- repositoryLinkId,
582
- supportsSessionReuse
1672
+ async function walkMarkdownFiles(directory, files) {
1673
+ for (const entry of await readdir2(directory, { withFileTypes: true })) {
1674
+ const fullPath = path6.join(directory, entry.name);
1675
+ if (entry.isDirectory()) {
1676
+ await walkMarkdownFiles(fullPath, files);
1677
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
1678
+ files.push(fullPath);
1679
+ }
1680
+ }
1681
+ }
1682
+ function safeRepoPath(rootDir, repoPath) {
1683
+ if (path6.isAbsolute(repoPath)) {
1684
+ throw new Error(`Refusing to use absolute repo path: ${repoPath}`);
1685
+ }
1686
+ const normalized = path6.normalize(repoPath);
1687
+ if (normalized === ".." || normalized.startsWith(`..${path6.sep}`)) {
1688
+ throw new Error(`Refusing to use path outside the repository: ${repoPath}`);
1689
+ }
1690
+ const root = path6.resolve(rootDir);
1691
+ const fullPath = path6.resolve(root, normalized);
1692
+ if (!fullPath.startsWith(`${root}${path6.sep}`)) {
1693
+ throw new Error(`Refusing to use path outside the repository: ${repoPath}`);
1694
+ }
1695
+ return fullPath;
1696
+ }
1697
+ function isControlPlanePath(repoPath) {
1698
+ const normalized = path6.normalize(repoPath);
1699
+ return syncRoots.some((syncRoot) => normalized === syncRoot || normalized.startsWith(`${syncRoot}${path6.sep}`));
1700
+ }
1701
+ function toRepoPath(rootDir, fullPath) {
1702
+ return path6.relative(rootDir, fullPath).split(path6.sep).join("/");
1703
+ }
1704
+ function inferTitle(content, repoPath) {
1705
+ const heading = content.split("\n").find((line) => line.startsWith("# "))?.replace(/^#\s+/, "").trim();
1706
+ return heading || path6.basename(repoPath, path6.extname(repoPath));
1707
+ }
1708
+ function parseFrontmatterFromSyncedDocument(frontmatter) {
1709
+ return {
1710
+ amistioDocumentId: frontmatter.amistioDocumentId,
1711
+ amistioDocumentType: frontmatter.amistioDocumentType,
1712
+ amistioRevision: frontmatter.amistioRevision,
1713
+ amistioContentHash: frontmatter.amistioContentHash,
1714
+ ...frontmatter.status ? { status: frontmatter.status } : {}
1715
+ };
1716
+ }
1717
+ async function exists2(filePath) {
1718
+ try {
1719
+ await stat3(filePath);
1720
+ return true;
1721
+ } catch {
1722
+ return false;
1723
+ }
1724
+ }
1725
+
1726
+ // src/tool-session-store.ts
1727
+ import { mkdir as mkdir6, readFile as readFile4, writeFile as writeFile6 } from "node:fs/promises";
1728
+ import os3 from "node:os";
1729
+ import path7 from "node:path";
1730
+ var LocalToolSessionStore = class {
1731
+ constructor(filePath = defaultSessionStorePath()) {
1732
+ this.filePath = filePath;
1733
+ }
1734
+ filePath;
1735
+ async getProviderSessionId(toolSessionId, toolName) {
1736
+ const data = await this.read();
1737
+ const record = data[toolSessionId];
1738
+ return record?.toolName === toolName ? record.providerSessionId : void 0;
1739
+ }
1740
+ async setProviderSessionId(toolSessionId, toolName, providerSessionId) {
1741
+ const data = await this.read();
1742
+ data[toolSessionId] = { toolName, providerSessionId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
1743
+ await mkdir6(path7.dirname(this.filePath), { recursive: true });
1744
+ await writeFile6(this.filePath, JSON.stringify(data, null, 2), "utf8");
1745
+ }
1746
+ async read() {
1747
+ try {
1748
+ return JSON.parse(await readFile4(this.filePath, "utf8"));
1749
+ } catch {
1750
+ return {};
1751
+ }
1752
+ }
1753
+ };
1754
+ function defaultSessionStorePath() {
1755
+ if (process.platform === "darwin") {
1756
+ return path7.join(os3.homedir(), "Library", "Application Support", "Amistio", "tool-sessions.json");
1757
+ }
1758
+ if (process.platform === "win32") {
1759
+ return path7.join(process.env.APPDATA ?? os3.homedir(), "Amistio", "tool-sessions.json");
1760
+ }
1761
+ return path7.join(process.env.XDG_STATE_HOME ?? path7.join(os3.homedir(), ".local", "state"), "amistio", "tool-sessions.json");
1762
+ }
1763
+
1764
+ // src/work-runner.ts
1765
+ var generationResultStart = "AMISTIO_BRAIN_GENERATION_RESULT_START";
1766
+ var generationResultEnd = "AMISTIO_BRAIN_GENERATION_RESULT_END";
1767
+ function createWorkExecutionPrompt(workItem) {
1768
+ if (workItem.workKind === "brainGeneration") {
1769
+ return createBrainGenerationPrompt(workItem);
1770
+ }
1771
+ return [
1772
+ "# Amistio Work Execution",
1773
+ "",
1774
+ "You are running locally through the Amistio CLI inside the user's repository.",
1775
+ "Execute the approved work item below using the repository's existing orchestration files, instructions, and constraints.",
1776
+ "",
1777
+ "## Work Item",
1778
+ "",
1779
+ `Title: ${workItem.title}`,
1780
+ `Work item ID: ${workItem.workItemId}`,
1781
+ `Project ID: ${workItem.projectId}`,
1782
+ "",
1783
+ "## Rules",
1784
+ "",
1785
+ "- Read AGENTS.md first when it exists.",
1786
+ "- Read the relevant root control-plane files before implementation so the work stays aligned with existing direction.",
1787
+ "- Keep changes focused on this work item.",
1788
+ "- Preserve old decisions, plans, memory, and prompts unless the work item explicitly supersedes them.",
1789
+ "- Do not commit changes.",
1790
+ "- Do not write secrets into the repository.",
1791
+ "- Do not create a repo-local .amistio folder.",
1792
+ "- Run relevant verification commands when feasible and summarize results."
1793
+ ].join("\n");
1794
+ }
1795
+ function parseBrainGenerationArtifacts(output) {
1796
+ const start = output.indexOf(generationResultStart);
1797
+ const end = output.indexOf(generationResultEnd, start + generationResultStart.length);
1798
+ if (start === -1 || end === -1 || end <= start) {
1799
+ throw new Error("Local AI generation did not return an Amistio brain generation result block.");
1800
+ }
1801
+ const payload = output.slice(start + generationResultStart.length, end).trim();
1802
+ const parsed = JSON.parse(stripJsonFence(payload));
1803
+ return brainGenerationResultSchema.parse(parsed).artifacts;
1804
+ }
1805
+ function createBrainGenerationPrompt(workItem) {
1806
+ const wish = workItem.sourceWish ?? workItem.title;
1807
+ return [
1808
+ "# Amistio Brain Generation",
1809
+ "",
1810
+ "You are running locally through the Amistio CLI inside the user's repository.",
1811
+ "Generate reviewable project-brain artifacts from the submitted wish. Do not implement product/source code changes in this pass.",
1812
+ "",
1813
+ "## Submitted Wish",
1814
+ "",
1815
+ wish,
1816
+ "",
1817
+ "## Work Item",
1818
+ "",
1819
+ `Title: ${workItem.title}`,
1820
+ `Work item ID: ${workItem.workItemId}`,
1821
+ `Project ID: ${workItem.projectId}`,
1822
+ `Generated draft ID: ${workItem.generatedDraftId ?? "unknown"}`,
1823
+ "",
1824
+ "## Read First",
1825
+ "",
1826
+ "- Read AGENTS.md first when it exists.",
1827
+ "- Read relevant files under architecture/, context/, decisions/, features/, memory/, plans/, prompts/, and workflows/ before drafting.",
1828
+ "- Keep source code and secrets local. Do not include source-code payloads, access tokens, API keys, local credential paths, or provider session references in the result.",
1829
+ "",
1830
+ "## Generate Artifacts",
1831
+ "",
1832
+ "Return Markdown artifacts that should enter human review before implementation. Use only these document types and matching repo roots:",
1833
+ "",
1834
+ "- architecture -> architecture/",
1835
+ "- context -> context/",
1836
+ "- decision -> decisions/",
1837
+ "- feature -> features/",
1838
+ "- memory -> memory/",
1839
+ "- plan -> plans/",
1840
+ "- prompt -> prompts/",
1841
+ "- workflow -> workflows/",
1842
+ "",
1843
+ "Each artifact needs documentType, title, repoPath, and content. Include at least a plan and an implementation prompt when implementation work is implied. Use model-agnostic prompt wording.",
1844
+ "",
1845
+ "## Output Contract",
1846
+ "",
1847
+ "Print exactly one JSON object between the markers below. The CLI will submit only this structured result back to Amistio.",
1848
+ "",
1849
+ generationResultStart,
1850
+ '{"artifacts":[{"documentType":"plan","title":"Plan: Example","repoPath":"plans/PLAN-example.md","content":"# Plan: Example\\n\\n## Goal\\n..."}]}',
1851
+ generationResultEnd,
1852
+ "",
1853
+ "Do not put Markdown fences around the markers. Do not claim implementation is complete."
1854
+ ].join("\n");
1855
+ }
1856
+ function stripJsonFence(value) {
1857
+ const trimmed = value.trim();
1858
+ if (!trimmed.startsWith("```")) {
1859
+ return trimmed;
1860
+ }
1861
+ return trimmed.replace(/^```(?:json)?\s*/i, "").replace(/```$/i, "").trim();
1862
+ }
1863
+
1864
+ // src/index.ts
1865
+ var program = new Command();
1866
+ var defaultRoot = process.env.INIT_CWD ?? process.cwd();
1867
+ var apiUrlOptionDescription = `Amistio API URL override (or ${AMISTIO_API_URL_ENV})`;
1868
+ program.name("amistio").description("Amistio project brain CLI").version("0.1.1");
1869
+ var CLI_VERSION = "0.1.1";
1870
+ program.command("init").description("Create Amistio control-plane folders for a new project").option("--root <path>", "Repository root", defaultRoot).action(async (options) => {
1871
+ const created = await initControlPlane(options.root);
1872
+ console.log(created.length ? `Created ${created.length} control-plane folders.` : "Control-plane folders already exist.");
1873
+ });
1874
+ program.command("onboard").description("Inspect and prepare an existing repository for Amistio").option("--root <path>", "Repository root", defaultRoot).action(async (options) => {
1875
+ const result = await inspectControlPlane(options.root);
1876
+ console.log(`Present: ${result.present.length ? result.present.join(", ") : "none"}`);
1877
+ console.log(`Missing: ${result.missing.length ? result.missing.join(", ") : "none"}`);
1878
+ if (result.missing.length) {
1879
+ console.log("Run `amistio init` to create missing control-plane folders without overwriting existing files.");
1880
+ }
1881
+ });
1882
+ program.command("bootstrap").description("Clone a linked repository locally, prepare the control plane, and pair it with Amistio").requiredOption("--repo-url <url>", "Linked repository clone URL").requiredOption("--target <path>", "Local checkout target path").requiredOption("--account <accountId>", "Amistio account ID").requiredOption("--project <projectId>", "Amistio project ID").requiredOption("--repository-link <repositoryLinkId>", "Existing repository link ID").option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl()).requiredOption("--pairing-code <code>", "Short-lived pairing code from the Amistio app").option("--default-branch <branch>", "Default branch", "main").action(async (options) => {
1883
+ const parsedRepoUrl = parseRepositoryCloneUrl(options.repoUrl);
1884
+ const checkout = await cloneOrValidateRepository({ repoUrl: parsedRepoUrl.cloneUrl, targetDir: options.target });
1885
+ await initControlPlane(checkout.targetDir);
1886
+ const pairing = await new ApiClient({
1887
+ apiUrl: options.apiUrl,
1888
+ accountId: options.account
1889
+ }).consumePairingSession({
1890
+ projectId: options.project,
1891
+ pairingCode: options.pairingCode,
1892
+ repositoryLinkId: options.repositoryLink,
1893
+ repoName: parsedRepoUrl.repoName,
1894
+ repoFingerprint: createRepoFingerprint(options.account, options.project, options.repositoryLink),
1895
+ defaultBranch: options.defaultBranch
1896
+ });
1897
+ const filePath = await writeProjectLink(checkout.targetDir, {
1898
+ amistioAccountId: options.account,
1899
+ amistioProjectId: options.project,
1900
+ repositoryLinkId: pairing.repositoryLink.repositoryLinkId,
1901
+ defaultBranch: options.defaultBranch,
1902
+ lastSyncedRevision: 0
1903
+ });
1904
+ await new LocalCredentialStore().set(credentialKey(options.account, options.project, pairing.repositoryLink.repositoryLinkId), pairing.token);
1905
+ console.log(checkout.status === "cloned" ? `Cloned repository to ${checkout.targetDir}.` : `Validated existing checkout at ${checkout.targetDir}.`);
1906
+ console.log(`Pairing confirmed for ${pairing.repositoryLink.repoName}.`);
1907
+ console.log(`Wrote non-secret project metadata to ${filePath}.`);
1908
+ console.log(`Next: cd ${formatShellArg(checkout.targetDir)} && amistio run${formatApiUrlFlag(options.apiUrl)} --watch`);
1909
+ });
1910
+ program.command("pair").description("Pair this repository with an Amistio web project").requiredOption("--account <accountId>", "Amistio account ID").requiredOption("--project <projectId>", "Amistio project ID").option("--repository-link <repositoryLinkId>", "Existing repository link ID").option("--default-branch <branch>", "Default branch", "main").option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl()).option("--pairing-code <code>", "Short-lived pairing code from the Amistio app").option("--token <token>", "Runner/device credential to store outside the repository").option("--root <path>", "Repository root", defaultRoot).action(async (options, command) => {
1911
+ let repositoryLinkId = options.repositoryLink ?? `repo_${randomUUID()}`;
1912
+ let credential = options.token;
1913
+ if (options.pairingCode) {
1914
+ const pairing = await new ApiClient({
1915
+ apiUrl: options.apiUrl,
1916
+ accountId: options.account,
1917
+ ...credential ? { token: credential } : {}
1918
+ }).consumePairingSession({
1919
+ projectId: options.project,
1920
+ pairingCode: options.pairingCode,
1921
+ repositoryLinkId,
1922
+ repoName: inferRepoName(options.root),
1923
+ repoFingerprint: createRepoFingerprint(options.account, options.project, repositoryLinkId),
1924
+ defaultBranch: options.defaultBranch
583
1925
  });
584
- if (selection.decision === "skipped") {
585
- return selection;
586
- }
587
- if (selection.toolSession) {
588
- const { toolSession } = await apiClient.updateToolSession(projectId, selection.toolSession.toolSessionId, {
589
- status: "active",
590
- runnerId,
591
- lastWorkItemId: workItem.workItemId,
592
- reusePolicy: sessionPolicy
593
- });
594
- return { ...selection, toolSession };
595
- }
596
- const toolSessionId = `tool_session_${randomUUID()}`;
597
- const { toolSession } = await apiClient.createToolSession(projectId, {
598
- toolSessionId,
599
- repositoryLinkId,
600
- tool: toolName,
601
- provider: toolName,
602
- resumabilityScope: supportsSessionReuse ? resumabilityScope : "none",
603
- title: workItem.title,
604
- summary: selection.reason,
605
- status: "active",
606
- runnerId,
607
- lastWorkItemId: workItem.workItemId,
608
- messageCount: 0,
609
- reusePolicy: sessionPolicy,
610
- ...(workItem.sessionGroupKey ? { sessionGroupKey: workItem.sessionGroupKey } : {})
1926
+ repositoryLinkId = pairing.repositoryLink.repositoryLinkId;
1927
+ credential = credential ?? pairing.token;
1928
+ console.log(`Pairing confirmed for ${pairing.repositoryLink.repoName}.`);
1929
+ }
1930
+ const filePath = await writeProjectLink(options.root, {
1931
+ amistioAccountId: options.account,
1932
+ amistioProjectId: options.project,
1933
+ repositoryLinkId,
1934
+ defaultBranch: options.defaultBranch,
1935
+ lastSyncedRevision: 0
1936
+ });
1937
+ if (credential) {
1938
+ await new LocalCredentialStore().set(credentialKey(options.account, options.project, repositoryLinkId), credential);
1939
+ }
1940
+ if (!options.pairingCode && apiUrlOverrideWasRequested(command)) {
1941
+ const session = await new ApiClient({
1942
+ apiUrl: options.apiUrl,
1943
+ accountId: options.account,
1944
+ ...credential ? { token: credential } : {}
1945
+ }).createPairingSession(options.project);
1946
+ console.log(`Pairing code: ${session.pairingCode}`);
1947
+ console.log(`Expires at: ${session.expiresAt}`);
1948
+ }
1949
+ console.log(`Wrote non-secret project metadata to ${filePath}.`);
1950
+ });
1951
+ var sync = program.command("sync").description("Inspect or move project brain changes between the repo and Amistio");
1952
+ sync.command("status").description("Show local sync status").option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl()).option("--root <path>", "Repository root", defaultRoot).action(async (options) => {
1953
+ const metadata = await readProjectLink(options.root);
1954
+ if (!metadata) {
1955
+ console.log("Repository is not paired. Run `amistio pair` first.");
1956
+ return;
1957
+ }
1958
+ const context = await loadPairedApiContext(options.root, options.apiUrl);
1959
+ const webDocuments = context ? await context.client.listBrainDocuments(metadata.amistioProjectId).then((result) => result.documents).catch(() => []) : [];
1960
+ const report = await collectSyncStatus(options.root, webDocuments);
1961
+ console.log(`Paired project: ${metadata.amistioProjectId}`);
1962
+ console.log(`Repository link: ${metadata.repositoryLinkId}`);
1963
+ console.log(`Last synced revision: ${metadata.lastSyncedRevision}`);
1964
+ console.log(`Sync status: ${report.status}`);
1965
+ console.log(`Clean: ${report.counts.clean}; Pending: ${report.counts.pending}; Dirty: ${report.counts.dirty}; Conflicted: ${report.counts.conflicted}`);
1966
+ for (const item of report.items.filter((entry) => entry.status !== "clean")) {
1967
+ console.log(`${item.status}: ${item.repoPath} - ${item.reason}`);
1968
+ }
1969
+ });
1970
+ sync.command("pull").description("Pull approved web changes into the repository").option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl()).option("--root <path>", "Repository root", defaultRoot).action(async (options) => {
1971
+ const context = await loadPairedApiContext(options.root, options.apiUrl);
1972
+ if (!context) {
1973
+ console.log("Repository is not paired. Run `amistio pair` first.");
1974
+ return;
1975
+ }
1976
+ const { documents } = await context.client.listBrainDocuments(context.metadata.amistioProjectId);
1977
+ const approvedDocuments = documents.filter((document) => document.syncState === "approved" || document.syncState === "synced");
1978
+ const result = await materializeBrainDocuments(options.root, approvedDocuments);
1979
+ for (const conflict of result.conflicts) {
1980
+ console.log(`conflicted: ${conflict}`);
1981
+ }
1982
+ if (result.conflicts.length) {
1983
+ process.exitCode = 1;
1984
+ return;
1985
+ }
1986
+ const latestRevision = Math.max(context.metadata.lastSyncedRevision, ...approvedDocuments.map((document) => document.revision));
1987
+ if (latestRevision !== context.metadata.lastSyncedRevision) {
1988
+ await writeProjectLink(options.root, { ...context.metadata, lastSyncedRevision: latestRevision });
1989
+ }
1990
+ console.log(result.written.length ? `Pulled ${result.written.length} approved document${result.written.length === 1 ? "" : "s"}.` : "No approved web changes were pulled.");
1991
+ });
1992
+ sync.command("push").description("Push local brain changes to Amistio for review").option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl()).option("--root <path>", "Repository root", defaultRoot).action(async (options) => {
1993
+ const context = await loadPairedApiContext(options.root, options.apiUrl);
1994
+ if (!context) {
1995
+ console.log("Repository is not paired. Run `amistio pair` first.");
1996
+ return;
1997
+ }
1998
+ const dirtyDocuments = await collectDirtyDocumentsForPush(options.root, context.metadata);
1999
+ if (!dirtyDocuments.length) {
2000
+ console.log("No local brain changes were pushed.");
2001
+ return;
2002
+ }
2003
+ const { documents } = await context.client.pushBrainDocuments(context.metadata.amistioProjectId, dirtyDocuments);
2004
+ const conflictedDocuments = documents.filter((document) => document.syncState === "conflicted" || document.status === "conflicted");
2005
+ if (conflictedDocuments.length) {
2006
+ for (const document of conflictedDocuments) {
2007
+ console.log(`conflicted: ${document.repoPath} - web and repository revisions both changed`);
2008
+ }
2009
+ process.exitCode = 1;
2010
+ return;
2011
+ }
2012
+ await materializeBrainDocuments(options.root, documents, { allowDirtyOverwrite: true });
2013
+ console.log(`Pushed ${documents.length} local document${documents.length === 1 ? "" : "s"} for web review.`);
2014
+ });
2015
+ var work = program.command("work").description("Inspect approved work items");
2016
+ work.command("list").description("List queued work without claiming it").option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl()).option("--root <path>", "Repository root", defaultRoot).action(async (options) => {
2017
+ const context = await loadPairedApiContext(options.root, options.apiUrl);
2018
+ if (!context) {
2019
+ console.log("Repository is not paired. Run `amistio pair` first.");
2020
+ return;
2021
+ }
2022
+ const { workItems } = await context.client.listWorkItems(context.metadata.amistioProjectId);
2023
+ if (!workItems.length) {
2024
+ console.log("No work items are queued for this project.");
2025
+ return;
2026
+ }
2027
+ for (const item of workItems) {
2028
+ console.log(`${item.workItemId} [${item.status}] ${item.title}`);
2029
+ }
2030
+ });
2031
+ work.command("prompt").description("Print or write an approved work prompt without claiming a runner lease").argument("[workItemId]", "Work item ID. Defaults to the newest approved work item.").option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl()).option("--root <path>", "Repository root", defaultRoot).option("--out <path>", "Write the prompt to a file instead of stdout").action(async (workItemId, options) => {
2032
+ const context = await loadPairedApiContext(options.root, options.apiUrl);
2033
+ if (!context) {
2034
+ console.log("Repository is not paired. Run `amistio pair` first.");
2035
+ return;
2036
+ }
2037
+ const { workItems } = await context.client.listWorkItems(context.metadata.amistioProjectId);
2038
+ const workItem = selectPromptWorkItem(workItems, workItemId);
2039
+ if (!workItem) {
2040
+ console.log(workItemId ? `No work item found for ${workItemId}.` : "No approved work item is ready for prompt export.");
2041
+ return;
2042
+ }
2043
+ const prompt = createWorkExecutionPrompt(workItem);
2044
+ if (options.out) {
2045
+ await writeFile7(options.out, prompt, "utf8");
2046
+ console.log(`Wrote work prompt to ${options.out}.`);
2047
+ } else {
2048
+ console.log(prompt);
2049
+ }
2050
+ });
2051
+ program.command("tools").description("List local AI coding tools that the Amistio CLI can use").action(async () => {
2052
+ const tools = await detectLocalTools();
2053
+ for (const tool of tools) {
2054
+ const mode = tool.execution === "sdk" ? "sdk" : tool.execution === "command" ? "cli" : "--";
2055
+ console.log(`${tool.available ? "yes" : "no "} ${mode} ${tool.name} - ${tool.description}`);
2056
+ }
2057
+ console.log("custom - pass --tool-command to use any other local runner command.");
2058
+ });
2059
+ program.command("orchestrate").description("Update the Amistio control plane through a user-installed local AI tool").argument("[goal...]", "Goal or next-step instruction for the orchestration pass").option("--root <path>", "Repository root", defaultRoot).option("--tool <name>", "Local tool to use: auto, none, opencode, claude, codex, copilot, gemini, aider, cursor-agent", "auto").option("--tool-command <command>", "Custom local command. Use {promptFile} and {root} placeholders when supported").option("--session <policy>", "Tool session policy: auto, new, continue:<toolSessionId>, or none", "auto").option("--prompt-out <path>", "Write the generated orchestration prompt to a file before running").option("--dry-run", "Print the generated orchestration prompt without running a tool").option("--no-stream", "Capture local tool output instead of streaming it").action(async (goalParts, options) => {
2060
+ const goal = goalParts?.join(" ").trim() || "Review the current repository state and update the Amistio control plane with the next useful orchestration steps.";
2061
+ const prompt = await createOrchestrationPrompt({ rootDir: options.root, goal });
2062
+ if (options.promptOut) {
2063
+ const promptPath = await writePromptFile(options.promptOut, prompt);
2064
+ console.log(`Wrote orchestration prompt to ${promptPath}.`);
2065
+ }
2066
+ if (options.dryRun || options.tool === "none") {
2067
+ console.log(prompt);
2068
+ return;
2069
+ }
2070
+ const sessionPolicy = normalizeSessionPolicy(options.session);
2071
+ const preview = await createToolRunPreview({ rootDir: options.root, prompt, tool: options.tool, ...options.toolCommand ? { toolCommand: options.toolCommand } : {} });
2072
+ console.log(`Running ${preview.toolName}: ${preview.displayCommand}`);
2073
+ const result = await runLocalTool({
2074
+ rootDir: options.root,
2075
+ prompt,
2076
+ tool: options.tool,
2077
+ ...options.toolCommand ? { toolCommand: options.toolCommand } : {},
2078
+ streamOutput: options.stream,
2079
+ ...sessionPolicy === "none" ? {} : { session: { toolSessionId: `local_orchestration_${randomUUID()}`, policy: sessionPolicy, decision: localSessionDecision(sessionPolicy) } }
2080
+ });
2081
+ if (!options.stream && result.stdout.trim()) {
2082
+ console.log(result.stdout.trim());
2083
+ }
2084
+ if (!options.stream && result.stderr.trim()) {
2085
+ console.error(result.stderr.trim());
2086
+ }
2087
+ if (result.exitCode !== 0) {
2088
+ process.exitCode = result.exitCode;
2089
+ }
2090
+ });
2091
+ program.command("run").description("Claim and run approved Amistio work locally").option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl()).option("--runner-id <runnerId>", "Stable runner ID", `runner_${randomUUID()}`).option("--root <path>", "Repository root", defaultRoot).option("--tool <name>", "Local tool to use: auto, none, opencode, claude, codex, copilot, gemini, aider, cursor-agent", "auto").option("--tool-command <command>", "Custom local command. Use {promptFile} and {root} placeholders when supported").option("--session <policy>", "Tool session policy: auto, new, continue:<toolSessionId>, or none", "auto").option("--dry-run", "Claim work and print the generated execution prompt without running a tool").option("--watch", "Keep polling for approved work until stopped").option("--interval-seconds <seconds>", "Polling interval for --watch", parsePositiveInteger, 10).option("--max-iterations <count>", "Stop watch mode after this many polling attempts", parsePositiveInteger).option("--no-stream", "Capture local tool output instead of streaming it").action(async (options) => {
2092
+ const context = await loadPairedApiContext(options.root, options.apiUrl);
2093
+ if (!context) {
2094
+ console.log("Repository is not paired. Run `amistio pair` first.");
2095
+ return;
2096
+ }
2097
+ if (!context.token) {
2098
+ console.log("No local runner credential found. Run `amistio pair --pairing-code <code>` to store this machine credential.");
2099
+ process.exitCode = 1;
2100
+ return;
2101
+ }
2102
+ if (options.watch) {
2103
+ console.log(`Runner ${options.runnerId} is watching ${context.metadata.amistioProjectId} every ${options.intervalSeconds}s. Press Ctrl+C to stop.`);
2104
+ }
2105
+ let iterations = 0;
2106
+ while (true) {
2107
+ iterations += 1;
2108
+ const result = await runNextWorkItem({
2109
+ apiClient: context.client,
2110
+ projectId: context.metadata.amistioProjectId,
2111
+ repositoryLinkId: context.metadata.repositoryLinkId,
2112
+ runnerId: options.runnerId,
2113
+ root: options.root,
2114
+ sessionPolicy: normalizeSessionPolicy(options.session),
2115
+ tool: options.tool,
2116
+ ...options.toolCommand ? { toolCommand: options.toolCommand } : {},
2117
+ dryRun: Boolean(options.dryRun),
2118
+ stream: options.stream
611
2119
  });
612
- return { ...selection, toolSession };
613
- }
614
- async function finalizeToolSession({ apiClient, costUsd, messageCount, projectId, runnerId, session, status, stdout, tokensIn, tokensOut, workItemId }) {
615
- if (!session) {
616
- return undefined;
617
- }
618
- const summary = summarizeToolOutput(stdout) ?? session.summary;
619
- const { toolSession } = await apiClient.updateToolSession(projectId, session.toolSessionId, {
620
- status: status === "completed" ? "open" : "blocked",
621
- runnerId,
622
- lastWorkItemId: workItemId,
623
- messageCount: (session.messageCount ?? 0) + (messageCount ?? 1),
624
- ...(summary ? { summary } : {}),
625
- ...(tokensIn !== undefined ? { estimatedInputTokens: (session.estimatedInputTokens ?? 0) + tokensIn } : {}),
626
- ...(tokensOut !== undefined ? { estimatedOutputTokens: (session.estimatedOutputTokens ?? 0) + tokensOut } : {}),
627
- ...(costUsd !== undefined ? { costUsd: (session.costUsd ?? 0) + costUsd } : {}),
628
- ...(status === "failed" ? { closedReason: "Last run failed or returned a non-zero exit code." } : {})
2120
+ if (!options.watch || options.dryRun) {
2121
+ if (result.exitCode !== 0) {
2122
+ process.exitCode = result.exitCode;
2123
+ }
2124
+ return;
2125
+ }
2126
+ if (options.maxIterations !== void 0 && iterations >= options.maxIterations) {
2127
+ console.log(`Runner stopped after ${iterations} polling attempt${iterations === 1 ? "" : "s"}.`);
2128
+ return;
2129
+ }
2130
+ if (result.status === "idle") {
2131
+ console.log(`No approved work item is available. Checking again in ${options.intervalSeconds}s.`);
2132
+ }
2133
+ await delay(options.intervalSeconds * 1e3);
2134
+ }
2135
+ });
2136
+ async function runNextWorkItem({
2137
+ apiClient,
2138
+ dryRun,
2139
+ projectId,
2140
+ repositoryLinkId,
2141
+ root,
2142
+ runnerId,
2143
+ sessionPolicy,
2144
+ stream,
2145
+ tool,
2146
+ toolCommand
2147
+ }) {
2148
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", CLI_VERSION);
2149
+ const result = await apiClient.claimWork(projectId, runnerId, repositoryLinkId);
2150
+ if (!result.workItem) {
2151
+ console.log("No approved work item is available.");
2152
+ return { status: "idle", exitCode: 0 };
2153
+ }
2154
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "running", CLI_VERSION);
2155
+ const prompt = createWorkExecutionPrompt(result.workItem);
2156
+ if (dryRun || tool === "none") {
2157
+ console.log(prompt);
2158
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", CLI_VERSION);
2159
+ return { status: "preview", exitCode: 0 };
2160
+ }
2161
+ const preview = await createToolRunPreview({ rootDir: root, prompt, tool, ...toolCommand ? { toolCommand } : {} });
2162
+ const sessionContext = await prepareToolSession({
2163
+ apiClient,
2164
+ projectId,
2165
+ repositoryLinkId,
2166
+ runnerId,
2167
+ sessionPolicy: result.workItem.sessionPolicy ?? sessionPolicy,
2168
+ toolName: preview.toolName,
2169
+ supportsSessionReuse: preview.supportsSessionReuse,
2170
+ resumabilityScope: preview.resumabilityScope,
2171
+ workItem: result.workItem
2172
+ });
2173
+ console.log(`Claimed ${result.workItem.workItemId}. Running ${preview.toolName}: ${preview.displayCommand}`);
2174
+ const startedAt = Date.now();
2175
+ const providerSessionStore = new LocalToolSessionStore();
2176
+ const providerSessionId = sessionContext.toolSession ? await providerSessionStore.getProviderSessionId(sessionContext.toolSession.toolSessionId, preview.toolName) : void 0;
2177
+ const toolResult = await runLocalTool({
2178
+ rootDir: root,
2179
+ prompt,
2180
+ tool,
2181
+ ...toolCommand ? { toolCommand } : {},
2182
+ streamOutput: stream,
2183
+ ...sessionContext.toolSession ? {
2184
+ session: {
2185
+ toolSessionId: sessionContext.toolSession.toolSessionId,
2186
+ policy: sessionContext.policy,
2187
+ decision: sessionContext.decision,
2188
+ ...providerSessionId ? { providerSessionId } : {}
2189
+ }
2190
+ } : {}
2191
+ }).catch(async (error) => {
2192
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "blocked", CLI_VERSION);
2193
+ await markToolSessionBlocked(apiClient, projectId, sessionContext.toolSession, errorMessage2(error));
2194
+ throw error;
2195
+ });
2196
+ if (sessionContext.toolSession && toolResult.providerSessionId) {
2197
+ await providerSessionStore.setProviderSessionId(sessionContext.toolSession.toolSessionId, preview.toolName, toolResult.providerSessionId);
2198
+ }
2199
+ if (!stream && toolResult.stdout.trim()) {
2200
+ console.log(toolResult.stdout.trim());
2201
+ }
2202
+ if (!stream && toolResult.stderr.trim()) {
2203
+ console.error(toolResult.stderr.trim());
2204
+ }
2205
+ if (result.workItem.workKind === "brainGeneration") {
2206
+ return finalizeBrainGenerationWork({
2207
+ apiClient,
2208
+ durationMs: Date.now() - startedAt,
2209
+ projectId,
2210
+ repositoryLinkId,
2211
+ runnerId,
2212
+ sessionContext,
2213
+ toolName: preview.toolName,
2214
+ toolResult,
2215
+ workItem: result.workItem
629
2216
  });
630
- return toolSession;
2217
+ }
2218
+ const finalStatus = toolResult.exitCode === 0 ? "completed" : "failed";
2219
+ const durationMs = Date.now() - startedAt;
2220
+ const failureExcerpt = toolResult.exitCode === 0 ? void 0 : truncateLogExcerpt(toolResult.stderr || toolResult.stdout);
2221
+ const updatedToolSession = await finalizeToolSession({
2222
+ apiClient,
2223
+ projectId,
2224
+ status: finalStatus,
2225
+ runnerId,
2226
+ workItemId: result.workItem.workItemId,
2227
+ stdout: toolResult.stdout,
2228
+ ...sessionContext.toolSession ? { session: sessionContext.toolSession } : {},
2229
+ ...toolResult.messageCount !== void 0 ? { messageCount: toolResult.messageCount } : {},
2230
+ ...toolResult.tokensIn !== void 0 ? { tokensIn: toolResult.tokensIn } : {},
2231
+ ...toolResult.tokensOut !== void 0 ? { tokensOut: toolResult.tokensOut } : {},
2232
+ ...toolResult.costUsd !== void 0 ? { costUsd: toolResult.costUsd } : {}
2233
+ });
2234
+ const statusResult = await apiClient.updateWorkStatus(
2235
+ projectId,
2236
+ result.workItem.workItemId,
2237
+ finalStatus,
2238
+ `run_${result.workItem.workItemId}_${randomUUID()}`,
2239
+ runnerId,
2240
+ {
2241
+ tool: preview.toolName,
2242
+ durationMs,
2243
+ message: `${preview.toolName} exited with code ${toolResult.exitCode}.`,
2244
+ sessionPolicy: sessionContext.policy,
2245
+ sessionDecision: sessionContext.decision,
2246
+ sessionDecisionReason: sessionContext.reason,
2247
+ ...updatedToolSession ? { toolSessionId: updatedToolSession.toolSessionId } : {},
2248
+ ...updatedToolSession?.sessionGroupKey ? { sessionGroupKey: updatedToolSession.sessionGroupKey } : {},
2249
+ ...toolResult.tokensIn !== void 0 ? { tokensIn: toolResult.tokensIn } : {},
2250
+ ...toolResult.tokensOut !== void 0 ? { tokensOut: toolResult.tokensOut } : {},
2251
+ ...toolResult.costUsd !== void 0 ? { costUsd: toolResult.costUsd } : {},
2252
+ ...failureExcerpt ? { error: failureExcerpt } : {}
2253
+ }
2254
+ );
2255
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", CLI_VERSION);
2256
+ const durationSeconds = Math.round(durationMs / 1e3);
2257
+ console.log(`Marked ${statusResult.workItem.workItemId} ${statusResult.workItem.status} after ${durationSeconds}s.`);
2258
+ return { status: finalStatus, exitCode: toolResult.exitCode };
631
2259
  }
632
- async function markToolSessionBlocked(apiClient, projectId, session, reason) {
633
- if (!session) {
634
- return;
2260
+ async function finalizeBrainGenerationWork({
2261
+ apiClient,
2262
+ durationMs,
2263
+ projectId,
2264
+ repositoryLinkId,
2265
+ runnerId,
2266
+ sessionContext,
2267
+ toolName,
2268
+ toolResult,
2269
+ workItem
2270
+ }) {
2271
+ let artifacts;
2272
+ let generationError;
2273
+ if (toolResult.exitCode === 0) {
2274
+ try {
2275
+ artifacts = parseBrainGenerationArtifacts(`${toolResult.stdout}
2276
+ ${toolResult.stderr}`);
2277
+ } catch (error) {
2278
+ generationError = errorMessage2(error);
635
2279
  }
636
- await apiClient.updateToolSession(projectId, session.toolSessionId, {
637
- status: "blocked",
638
- summary: reason,
639
- closedReason: reason
2280
+ } else {
2281
+ generationError = truncateLogExcerpt(toolResult.stderr || toolResult.stdout) || `${toolName} exited with code ${toolResult.exitCode}.`;
2282
+ }
2283
+ const finalStatus = artifacts ? "completed" : "failed";
2284
+ const updatedToolSession = await finalizeToolSession({
2285
+ apiClient,
2286
+ projectId,
2287
+ status: finalStatus,
2288
+ runnerId,
2289
+ workItemId: workItem.workItemId,
2290
+ stdout: toolResult.stdout,
2291
+ ...sessionContext.toolSession ? { session: sessionContext.toolSession } : {},
2292
+ ...toolResult.messageCount !== void 0 ? { messageCount: toolResult.messageCount } : {},
2293
+ ...toolResult.tokensIn !== void 0 ? { tokensIn: toolResult.tokensIn } : {},
2294
+ ...toolResult.tokensOut !== void 0 ? { tokensOut: toolResult.tokensOut } : {},
2295
+ ...toolResult.costUsd !== void 0 ? { costUsd: toolResult.costUsd } : {}
2296
+ });
2297
+ const sessionTelemetry = {
2298
+ sessionPolicy: sessionContext.policy,
2299
+ sessionDecision: sessionContext.decision,
2300
+ sessionDecisionReason: sessionContext.reason,
2301
+ ...updatedToolSession ? { toolSessionId: updatedToolSession.toolSessionId } : {},
2302
+ ...updatedToolSession?.sessionGroupKey ? { sessionGroupKey: updatedToolSession.sessionGroupKey } : {}
2303
+ };
2304
+ if (artifacts) {
2305
+ const result = await apiClient.submitBrainGenerationResult(projectId, workItem.workItemId, {
2306
+ status: "completed",
2307
+ runnerId,
2308
+ idempotencyKey: `generation_${workItem.workItemId}_${randomUUID()}`,
2309
+ artifacts,
2310
+ tool: toolName,
2311
+ durationMs,
2312
+ ...sessionTelemetry,
2313
+ message: `${toolName} generated ${artifacts.length} brain artifact${artifacts.length === 1 ? "" : "s"}.`
640
2314
  });
2315
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", CLI_VERSION);
2316
+ console.log(`Generated ${result.documents.length} brain artifact${result.documents.length === 1 ? "" : "s"} for review.`);
2317
+ return { status: "completed", exitCode: 0 };
2318
+ }
2319
+ await apiClient.submitBrainGenerationResult(projectId, workItem.workItemId, {
2320
+ status: "failed",
2321
+ runnerId,
2322
+ idempotencyKey: `generation_${workItem.workItemId}_${randomUUID()}`,
2323
+ tool: toolName,
2324
+ durationMs,
2325
+ ...sessionTelemetry,
2326
+ message: `${toolName} did not produce valid brain artifacts.`,
2327
+ ...generationError ? { error: generationError } : {}
2328
+ });
2329
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", CLI_VERSION);
2330
+ console.error(generationError ?? "Local runner generation failed.");
2331
+ return { status: "failed", exitCode: toolResult.exitCode || 1 };
2332
+ }
2333
+ async function loadPairedApiContext(root, apiUrl) {
2334
+ const metadata = await readProjectLink(root);
2335
+ if (!metadata) {
2336
+ return void 0;
2337
+ }
2338
+ const token = await new LocalCredentialStore().get(
2339
+ credentialKey(metadata.amistioAccountId, metadata.amistioProjectId, metadata.repositoryLinkId)
2340
+ );
2341
+ return {
2342
+ metadata,
2343
+ ...token ? { token } : {},
2344
+ client: new ApiClient({
2345
+ apiUrl,
2346
+ accountId: metadata.amistioAccountId,
2347
+ ...token ? { token } : {}
2348
+ })
2349
+ };
2350
+ }
2351
+ function selectPromptWorkItem(workItems, workItemId) {
2352
+ if (workItemId) {
2353
+ return workItems.find((item) => item.workItemId === workItemId || item.id === workItemId);
2354
+ }
2355
+ return workItems.find((item) => isPromptReadyStatus(item.status));
2356
+ }
2357
+ function isPromptReadyStatus(status) {
2358
+ return status === "approved" || status === "running" || status === "blocked" || status === "changesRequested";
2359
+ }
2360
+ async function prepareToolSession({
2361
+ apiClient,
2362
+ projectId,
2363
+ repositoryLinkId,
2364
+ runnerId,
2365
+ sessionPolicy,
2366
+ supportsSessionReuse,
2367
+ resumabilityScope,
2368
+ toolName,
2369
+ workItem
2370
+ }) {
2371
+ const { toolSessions } = await apiClient.listToolSessions(projectId);
2372
+ const selection = selectToolSession({
2373
+ policy: sessionPolicy,
2374
+ workItem,
2375
+ sessions: toolSessions,
2376
+ toolName,
2377
+ runnerId,
2378
+ repositoryLinkId,
2379
+ supportsSessionReuse
2380
+ });
2381
+ if (selection.decision === "skipped") {
2382
+ return selection;
2383
+ }
2384
+ if (selection.toolSession) {
2385
+ const { toolSession: toolSession2 } = await apiClient.updateToolSession(projectId, selection.toolSession.toolSessionId, {
2386
+ status: "active",
2387
+ runnerId,
2388
+ lastWorkItemId: workItem.workItemId,
2389
+ reusePolicy: sessionPolicy
2390
+ });
2391
+ return { ...selection, toolSession: toolSession2 };
2392
+ }
2393
+ const toolSessionId = `tool_session_${randomUUID()}`;
2394
+ const { toolSession } = await apiClient.createToolSession(projectId, {
2395
+ toolSessionId,
2396
+ repositoryLinkId,
2397
+ tool: toolName,
2398
+ provider: toolName,
2399
+ resumabilityScope: supportsSessionReuse ? resumabilityScope : "none",
2400
+ title: workItem.title,
2401
+ summary: selection.reason,
2402
+ status: "active",
2403
+ runnerId,
2404
+ lastWorkItemId: workItem.workItemId,
2405
+ messageCount: 0,
2406
+ reusePolicy: sessionPolicy,
2407
+ ...workItem.sessionGroupKey ? { sessionGroupKey: workItem.sessionGroupKey } : {}
2408
+ });
2409
+ return { ...selection, toolSession };
2410
+ }
2411
+ async function finalizeToolSession({
2412
+ apiClient,
2413
+ costUsd,
2414
+ messageCount,
2415
+ projectId,
2416
+ runnerId,
2417
+ session,
2418
+ status,
2419
+ stdout,
2420
+ tokensIn,
2421
+ tokensOut,
2422
+ workItemId
2423
+ }) {
2424
+ if (!session) {
2425
+ return void 0;
2426
+ }
2427
+ const summary = summarizeToolOutput(stdout) ?? session.summary;
2428
+ const { toolSession } = await apiClient.updateToolSession(projectId, session.toolSessionId, {
2429
+ status: status === "completed" ? "open" : "blocked",
2430
+ runnerId,
2431
+ lastWorkItemId: workItemId,
2432
+ messageCount: (session.messageCount ?? 0) + (messageCount ?? 1),
2433
+ ...summary ? { summary } : {},
2434
+ ...tokensIn !== void 0 ? { estimatedInputTokens: (session.estimatedInputTokens ?? 0) + tokensIn } : {},
2435
+ ...tokensOut !== void 0 ? { estimatedOutputTokens: (session.estimatedOutputTokens ?? 0) + tokensOut } : {},
2436
+ ...costUsd !== void 0 ? { costUsd: (session.costUsd ?? 0) + costUsd } : {},
2437
+ ...status === "failed" ? { closedReason: "Last run failed or returned a non-zero exit code." } : {}
2438
+ });
2439
+ return toolSession;
2440
+ }
2441
+ async function markToolSessionBlocked(apiClient, projectId, session, reason) {
2442
+ if (!session) {
2443
+ return;
2444
+ }
2445
+ await apiClient.updateToolSession(projectId, session.toolSessionId, {
2446
+ status: "blocked",
2447
+ summary: reason,
2448
+ closedReason: reason
2449
+ });
641
2450
  }
642
2451
  function localSessionDecision(policy) {
643
- if (policy === "new") {
644
- return "forcedNew";
645
- }
646
- if (typeof policy === "string" && policy.startsWith("continue:")) {
647
- return "forcedContinue";
648
- }
649
- return "created";
2452
+ if (policy === "new") {
2453
+ return "forcedNew";
2454
+ }
2455
+ if (typeof policy === "string" && policy.startsWith("continue:")) {
2456
+ return "forcedContinue";
2457
+ }
2458
+ return "created";
650
2459
  }
651
2460
  function summarizeToolOutput(value) {
652
- const trimmed = value.trim();
653
- if (!trimmed) {
654
- return undefined;
655
- }
656
- return trimmed.length > 300 ? `${trimmed.slice(0, 300)}...` : trimmed;
2461
+ const trimmed = value.trim();
2462
+ if (!trimmed) {
2463
+ return void 0;
2464
+ }
2465
+ return trimmed.length > 300 ? `${trimmed.slice(0, 300)}...` : trimmed;
657
2466
  }
658
- function errorMessage(error) {
659
- return error instanceof Error ? error.message : String(error);
2467
+ function errorMessage2(error) {
2468
+ return error instanceof Error ? error.message : String(error);
660
2469
  }
661
2470
  function truncateLogExcerpt(value) {
662
- const trimmed = value.trim();
663
- return trimmed.length > 1200 ? `${trimmed.slice(0, 1200)}...` : trimmed;
2471
+ const trimmed = value.trim();
2472
+ return trimmed.length > 1200 ? `${trimmed.slice(0, 1200)}...` : trimmed;
664
2473
  }
665
2474
  function parsePositiveInteger(value) {
666
- const parsed = Number(value);
667
- if (!Number.isInteger(parsed) || parsed <= 0) {
668
- throw new Error(`Expected a positive integer, received ${value}.`);
669
- }
670
- return parsed;
2475
+ const parsed = Number(value);
2476
+ if (!Number.isInteger(parsed) || parsed <= 0) {
2477
+ throw new Error(`Expected a positive integer, received ${value}.`);
2478
+ }
2479
+ return parsed;
671
2480
  }
672
2481
  function inferRepoName(root) {
673
- return path.basename(path.resolve(root)) || "repository";
2482
+ return path8.basename(path8.resolve(root)) || "repository";
674
2483
  }
675
2484
  function createRepoFingerprint(accountId, projectId, repositoryLinkId) {
676
- return createHash("sha256").update(`${accountId}:${projectId}:${repositoryLinkId}`).digest("hex");
2485
+ return createHash2("sha256").update(`${accountId}:${projectId}:${repositoryLinkId}`).digest("hex");
677
2486
  }
678
2487
  function defaultApiUrl() {
679
- const envApiUrl = process.env[AMISTIO_API_URL_ENV]?.trim();
680
- return envApiUrl || officialAmistioApiUrl;
2488
+ const envApiUrl = process.env[AMISTIO_API_URL_ENV]?.trim();
2489
+ return envApiUrl || officialAmistioApiUrl;
681
2490
  }
682
2491
  function apiUrlOverrideWasRequested(command) {
683
- return command.getOptionValueSource("apiUrl") === "cli" || Boolean(process.env[AMISTIO_API_URL_ENV]?.trim());
2492
+ return command.getOptionValueSource("apiUrl") === "cli" || Boolean(process.env[AMISTIO_API_URL_ENV]?.trim());
684
2493
  }
685
2494
  function formatApiUrlFlag(apiUrl) {
686
- return isOfficialAmistioApiUrl(apiUrl) ? "" : ` --api-url ${formatShellArg(apiUrl)}`;
2495
+ return isOfficialAmistioApiUrl(apiUrl) ? "" : ` --api-url ${formatShellArg(apiUrl)}`;
687
2496
  }
688
2497
  function formatShellArg(value) {
689
- return /^[A-Za-z0-9_./:@-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
2498
+ return /^[A-Za-z0-9_./:@-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
690
2499
  }
691
2500
  async function delay(milliseconds) {
692
- await new Promise((resolve) => setTimeout(resolve, milliseconds));
2501
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
693
2502
  }
694
2503
  program.parseAsync().catch((error) => {
695
- console.error(error instanceof Error ? error.message : String(error));
696
- process.exitCode = 1;
2504
+ console.error(error instanceof Error ? error.message : String(error));
2505
+ process.exitCode = 1;
697
2506
  });
698
- //# sourceMappingURL=index.js.map
2507
+ //# sourceMappingURL=index.js.map