agent-trellis 0.6.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +97 -38
  2. package/dist/adapters/claude-code.js +3 -2
  3. package/dist/adapters/codex.js +3 -2
  4. package/dist/adapters/jsonMcp.d.ts +3 -1
  5. package/dist/adapters/jsonMcp.js +18 -4
  6. package/dist/adapters/kimi-code.d.ts +21 -0
  7. package/dist/adapters/kimi-code.js +107 -0
  8. package/dist/adapters/kiro.js +3 -2
  9. package/dist/adapters/mcpPlan.d.ts +26 -2
  10. package/dist/adapters/mcpPlan.js +135 -15
  11. package/dist/adapters/pi.js +22 -3
  12. package/dist/adapters/symlinkPlan.d.ts +7 -0
  13. package/dist/adapters/symlinkPlan.js +16 -7
  14. package/dist/cli.js +129 -20
  15. package/dist/commands/doctor.d.ts +7 -2
  16. package/dist/commands/doctor.js +38 -2
  17. package/dist/commands/init.d.ts +4 -2
  18. package/dist/commands/init.js +26 -13
  19. package/dist/commands/kimi.d.ts +16 -0
  20. package/dist/commands/kimi.js +64 -0
  21. package/dist/commands/manage.d.ts +39 -0
  22. package/dist/commands/manage.js +126 -0
  23. package/dist/commands/mcp.d.ts +22 -1
  24. package/dist/commands/mcp.js +65 -2
  25. package/dist/commands/mcpGateway.d.ts +6 -0
  26. package/dist/commands/mcpGateway.js +34 -14
  27. package/dist/commands/mcpImport.d.ts +27 -0
  28. package/dist/commands/mcpImport.js +336 -0
  29. package/dist/commands/memory.d.ts +5 -3
  30. package/dist/commands/memory.js +12 -8
  31. package/dist/commands/migrate.d.ts +37 -7
  32. package/dist/commands/migrate.js +253 -27
  33. package/dist/commands/onboard.d.ts +74 -2
  34. package/dist/commands/onboard.js +463 -72
  35. package/dist/commands/onboardVerdict.js +20 -7
  36. package/dist/commands/rollback.js +4 -1
  37. package/dist/commands/secretsAudit.d.ts +21 -12
  38. package/dist/commands/secretsAudit.js +39 -10
  39. package/dist/commands/skill.d.ts +17 -0
  40. package/dist/commands/skill.js +47 -2
  41. package/dist/commands/sync.js +2 -0
  42. package/dist/core/canonical.d.ts +58 -4
  43. package/dist/core/canonical.js +183 -11
  44. package/dist/core/capabilitySelection.d.ts +45 -0
  45. package/dist/core/capabilitySelection.js +124 -0
  46. package/dist/core/types.d.ts +25 -2
  47. package/dist/core/types.js +7 -0
  48. package/dist/lib/backup.d.ts +2 -0
  49. package/dist/lib/backup.js +4 -0
  50. package/dist/lib/builtinSkills.d.ts +2 -0
  51. package/dist/lib/builtinSkills.js +4 -0
  52. package/dist/lib/cliMetadata.d.ts +1 -0
  53. package/dist/lib/cliMetadata.js +14 -0
  54. package/dist/lib/gatewayBackend.d.ts +28 -0
  55. package/dist/lib/gatewayBackend.js +72 -1
  56. package/dist/lib/installAgent.d.ts +7 -5
  57. package/dist/lib/installAgent.js +4 -3
  58. package/dist/lib/mcpConnect.js +5 -1
  59. package/dist/lib/mcpMigrateRead.d.ts +1 -0
  60. package/dist/lib/mcpMigrateRead.js +3 -0
  61. package/dist/lib/mcpRuntime.d.ts +46 -0
  62. package/dist/lib/mcpRuntime.js +185 -0
  63. package/dist/lib/mcpStatusProvider.d.ts +10 -0
  64. package/dist/lib/mcpStatusProvider.js +25 -0
  65. package/dist/lib/mcpToolRegistry.d.ts +18 -0
  66. package/dist/lib/mcpToolRegistry.js +107 -14
  67. package/dist/lib/memoryGraph.d.ts +1 -1
  68. package/dist/lib/memoryGraph.js +9 -3
  69. package/dist/lib/memoryProvider.d.ts +58 -0
  70. package/dist/lib/memoryProvider.js +175 -0
  71. package/dist/lib/memoryStatus.d.ts +17 -0
  72. package/dist/lib/memoryStatus.js +88 -0
  73. package/dist/lib/nativeMemory.d.ts +19 -0
  74. package/dist/lib/nativeMemory.js +129 -0
  75. package/dist/lib/runtimeControlProvider.d.ts +16 -0
  76. package/dist/lib/runtimeControlProvider.js +168 -0
  77. package/dist/lib/secretEnv.d.ts +17 -0
  78. package/dist/lib/secretEnv.js +31 -1
  79. package/dist/lib/skillProvider.d.ts +13 -0
  80. package/dist/lib/skillProvider.js +210 -0
  81. package/dist/lib/taskProvider.d.ts +7 -0
  82. package/dist/lib/taskProvider.js +43 -0
  83. package/dist/lib/taskStore.d.ts +46 -0
  84. package/dist/lib/taskStore.js +156 -0
  85. package/dist/lib/terminalPicker.d.ts +23 -28
  86. package/dist/lib/terminalPicker.js +54 -164
  87. package/dist/pi-bridge/bundle.js +493 -51
  88. package/dist/pi-bridge/index.js +42 -21
  89. package/dist/pi-bridge/schemaTranslate.d.ts +4 -8
  90. package/dist/pi-bridge/schemaTranslate.js +4 -8
  91. package/dist/probes/kimi-code.d.ts +12 -0
  92. package/dist/probes/kimi-code.js +55 -0
  93. package/dist/sdk.d.ts +1 -1
  94. package/docs/architecture.md +224 -26
  95. package/docs/assets/agent-trellis-architecture-horizontal.svg +80 -0
  96. package/docs/assets/agent-trellis-architecture-vertical.svg +82 -0
  97. package/docs/assets/trellis-mark.svg +12 -0
  98. package/docs/getting-started.md +403 -35
  99. package/docs/overview.md +131 -0
  100. package/docs/overview.zh-CN.md +100 -0
  101. package/docs/research.md +59 -0
  102. package/docs/roadmap.md +10 -5
  103. package/package.json +5 -3
  104. package/schema/builtin-skills/trellis-runtime/SKILL.md +117 -0
  105. package/schema/capability-selection.example.yaml +23 -0
  106. package/schema/scope.example.yaml +1 -1
  107. package/schema/servers.example.yaml +15 -1
@@ -8,7 +8,8 @@
8
8
  * dotenv library's quoting/escaping/interpolation rules are more than
9
9
  * this narrow need requires (design.md D4).
10
10
  */
11
- import { existsSync, readFileSync } from "node:fs";
11
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
12
+ import { dirname } from "node:path";
12
13
  const LINE_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
13
14
  export function parseDotenv(content) {
14
15
  const result = {};
@@ -44,3 +45,32 @@ export function resolveSecretEnv(names, policy) {
44
45
  }
45
46
  return result;
46
47
  }
48
+ /**
49
+ * `trellis migrate`'s static-env secret extraction
50
+ * (trellis-migrate-extract-static-env-secrets design.md D4) — appends
51
+ * one `NAME=value` line to a dotenv-format file in exactly the shape
52
+ * `parseDotenv` above already reads, creating the file (and its parent
53
+ * directory) if neither exists yet. Idempotent, the same three-way
54
+ * split every other Trellis writer uses: the name is absent (create),
55
+ * already present with the identical value (no-op — re-running migrate
56
+ * must not duplicate the line), or already present with a *different*
57
+ * value (conflict, left untouched — the file may hold a value the user
58
+ * already rotated by hand since the last run; silently overwriting it
59
+ * would be exactly the kind of value-clobbering this whole feature
60
+ * exists to prevent, just relocated to a new file).
61
+ */
62
+ export function writeLocalSecretValue(path, name, value, backup) {
63
+ const existingContent = existsSync(path) ? readFileSync(path, "utf-8") : "";
64
+ const existing = parseDotenv(existingContent);
65
+ if (Object.hasOwn(existing, name)) {
66
+ return existing[name] === value ? "already-present" : "conflict";
67
+ }
68
+ mkdirSync(dirname(path), { recursive: true });
69
+ const separator = existingContent.length > 0 && !existingContent.endsWith("\n") ? "\n" : "";
70
+ const next = `${existingContent}${separator}${name}=${value}\n`;
71
+ if (backup)
72
+ backup.writeFile(path, next);
73
+ else
74
+ appendFileSync(path, `${separator}${name}=${value}\n`);
75
+ return "created";
76
+ }
@@ -0,0 +1,13 @@
1
+ import type { Resource, ReadResourceResult, Tool, CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
+ import type { RuntimeContext, TrellisProvider } from "./mcpRuntime.js";
3
+ export declare class SkillProvider implements TrellisProvider {
4
+ readonly id = "skills";
5
+ listTools(): Tool[];
6
+ callTool(name: string, args: unknown, context: RuntimeContext): Promise<CallToolResult>;
7
+ listResources(context: RuntimeContext): Resource[];
8
+ readResource(uri: URL, context: RuntimeContext): ReadResourceResult;
9
+ private search;
10
+ private readSkill;
11
+ private readFile;
12
+ private readBoundedFile;
13
+ }
@@ -0,0 +1,210 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
3
+ import { resolve, relative, sep } from "node:path";
4
+ import { parse as parseYaml } from "yaml";
5
+ import { isInScope } from "../core/adapter.js";
6
+ import { loadCanonicalSource } from "../core/canonical.js";
7
+ import { resolveScope } from "../core/types.js";
8
+ const MAX_FILE_BYTES = 256 * 1024;
9
+ const SKILL_RESOURCE_PREFIX = "trellis://skills/";
10
+ const SEARCH_TOOL = {
11
+ name: "trellis.skills.search",
12
+ description: "Search in-scope Trellis skills by name or description, then read one selected skill on demand.",
13
+ inputSchema: {
14
+ type: "object",
15
+ properties: { query: { type: "string" }, limit: { type: "number", minimum: 1, maximum: 50 } },
16
+ required: ["query"],
17
+ additionalProperties: false,
18
+ },
19
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
20
+ };
21
+ const READ_TOOL = {
22
+ name: "trellis.skills.read",
23
+ description: "Read the SKILL.md instructions for one in-scope Trellis skill.",
24
+ inputSchema: {
25
+ type: "object",
26
+ properties: { name: { type: "string" } },
27
+ required: ["name"],
28
+ additionalProperties: false,
29
+ },
30
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
31
+ };
32
+ const READ_FILE_TOOL = {
33
+ name: "trellis.skills.read_file",
34
+ description: "Read one bounded, non-secret supporting file under an in-scope skill directory.",
35
+ inputSchema: {
36
+ type: "object",
37
+ properties: { name: { type: "string" }, relativePath: { type: "string" } },
38
+ required: ["name", "relativePath"],
39
+ additionalProperties: false,
40
+ },
41
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
42
+ };
43
+ function textResult(value) {
44
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], structuredContent: value };
45
+ }
46
+ function errorResult(message) {
47
+ return { content: [{ type: "text", text: message }], isError: true };
48
+ }
49
+ function frontmatter(content) {
50
+ if (!content.startsWith("---"))
51
+ return {};
52
+ const end = content.indexOf("\n---", 3);
53
+ if (end < 0)
54
+ return {};
55
+ try {
56
+ const parsed = parseYaml(content.slice(3, end));
57
+ return {
58
+ name: typeof parsed?.name === "string" ? parsed.name : undefined,
59
+ description: typeof parsed?.description === "string" ? parsed.description : undefined,
60
+ };
61
+ }
62
+ catch {
63
+ return {};
64
+ }
65
+ }
66
+ function fingerprint(content) {
67
+ return createHash("sha256").update(content).digest("hex");
68
+ }
69
+ function isSecretLikePath(path) {
70
+ const normalized = path.toLowerCase();
71
+ return normalized.includes(".env") || normalized.includes("secret") || normalized.endsWith("servers.local.env");
72
+ }
73
+ function skillFor(canonical, agentId, name) {
74
+ const skill = canonical.skills.find((candidate) => candidate.name === name);
75
+ if (!skill || !hasSkillDocument(skill) || !isInScope(agentId, skill.scope, canonical.managedAgents))
76
+ return undefined;
77
+ return skill;
78
+ }
79
+ function hasSkillDocument(skill) {
80
+ try {
81
+ return statSync(resolve(skill.dir, "SKILL.md")).isFile();
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
87
+ function skillUri(name) {
88
+ return `${SKILL_RESOURCE_PREFIX}${encodeURIComponent(name)}/SKILL.md`;
89
+ }
90
+ function skillMetadata(skill, canonical, agentId, content) {
91
+ const metadata = frontmatter(content);
92
+ return {
93
+ name: skill.name,
94
+ description: metadata.description ?? "",
95
+ fingerprint: fingerprint(content),
96
+ uri: skillUri(skill.name),
97
+ scope: resolveScope(skill.scope, canonical.managedAgents).includes(agentId) ? "in-scope" : "out-of-scope",
98
+ source: "canonical",
99
+ };
100
+ }
101
+ export class SkillProvider {
102
+ id = "skills";
103
+ listTools() {
104
+ return [SEARCH_TOOL, READ_TOOL, READ_FILE_TOOL];
105
+ }
106
+ async callTool(name, args, context) {
107
+ const canonical = loadCanonicalSource(context.homeDir);
108
+ const input = (args ?? {});
109
+ if (name === SEARCH_TOOL.name)
110
+ return this.search(canonical, context.agentId, input);
111
+ if (name === READ_TOOL.name)
112
+ return this.readSkill(canonical, context.agentId, input);
113
+ if (name === READ_FILE_TOOL.name)
114
+ return this.readFile(canonical, context.agentId, input);
115
+ return errorResult(`unknown skill provider tool "${name}"`);
116
+ }
117
+ listResources(context) {
118
+ const canonical = loadCanonicalSource(context.homeDir);
119
+ return canonical.skills
120
+ .filter((skill) => hasSkillDocument(skill) && isInScope(context.agentId, skill.scope, canonical.managedAgents))
121
+ .map((skill) => ({
122
+ uri: skillUri(skill.name),
123
+ name: `${skill.name}/SKILL.md`,
124
+ description: `Instructions for the ${skill.name} skill`,
125
+ mimeType: "text/markdown",
126
+ }));
127
+ }
128
+ readResource(uri, context) {
129
+ if (uri.protocol !== "trellis:" || uri.hostname !== "skills") {
130
+ throw new Error(`unsupported skill resource URI "${uri.toString()}"`);
131
+ }
132
+ const segments = uri.pathname.split("/").filter(Boolean).map((segment) => decodeURIComponent(segment));
133
+ if (segments.length !== 2 || segments[1] !== "SKILL.md") {
134
+ throw new Error(`unsupported skill resource URI "${uri.toString()}"`);
135
+ }
136
+ const canonical = loadCanonicalSource(context.homeDir);
137
+ const skill = skillFor(canonical, context.agentId, segments[0]);
138
+ if (!skill)
139
+ throw new Error(`skill "${segments[0]}" is not available to ${context.agentId}`);
140
+ const content = this.readBoundedFile(resolve(skill.dir, "SKILL.md"), skill.dir);
141
+ return { contents: [{ uri: uri.toString(), mimeType: "text/markdown", text: content }] };
142
+ }
143
+ search(canonical, agentId, input) {
144
+ const query = typeof input.query === "string" ? input.query.trim().toLowerCase() : "";
145
+ const limit = typeof input.limit === "number" ? Math.min(50, Math.max(1, Math.floor(input.limit))) : 20;
146
+ const results = canonical.skills
147
+ .filter((skill) => isInScope(agentId, skill.scope, canonical.managedAgents))
148
+ .map((skill) => {
149
+ let content;
150
+ try {
151
+ content = this.readBoundedFile(resolve(skill.dir, "SKILL.md"), skill.dir);
152
+ }
153
+ catch {
154
+ return undefined;
155
+ }
156
+ return { ...skillMetadata(skill, canonical, agentId, content), dir: skill.dir };
157
+ })
158
+ .filter((skill) => skill !== undefined)
159
+ .filter((skill) => !query || `${skill.name} ${skill.description}`.toLowerCase().includes(query))
160
+ .slice(0, limit)
161
+ .map(({ dir: _dir, ...result }) => result);
162
+ return textResult({ skills: results });
163
+ }
164
+ readSkill(canonical, agentId, input) {
165
+ if (typeof input.name !== "string" || input.name.length === 0)
166
+ return errorResult('"name" is required');
167
+ const skill = skillFor(canonical, agentId, input.name);
168
+ if (!skill)
169
+ return errorResult(`skill "${input.name}" is not available to ${agentId}`);
170
+ const path = resolve(skill.dir, "SKILL.md");
171
+ const content = this.readBoundedFile(path, skill.dir);
172
+ return textResult({ ...skillMetadata(skill, canonical, agentId, content), content });
173
+ }
174
+ readFile(canonical, agentId, input) {
175
+ if (typeof input.name !== "string" || typeof input.relativePath !== "string")
176
+ return errorResult('"name" and "relativePath" are required');
177
+ const skill = skillFor(canonical, agentId, input.name);
178
+ if (!skill)
179
+ return errorResult(`skill "${input.name}" is not available to ${agentId}`);
180
+ if (input.relativePath.length === 0 || input.relativePath.includes("\0") || isSecretLikePath(input.relativePath)) {
181
+ return errorResult("supporting file path is not allowed");
182
+ }
183
+ try {
184
+ const path = resolve(skill.dir, input.relativePath);
185
+ const root = realpathSync(skill.dir);
186
+ const realPath = realpathSync(path);
187
+ const rel = relative(root, realPath);
188
+ if (rel.startsWith(`..${sep}`) || rel === ".." || !existsSync(realPath) || !statSync(realPath).isFile()) {
189
+ return errorResult("supporting file is outside the skill root");
190
+ }
191
+ return textResult({ name: skill.name, relativePath: rel, source: "canonical-skill-supporting-file", content: this.readBoundedFile(realPath, root) });
192
+ }
193
+ catch (err) {
194
+ return errorResult(`could not read supporting file: ${err instanceof Error ? err.message : String(err)}`);
195
+ }
196
+ }
197
+ readBoundedFile(path, root) {
198
+ if (isSecretLikePath(path))
199
+ throw new Error("secret-like files are not readable through the skill provider");
200
+ const rootPath = realpathSync(root);
201
+ const realPath = realpathSync(path);
202
+ const rel = relative(rootPath, realPath);
203
+ if (rel.startsWith(`..${sep}`) || rel === "..")
204
+ throw new Error("file is outside the skill root");
205
+ const stat = statSync(realPath);
206
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES)
207
+ throw new Error(`file exceeds the ${MAX_FILE_BYTES}-byte provider limit`);
208
+ return readFileSync(realPath, "utf8");
209
+ }
210
+ }
@@ -0,0 +1,7 @@
1
+ import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
2
+ import type { RuntimeContext, TrellisProvider } from "./mcpRuntime.js";
3
+ export declare class TaskProvider implements TrellisProvider {
4
+ readonly id = "tasks";
5
+ listTools(): Tool[];
6
+ callTool(name: string, args: unknown, context: RuntimeContext): Promise<CallToolResult>;
7
+ }
@@ -0,0 +1,43 @@
1
+ import { claimTask, createTask, handoffTask, listTasks, readTask, updateTask } from "./taskStore.js";
2
+ const LIST = { name: "trellis.tasks.list", description: "List safe Trellis handoff tasks.", inputSchema: { type: "object", properties: { status: { type: "string" }, toAgent: { type: "string" } }, additionalProperties: false }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false } };
3
+ const READ = { name: "trellis.tasks.read", description: "Read one Trellis handoff task and its audit history.", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false } };
4
+ const CREATE = { name: "trellis.tasks.create", description: "Create a safe durable handoff task. Requires explicit confirm=true and never launches another Agent.", inputSchema: { type: "object", properties: { objective: { type: "string" }, toAgent: { type: "string" }, context: { type: "object" }, constraints: { type: "array", items: { type: "string" } }, confirm: { type: "boolean" } }, required: ["objective", "confirm"], additionalProperties: false }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false } };
5
+ const CLAIM = { name: "trellis.tasks.claim", description: "Claim one pending handoff task with an exclusive lease. Requires explicit confirm=true.", inputSchema: { type: "object", properties: { id: { type: "string" }, leaseMs: { type: "number" }, confirm: { type: "boolean" } }, required: ["id", "confirm"], additionalProperties: false }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false } };
6
+ const UPDATE = { name: "trellis.tasks.update", description: "Update a claimed task status without launching another Agent. Requires explicit confirm=true.", inputSchema: { type: "object", properties: { id: { type: "string" }, status: { type: "string" }, note: { type: "string" }, result: { type: "string" }, confirm: { type: "boolean" } }, required: ["id", "status", "confirm"], additionalProperties: false }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false } };
7
+ const HANDOFF = { name: "trellis.tasks.handoff", description: "Hand a task to another managed Agent as data only. Requires explicit confirm=true.", inputSchema: { type: "object", properties: { id: { type: "string" }, toAgent: { type: "string" }, note: { type: "string" }, confirm: { type: "boolean" } }, required: ["id", "toAgent", "confirm"], additionalProperties: false }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false } };
8
+ function result(value) {
9
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], structuredContent: value };
10
+ }
11
+ function failure(error) {
12
+ return { content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], isError: true };
13
+ }
14
+ export class TaskProvider {
15
+ id = "tasks";
16
+ listTools() { return [LIST, READ, CREATE, CLAIM, UPDATE, HANDOFF]; }
17
+ async callTool(name, args, context) {
18
+ const input = (args ?? {});
19
+ try {
20
+ if (name === LIST.name) {
21
+ const status = input.status;
22
+ const toAgent = input.toAgent;
23
+ return result({ tasks: listTasks(context.homeDir, { status, toAgent }) });
24
+ }
25
+ if (name === READ.name)
26
+ return result(readTask(context.homeDir, String(input.id ?? "")));
27
+ if (input.confirm !== true)
28
+ return failure(`task mutation "${name}" requires confirm=true; no Agent is launched automatically`);
29
+ if (name === CREATE.name)
30
+ return result(createTask(context.homeDir, { objective: String(input.objective ?? ""), fromAgent: context.agentId, ...(input.toAgent ? { toAgent: input.toAgent } : {}), ...(input.context ? { context: input.context } : {}), ...(input.constraints ? { constraints: input.constraints } : {}) }));
31
+ if (name === CLAIM.name)
32
+ return result(claimTask(context.homeDir, String(input.id ?? ""), context.agentId, input.leaseMs));
33
+ if (name === UPDATE.name)
34
+ return result(updateTask(context.homeDir, String(input.id ?? ""), context.agentId, input.status, input.note, input.result));
35
+ if (name === HANDOFF.name)
36
+ return result(handoffTask(context.homeDir, String(input.id ?? ""), context.agentId, input.toAgent, input.note));
37
+ return failure(`unknown task tool "${name}"`);
38
+ }
39
+ catch (error) {
40
+ return failure(error);
41
+ }
42
+ }
43
+ }
@@ -0,0 +1,46 @@
1
+ import type { AgentId } from "../core/types.js";
2
+ export type TaskStatus = "pending" | "claimed" | "in_progress" | "blocked" | "completed";
3
+ export interface TaskContext {
4
+ workspace?: string;
5
+ commit?: string;
6
+ references?: string[];
7
+ note?: string;
8
+ }
9
+ export interface TaskHistoryEntry {
10
+ at: string;
11
+ agent: AgentId;
12
+ action: string;
13
+ status: TaskStatus;
14
+ note?: string;
15
+ }
16
+ export interface TaskRecord {
17
+ id: string;
18
+ objective: string;
19
+ fromAgent: AgentId;
20
+ toAgent?: AgentId;
21
+ status: TaskStatus;
22
+ context?: TaskContext;
23
+ constraints?: string[];
24
+ result?: string;
25
+ claimedBy?: AgentId;
26
+ leaseUntil?: string;
27
+ createdAt: string;
28
+ updatedAt: string;
29
+ history: TaskHistoryEntry[];
30
+ }
31
+ export declare function taskStorePath(homeDir: string): string;
32
+ export declare function listTasks(homeDir: string, filter?: {
33
+ status?: TaskStatus;
34
+ toAgent?: AgentId;
35
+ }): TaskRecord[];
36
+ export declare function readTask(homeDir: string, id: string): TaskRecord;
37
+ export declare function createTask(homeDir: string, input: {
38
+ objective: string;
39
+ fromAgent: AgentId;
40
+ toAgent?: AgentId;
41
+ context?: TaskContext;
42
+ constraints?: string[];
43
+ }): TaskRecord;
44
+ export declare function claimTask(homeDir: string, id: string, agent: AgentId, leaseMs?: number): TaskRecord;
45
+ export declare function updateTask(homeDir: string, id: string, agent: AgentId, status: TaskStatus, note?: string, result?: string): TaskRecord;
46
+ export declare function handoffTask(homeDir: string, id: string, agent: AgentId, toAgent: AgentId, note?: string): TaskRecord;
@@ -0,0 +1,156 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ const SECRET_LIKE = /(sk-|ghp_|glpat-|mcpr_|token\s*=|password\s*=|secret\s*=|authorization\s*:)/i;
5
+ export function taskStorePath(homeDir) {
6
+ return join(homeDir, ".trellis", "tasks", "tasks.json");
7
+ }
8
+ function readFile(path) {
9
+ if (!existsSync(path))
10
+ return { tasks: [] };
11
+ try {
12
+ const value = JSON.parse(readFileSync(path, "utf8"));
13
+ return { tasks: Array.isArray(value.tasks) ? value.tasks : [] };
14
+ }
15
+ catch {
16
+ throw new Error(`could not parse task store ${path}`);
17
+ }
18
+ }
19
+ function writeFile(path, value) {
20
+ mkdirSync(dirname(path), { recursive: true });
21
+ const temporary = `${path}.${process.pid}.tmp`;
22
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
23
+ renameSync(temporary, path);
24
+ }
25
+ function assertSafeText(label, value) {
26
+ if (!value.trim())
27
+ throw new Error(`${label} must not be empty`);
28
+ if (SECRET_LIKE.test(value))
29
+ throw new Error(`${label} looks like it contains a credential or secret-like value`);
30
+ }
31
+ function assertSafeContext(context) {
32
+ if (!context)
33
+ return;
34
+ for (const [key, value] of Object.entries(context)) {
35
+ if (typeof value === "string")
36
+ assertSafeText(`context.${key}`, value);
37
+ if (Array.isArray(value))
38
+ value.forEach((item) => assertSafeText(`context.${key}`, item));
39
+ }
40
+ }
41
+ function now() {
42
+ return new Date().toISOString();
43
+ }
44
+ function assertAgent(value, label) {
45
+ if (value !== "claude-code" && value !== "codex" && value !== "kiro" && value !== "pi" && value !== "kimi-code") {
46
+ throw new Error(`${label} must be a supported Agent id`);
47
+ }
48
+ }
49
+ function taskOrThrow(file, id) {
50
+ const task = file.tasks.find((candidate) => candidate.id === id);
51
+ if (!task)
52
+ throw new Error(`task "${id}" was not found`);
53
+ return task;
54
+ }
55
+ function addHistory(task, agent, action, status, note) {
56
+ task.status = status;
57
+ task.updatedAt = now();
58
+ task.history.push({ at: task.updatedAt, agent, action, status, ...(note ? { note } : {}) });
59
+ }
60
+ export function listTasks(homeDir, filter) {
61
+ const tasks = readFile(taskStorePath(homeDir)).tasks;
62
+ return tasks
63
+ .filter((task) => !filter?.status || task.status === filter.status)
64
+ .filter((task) => !filter?.toAgent || task.toAgent === filter.toAgent)
65
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
66
+ }
67
+ export function readTask(homeDir, id) {
68
+ return taskOrThrow(readFile(taskStorePath(homeDir)), id);
69
+ }
70
+ export function createTask(homeDir, input) {
71
+ assertSafeText("objective", input.objective);
72
+ assertAgent(input.fromAgent, "fromAgent");
73
+ if (input.toAgent)
74
+ assertAgent(input.toAgent, "toAgent");
75
+ assertSafeContext(input.context);
76
+ input.constraints?.forEach((value) => assertSafeText("constraint", value));
77
+ const timestamp = now();
78
+ const task = {
79
+ id: `task-${randomUUID()}`,
80
+ objective: input.objective,
81
+ fromAgent: input.fromAgent,
82
+ ...(input.toAgent ? { toAgent: input.toAgent } : {}),
83
+ status: "pending",
84
+ ...(input.context ? { context: input.context } : {}),
85
+ ...(input.constraints?.length ? { constraints: input.constraints } : {}),
86
+ createdAt: timestamp,
87
+ updatedAt: timestamp,
88
+ history: [{ at: timestamp, agent: input.fromAgent, action: "created", status: "pending" }],
89
+ };
90
+ const file = readFile(taskStorePath(homeDir));
91
+ file.tasks.push(task);
92
+ writeFile(taskStorePath(homeDir), file);
93
+ return task;
94
+ }
95
+ export function claimTask(homeDir, id, agent, leaseMs = 30 * 60 * 1000) {
96
+ assertAgent(agent, "agent");
97
+ if (!Number.isFinite(leaseMs) || leaseMs < 1_000 || leaseMs > 24 * 60 * 60 * 1000)
98
+ throw new Error("leaseMs must be between 1000 and 86400000");
99
+ const path = taskStorePath(homeDir);
100
+ const file = readFile(path);
101
+ const task = taskOrThrow(file, id);
102
+ const timestamp = Date.now();
103
+ if (task.leaseUntil && new Date(task.leaseUntil).getTime() <= timestamp) {
104
+ task.claimedBy = undefined;
105
+ task.leaseUntil = undefined;
106
+ if (task.status === "claimed" || task.status === "in_progress")
107
+ task.status = "pending";
108
+ }
109
+ if (task.status !== "pending" || task.claimedBy)
110
+ throw new Error(`task "${id}" is already claimed or is not pending`);
111
+ task.claimedBy = agent;
112
+ task.leaseUntil = new Date(timestamp + leaseMs).toISOString();
113
+ addHistory(task, agent, "claimed", "claimed");
114
+ writeFile(path, file);
115
+ return task;
116
+ }
117
+ export function updateTask(homeDir, id, agent, status, note, result) {
118
+ assertAgent(agent, "agent");
119
+ if (!["pending", "claimed", "in_progress", "blocked", "completed"].includes(status))
120
+ throw new Error(`invalid task status "${status}"`);
121
+ if (note)
122
+ assertSafeText("note", note);
123
+ if (result)
124
+ assertSafeText("result", result);
125
+ const path = taskStorePath(homeDir);
126
+ const file = readFile(path);
127
+ const task = taskOrThrow(file, id);
128
+ if (task.claimedBy && task.claimedBy !== agent)
129
+ throw new Error(`task "${id}" is claimed by another Agent`);
130
+ if (status === "completed" && result)
131
+ task.result = result;
132
+ addHistory(task, agent, status === "completed" ? "completed" : "updated", status, note);
133
+ if (status === "completed") {
134
+ task.claimedBy = undefined;
135
+ task.leaseUntil = undefined;
136
+ }
137
+ writeFile(path, file);
138
+ return task;
139
+ }
140
+ export function handoffTask(homeDir, id, agent, toAgent, note) {
141
+ assertAgent(agent, "agent");
142
+ assertAgent(toAgent, "toAgent");
143
+ if (note)
144
+ assertSafeText("note", note);
145
+ const path = taskStorePath(homeDir);
146
+ const file = readFile(path);
147
+ const task = taskOrThrow(file, id);
148
+ if (task.claimedBy && task.claimedBy !== agent)
149
+ throw new Error(`task "${id}" is claimed by another Agent`);
150
+ task.toAgent = toAgent;
151
+ task.claimedBy = undefined;
152
+ task.leaseUntil = undefined;
153
+ addHistory(task, agent, "handed-off", "pending", note);
154
+ writeFile(path, file);
155
+ return task;
156
+ }
@@ -1,45 +1,40 @@
1
1
  /**
2
- * Minimal, dependency-free arrow-key/checkbox terminal picker
3
- * (trellis-onboard-interactive-picker design.md D1) — the interaction
4
- * surface `trellis onboard`'s two prompts need (at most four rows, no
5
- * search, no pagination) doesn't need a full prompt library; this
6
- * hand-rolls just enough raw-mode key handling and ANSI rendering for
7
- * that bounded case, matching the project's existing narrow-dependency
8
- * precedent (src/lib/envVarNames.ts, src/lib/secretEnv.ts).
2
+ * Styled interactive prompts used by `trellis onboard`.
9
3
  *
10
- * `input`/`output` are injectable (never a CLI flag) purely so tests can
11
- * drive the real key-parsing/render loop against a plain stream instead
12
- * of a real TTY — same seam pattern (`homeDir`, etc.) used everywhere
13
- * else in this project.
4
+ * The onboarding orchestration depends on semantic values (an index or a set
5
+ * of indices), not on a particular terminal renderer. Clack owns raw-mode
6
+ * handling, color, wrapping, cursor cleanup, and cancellation; the injected
7
+ * streams keep the prompt loop testable without a real terminal.
14
8
  */
15
9
  import type { Readable, Writable } from "node:stream";
16
10
  export interface PickerStreams {
17
11
  input: NodeJS.ReadStream | Readable;
18
12
  output: NodeJS.WriteStream | Writable;
19
13
  }
14
+ export interface PickerOption {
15
+ label: string;
16
+ disabled?: boolean;
17
+ }
20
18
  /**
21
- * True only when both streams are real, raw-mode-capable terminals — the
22
- * exact gate deciding picker vs. the pre-existing numbered-typing prompt
23
- * (design.md D2). A stream lacking `setRawMode` (piped input, some
24
- * minimal TTYs) always falls back, never hangs or guesses.
19
+ * Clack is only started for a real interactive terminal. Piped input keeps
20
+ * the existing numbered fallback in onboard instead of hanging in raw mode.
25
21
  */
26
22
  export declare function canUseInteractivePicker(streams?: PickerStreams): boolean;
27
- /** Wrapping index navigation — pure, exported for direct unit testing. */
23
+ /** Wrapping index navigation retained as a small public utility for callers/tests. */
28
24
  export declare function nextIndex(current: number, delta: number, length: number): number;
29
- /** Pure single-index toggle — exported for direct unit testing. */
25
+ /** Pure single-index toggle retained for callers/tests. */
30
26
  export declare function toggled(checked: readonly boolean[], index: number): boolean[];
31
27
  /**
32
- * Single-select: Up/Down/j/k moves the highlight, Enter confirms. Resolves
33
- * the confirmed index, or `null` on Ctrl+C cancel. Caller (onboard.ts)
34
- * must have already confirmed `canUseInteractivePicker()` — this function
35
- * does not re-check, and assumes `streams.input` genuinely supports raw
36
- * mode.
28
+ * Single-select prompt. The returned number is the original item index, so
29
+ * callers do not depend on display labels or prompt-library values.
37
30
  */
38
- export declare function runSingleSelectPicker(items: string[], streams?: PickerStreams): Promise<number | null>;
31
+ export declare function runSingleSelectPicker(items: readonly (string | PickerOption)[], streams?: PickerStreams, message?: string): Promise<number | null>;
39
32
  /**
40
- * Multi-select (checkbox): Up/Down/j/k moves the highlight, Space toggles
41
- * the current row, Enter confirms. Resolves the checked indices at
42
- * confirm time, or `null` on Ctrl+C cancel. Same precondition as
43
- * `runSingleSelectPicker`.
33
+ * Checkbox prompt. The returned indices preserve the existing onboard
34
+ * contract, while Clack handles checked state, wrapping, colors, and cleanup.
44
35
  */
45
- export declare function runMultiSelectPicker(items: string[], initiallyChecked: readonly boolean[], streams?: PickerStreams): Promise<number[] | null>;
36
+ export declare function runMultiSelectPicker(items: string[], initiallyChecked: readonly boolean[], streams?: PickerStreams, message?: string): Promise<number[] | null>;
37
+ /** Searchable multiselect for large skill/MCP inventories. It returns the
38
+ * selected labels as stable names, preserving the semantic adapter contract
39
+ * while avoiding a wall of dozens of rows in the onboarding terminal. */
40
+ export declare function runSearchMultiSelectPicker(items: readonly string[], streams?: PickerStreams, message?: string): Promise<string[] | null>;