toolcraft 0.0.20 → 0.0.21

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 (33) hide show
  1. package/README.md +43 -30
  2. package/dist/sdk.d.ts +2 -0
  3. package/dist/sdk.js +3 -1
  4. package/node_modules/@poe-code/agent-defs/dist/agents/claude-code.js +1 -0
  5. package/node_modules/@poe-code/agent-defs/dist/agents/codex.js +1 -0
  6. package/node_modules/@poe-code/agent-defs/dist/agents/gemini-cli.d.ts +2 -0
  7. package/node_modules/@poe-code/agent-defs/dist/agents/gemini-cli.js +16 -0
  8. package/node_modules/@poe-code/agent-defs/dist/agents/goose.js +1 -0
  9. package/node_modules/@poe-code/agent-defs/dist/agents/index.d.ts +1 -0
  10. package/node_modules/@poe-code/agent-defs/dist/agents/index.js +1 -0
  11. package/node_modules/@poe-code/agent-defs/dist/agents/kimi.js +1 -0
  12. package/node_modules/@poe-code/agent-defs/dist/agents/opencode.js +1 -0
  13. package/node_modules/@poe-code/agent-defs/dist/agents/poe-agent.js +1 -0
  14. package/node_modules/@poe-code/agent-defs/dist/index.d.ts +2 -2
  15. package/node_modules/@poe-code/agent-defs/dist/index.js +1 -1
  16. package/node_modules/@poe-code/agent-defs/dist/registry.js +2 -1
  17. package/node_modules/@poe-code/agent-defs/dist/types.d.ts +2 -0
  18. package/node_modules/@poe-code/design-system/dist/components/browser.d.ts +15 -0
  19. package/node_modules/@poe-code/design-system/dist/components/browser.js +26 -0
  20. package/node_modules/@poe-code/design-system/dist/explorer/index.d.ts +1 -1
  21. package/node_modules/@poe-code/design-system/dist/explorer/keymap.js +6 -3
  22. package/node_modules/@poe-code/design-system/dist/explorer/render/footer.js +14 -0
  23. package/node_modules/@poe-code/design-system/dist/explorer/runtime.js +11 -4
  24. package/node_modules/@poe-code/design-system/dist/explorer/state.d.ts +6 -1
  25. package/node_modules/@poe-code/design-system/dist/index.d.ts +2 -1
  26. package/node_modules/@poe-code/design-system/dist/index.js +1 -0
  27. package/node_modules/@poe-code/task-list/README.md +43 -26
  28. package/node_modules/@poe-code/task-list/dist/backends/gh-issues.js +19 -2
  29. package/node_modules/@poe-code/task-list/dist/backends/markdown-dir.js +13 -11
  30. package/node_modules/@poe-code/task-list/dist/backends/yaml-file.js +17 -15
  31. package/node_modules/@poe-code/task-list/dist/types.d.ts +2 -0
  32. package/node_modules/auth-store/dist/provider-store.js +4 -2
  33. package/package.json +2 -2
package/README.md CHANGED
@@ -59,12 +59,12 @@ export const greet = defineCommand({
59
59
  description: "Say hello",
60
60
  params: S.Object({
61
61
  name: S.String({ description: "Who to greet" }),
62
- loud: S.Optional(S.Boolean({ default: false })),
62
+ loud: S.Optional(S.Boolean({ default: false }))
63
63
  }),
64
64
  handler: async ({ params }) => {
65
65
  const message = `Hello, ${params.name}`;
66
66
  return { message: params.loud ? message.toUpperCase() : message };
67
- },
67
+ }
68
68
  });
69
69
  ```
70
70
 
@@ -75,7 +75,7 @@ import { greet } from "./commands/greet.js";
75
75
 
76
76
  export const root = defineGroup({
77
77
  name: "mytool",
78
- children: [greet],
78
+ children: [greet]
79
79
  });
80
80
  ```
81
81
 
@@ -178,6 +178,8 @@ The same `root` flows into all three. No duplication.
178
178
 
179
179
  **Tree**: the `root` group is a `defineGroup` whose children are commands and sub-groups. Any depth. CLI flags, MCP tool names, and SDK methods are derived from the path.
180
180
 
181
+ **CLI help**: group help lists visible child commands with their parameter tokens inline. Required options appear as `--name <type>`, optional options and defaults appear in brackets like `[--limit <number>]`, and positional parameters render as positional tokens like `<name>` or `[name]` depending on whether they are required. Command-specific `--help` still shows the detailed parameter table.
182
+
181
183
  ## Secrets
182
184
 
183
185
  Declare env-backed secrets on a command or group. Toolcraft reads `process.env` at command-run time and passes the values to the handler:
@@ -188,12 +190,12 @@ const deploy = defineCommand({
188
190
  params: S.Object({ service: S.String() }),
189
191
  secrets: {
190
192
  apiKey: { env: "DEPLOY_API_KEY", description: "Required for /deploy endpoint" },
191
- debugToken: { env: "DEPLOY_DEBUG", optional: true },
193
+ debugToken: { env: "DEPLOY_DEBUG", optional: true }
192
194
  },
193
195
  handler: async ({ params, secrets }) => {
194
196
  // secrets.apiKey: string
195
197
  // secrets.debugToken: string | undefined
196
- },
198
+ }
197
199
  });
198
200
  ```
199
201
 
@@ -205,13 +207,14 @@ Required secrets that aren't set produce a `UserError` with the env var name and
205
207
  defineCommand({
206
208
  // ...
207
209
  requires: {
208
- auth: true, // fails if POE_API_KEY (or runner-specified env) is missing
209
- apiVersion: ">=1.2.0", // fails if runner reports older apiVersion
210
- check: async (ctx) => ({ // arbitrary async gate
210
+ auth: true, // fails if POE_API_KEY (or runner-specified env) is missing
211
+ apiVersion: ">=1.2.0", // fails if runner reports older apiVersion
212
+ check: async (ctx) => ({
213
+ // arbitrary async gate
211
214
  ok: ctx.fs.exists(".lock") === false,
212
- message: ".lock present, refusing to run",
213
- }),
214
- },
215
+ message: ".lock present, refusing to run"
216
+ })
217
+ }
215
218
  });
216
219
  ```
217
220
 
@@ -239,7 +242,7 @@ defineCommand<Services>({
239
242
  handler: async ({ params, db, logger }) => {
240
243
  logger.info("running");
241
244
  return db.query(params.id);
242
- },
245
+ }
243
246
  });
244
247
  ```
245
248
 
@@ -257,8 +260,8 @@ defineCommand({
257
260
  rich: (result, { renderTable }) =>
258
261
  console.log(renderTable({ rows: result.rows, columns: ["id"] })),
259
262
  markdown: (result) => `Found ${result.rows.length} rows`,
260
- json: (result) => result,
261
- },
263
+ json: (result) => result
264
+ }
262
265
  });
263
266
  ```
264
267
 
@@ -273,13 +276,13 @@ defineGroup({
273
276
  name: "github",
274
277
  mcp: {
275
278
  transport: "stdio",
276
- command: "github-mcp-server",
279
+ command: "github-mcp-server"
277
280
  },
278
281
  tools: ["create_issue", "list_issues"],
279
282
  rename: {
280
- create_issue: "issues.create",
283
+ create_issue: "issues.create"
281
284
  },
282
- children: [],
285
+ children: []
283
286
  });
284
287
  ```
285
288
 
@@ -301,21 +304,21 @@ defineGroup({
301
304
  name: "deploy",
302
305
  humanInLoop: {
303
306
  mode: "async",
304
- message: ({ commandPath, params }) => `Run ${commandPath} for ${params.target}?`,
307
+ message: ({ commandPath, params }) => `Run ${commandPath} for ${params.target}?`
305
308
  },
306
309
  children: [
307
310
  defineCommand({
308
311
  name: "prod",
309
312
  params: S.Object({ target: S.String() }),
310
- handler: async ({ params }) => ({ target: params.target }),
313
+ handler: async ({ params }) => ({ target: params.target })
311
314
  }),
312
315
  defineCommand({
313
316
  name: "preview",
314
317
  params: S.Object({ target: S.String() }),
315
- humanInLoop: null, // opt out
316
- handler: async ({ params }) => ({ target: params.target }),
317
- }),
318
- ],
318
+ humanInLoop: null, // opt out
319
+ handler: async ({ params }) => ({ target: params.target })
320
+ })
321
+ ]
319
322
  });
320
323
  ```
321
324
 
@@ -329,7 +332,7 @@ Wire the same `humanInLoop` options into every entrypoint:
329
332
  ```ts
330
333
  const humanInLoop = {
331
334
  provider: slackApprovalProvider({ channel: "#deploys", client }),
332
- taskList: { dir: ".toolcraft/approvals.yaml", format: "yaml-file" as const },
335
+ taskList: { dir: ".toolcraft/approvals.yaml", format: "yaml-file" as const }
333
336
  };
334
337
 
335
338
  await runCLI(root, { humanInLoop });
@@ -354,7 +357,11 @@ Async results must be JSON-serializable; non-serializable returns mark the appro
354
357
  A minimal Slack-style provider:
355
358
 
356
359
  ```ts
357
- import type { ApprovalRequest, ApprovalResult, HumanInLoopProvider } from "@poe-code/agent-human-in-loop";
360
+ import type {
361
+ ApprovalRequest,
362
+ ApprovalResult,
363
+ HumanInLoopProvider
364
+ } from "@poe-code/agent-human-in-loop";
358
365
 
359
366
  export function slackApprovalProvider(opts: {
360
367
  channel: string;
@@ -380,14 +387,18 @@ export function slackApprovalProvider(opts: {
380
387
  }
381
388
 
382
389
  return { outcome: "declined" };
383
- },
390
+ }
384
391
  };
385
392
  }
386
393
  ```
387
394
 
388
395
  ## Errors
389
396
 
390
- Throw `UserError` for expected, user-facing failures. The CLI prints the message without a stack trace and sets exit code 1; MCP and SDK surface the message as the error body. Any other thrown error is treated as unexpected and shows a stack with `--debug`.
397
+ Throw `UserError` for expected, user-facing failures. The CLI prints the message without a stack trace and sets exit code 1; MCP and SDK surface the message as the error body. Usage mistakes include a pointer to the relevant command help. Any other thrown error is treated as unexpected and shows a trimmed stack with `--debug`; use `--debug=raw` to include framework and runtime frames.
398
+
399
+ HTTP-style errors with request/response context print the request, status, and a response-body snippet by default. `--verbose` or `--debug` prints headers and the full request/response bodies, with authorization headers redacted.
400
+
401
+ Enable structured error reports with `errorReports: true`, `errorReports: { dir }`, or `TOOLCRAFT_ERROR_REPORTS=1`. Reports are written under `.toolcraft/errors` by default, include argv, parsed params, resolved secret presence, structured error fields, stack/cause chains, and HTTP transcripts, and redact declared secrets plus parameter names that look sensitive.
391
402
 
392
403
  ## Migrating from a folder of scripts
393
404
 
@@ -405,6 +416,7 @@ If you have an existing MCP server you want to keep running, use the MCP proxy:
405
416
  ## Environment variables
406
417
 
407
418
  - `TOOLCRAFT_MCP_REFRESH` — MCP proxy cache refresh (`unset` = use cache, `1`/`true` = refresh all, comma-separated names = refresh those).
419
+ - `TOOLCRAFT_ERROR_REPORTS=1` — enables structured error report files for CLI, MCP, and SDK surfaces that wire `errorReports`.
408
420
  - Per-command `secrets` declarations name additional env vars. They are read at command run time and passed to the handler.
409
421
 
410
422
  ## API reference
@@ -444,19 +456,20 @@ If you have an existing MCP server you want to keep running, use the MCP proxy:
444
456
  - `presets?: boolean` — enables `--preset <path>` for loading parameter defaults from JSON files.
445
457
  - `apiVersion?: string` — for `requires.apiVersion`.
446
458
  - `humanInLoop?: HumanInLoopRuntimeOptions`
459
+ - `errorReports?: boolean | { dir?: string }`
447
460
  - `projectRoot?: string` — root used for MCP proxy cache files (`.toolcraft/mcp/*.json`).
448
461
 
449
462
  ### `createSDK(root, options)`
450
463
 
451
464
  - `casing?: "camel"` — generated SDK member style.
452
- - `services?` / `humanInLoop?` / `apiVersion?`
465
+ - `services?` / `humanInLoop?` / `apiVersion?` / `errorReports?`
453
466
  - `projectRoot?: string` — root used for MCP proxy cache files (`.toolcraft/mcp/*.json`).
454
467
 
455
468
  ### `createMCPServer(root, options)` / `runMCP(root, options)`
456
469
 
457
470
  - `name: string`
458
471
  - `version: string`
459
- - `services?` / `humanInLoop?` / `apiVersion?`
472
+ - `services?` / `humanInLoop?` / `apiVersion?` / `errorReports?`
460
473
  - `projectRoot?: string` — root used for MCP proxy cache files (`.toolcraft/mcp/*.json`).
461
474
  - `tools?: string[]` — allowlist of MCP tool names or group prefixes. Tool names are `__`-joined snake_case path segments (`root__bot__create`); a prefix like `root__bot` includes every descendant tool.
462
475
  - `omitRootToolNamePrefix?: boolean` — defaults to `false`. Set to `true` to omit the root group name from single-root MCP tool names (`bot__create`).
@@ -468,7 +481,7 @@ If you have an existing MCP server you want to keep running, use the MCP proxy:
468
481
  type HumanInLoopRuntimeOptions = {
469
482
  provider?: HumanInLoopProvider;
470
483
  taskList?: TaskList | { dir: string; format: "markdown-dir" | "yaml-file" };
471
- listName?: string; // defaults to "approvals"
484
+ listName?: string; // defaults to "approvals"
472
485
  binPath?: { execPath: string; entryArgs: readonly string[] };
473
486
  };
474
487
  ```
package/dist/sdk.d.ts CHANGED
@@ -64,6 +64,7 @@ export interface CreateSDKOptions<TServices extends object = Record<string, unkn
64
64
  services?: TServices;
65
65
  casing?: "camel";
66
66
  humanInLoop?: HumanInLoopRuntimeOptions;
67
+ apiVersion?: string;
67
68
  projectRoot?: string;
68
69
  errorReports?: ErrorReportsOption;
69
70
  }
@@ -72,4 +73,5 @@ export declare function createSDK<TRootInfo, TServices extends object = Record<s
72
73
  }, options?: CreateSDKOptions<TServices>): TRootInfo extends {
73
74
  children: infer TChildren extends readonly unknown[];
74
75
  } ? SDKChildrenShape<TChildren, undefined, undefined> : EmptyRecord;
76
+ export declare function createSDK<TServices extends object = Record<string, unknown>>(root: Group<TServices>, options?: CreateSDKOptions<TServices>): Record<string, unknown>;
75
77
  export {};
package/dist/sdk.js CHANGED
@@ -269,7 +269,9 @@ function createResolvedSDK(root, options = {}) {
269
269
  return undefined;
270
270
  }
271
271
  };
272
- await assertCommandRequirements(node, { ...baseContext, params: undefined });
272
+ await assertCommandRequirements(node, { ...baseContext, params: undefined }, {
273
+ apiVersion: options.apiVersion
274
+ });
273
275
  const paramsSchema = filterSchemaForScope(node.params, "sdk");
274
276
  if (paramsSchema === undefined || paramsSchema.kind !== "object") {
275
277
  throw new ToolcraftBugError(`command "${node.name}" must define an object params schema for SDK.`);
@@ -5,6 +5,7 @@ export const claudeCodeAgent = {
5
5
  summary: "Configure Claude Code to route through Poe.",
6
6
  aliases: ["claude"],
7
7
  binaryName: "claude",
8
+ apiShapes: ["anthropic-messages"],
8
9
  configPath: "~/.claude/settings.json",
9
10
  branding: {
10
11
  colors: {
@@ -4,6 +4,7 @@ export const codexAgent = {
4
4
  label: "Codex",
5
5
  summary: "Configure Codex to use Poe as the model provider.",
6
6
  binaryName: "codex",
7
+ apiShapes: ["openai-responses"],
7
8
  configPath: "~/.codex/config.toml",
8
9
  branding: {
9
10
  colors: {
@@ -0,0 +1,2 @@
1
+ import type { AgentDefinition } from "../types.js";
2
+ export declare const geminiCliAgent: AgentDefinition;
@@ -0,0 +1,16 @@
1
+ export const geminiCliAgent = {
2
+ id: "gemini-cli",
3
+ name: "gemini-cli",
4
+ aliases: ["gemini"],
5
+ label: "Gemini CLI",
6
+ summary: "Configure Google's Gemini CLI to use a compatible Google generations API.",
7
+ binaryName: "gemini",
8
+ configPath: "~/.gemini/settings.json",
9
+ apiShapes: ["google-generations"],
10
+ branding: {
11
+ colors: {
12
+ dark: "#8AB4F8",
13
+ light: "#1A73E8"
14
+ }
15
+ }
16
+ };
@@ -4,6 +4,7 @@ export const gooseAgent = {
4
4
  label: "Goose",
5
5
  summary: "Block's open-source AI agent with ACP support.",
6
6
  binaryName: "goose",
7
+ apiShapes: ["openai-chat-completions"],
7
8
  configPath: "~/.config/goose/config.yaml",
8
9
  branding: {
9
10
  colors: {
@@ -1,6 +1,7 @@
1
1
  export { claudeCodeAgent } from "./claude-code.js";
2
2
  export { claudeDesktopAgent } from "./claude-desktop.js";
3
3
  export { codexAgent } from "./codex.js";
4
+ export { geminiCliAgent } from "./gemini-cli.js";
4
5
  export { openCodeAgent } from "./opencode.js";
5
6
  export { kimiAgent } from "./kimi.js";
6
7
  export { gooseAgent } from "./goose.js";
@@ -1,6 +1,7 @@
1
1
  export { claudeCodeAgent } from "./claude-code.js";
2
2
  export { claudeDesktopAgent } from "./claude-desktop.js";
3
3
  export { codexAgent } from "./codex.js";
4
+ export { geminiCliAgent } from "./gemini-cli.js";
4
5
  export { openCodeAgent } from "./opencode.js";
5
6
  export { kimiAgent } from "./kimi.js";
6
7
  export { gooseAgent } from "./goose.js";
@@ -5,6 +5,7 @@ export const kimiAgent = {
5
5
  summary: "Configure Kimi CLI to use Poe API",
6
6
  aliases: ["kimi-cli"],
7
7
  binaryName: "kimi",
8
+ apiShapes: ["openai-chat-completions"],
8
9
  configPath: "~/.kimi/config.toml",
9
10
  branding: {
10
11
  colors: {
@@ -4,6 +4,7 @@ export const openCodeAgent = {
4
4
  label: "OpenCode CLI",
5
5
  summary: "Configure OpenCode CLI to use the Poe API.",
6
6
  binaryName: "opencode",
7
+ apiShapes: ["openai-chat-completions"],
7
8
  configPath: "~/.config/opencode/config.json",
8
9
  branding: {
9
10
  colors: {
@@ -3,6 +3,7 @@ export const poeAgentAgent = {
3
3
  name: "poe-agent",
4
4
  label: "Poe Agent",
5
5
  summary: "Run one-shot prompts with the built-in Poe agent runtime.",
6
+ apiShapes: ["openai-responses", "openai-chat-completions"],
6
7
  configPath: "~/.poe-code/config.json",
7
8
  branding: {
8
9
  colors: {
@@ -1,5 +1,5 @@
1
- export type { AgentDefinition } from "./types.js";
1
+ export type { AgentDefinition, ApiShapeId } from "./types.js";
2
2
  export type { AgentSpecifier } from "./specifier.js";
3
- export { claudeCodeAgent, claudeDesktopAgent, codexAgent, openCodeAgent, kimiAgent, gooseAgent, poeAgentAgent } from "./agents/index.js";
3
+ export { claudeCodeAgent, claudeDesktopAgent, codexAgent, geminiCliAgent, openCodeAgent, kimiAgent, gooseAgent, poeAgentAgent } from "./agents/index.js";
4
4
  export { allAgents, resolveAgentId } from "./registry.js";
5
5
  export { parseAgentSpecifier, formatAgentSpecifier, normalizeAgentId } from "./specifier.js";
@@ -1,3 +1,3 @@
1
- export { claudeCodeAgent, claudeDesktopAgent, codexAgent, openCodeAgent, kimiAgent, gooseAgent, poeAgentAgent } from "./agents/index.js";
1
+ export { claudeCodeAgent, claudeDesktopAgent, codexAgent, geminiCliAgent, openCodeAgent, kimiAgent, gooseAgent, poeAgentAgent } from "./agents/index.js";
2
2
  export { allAgents, resolveAgentId } from "./registry.js";
3
3
  export { parseAgentSpecifier, formatAgentSpecifier, normalizeAgentId } from "./specifier.js";
@@ -1,8 +1,9 @@
1
- import { claudeCodeAgent, claudeDesktopAgent, codexAgent, openCodeAgent, kimiAgent, gooseAgent, poeAgentAgent } from "./agents/index.js";
1
+ import { claudeCodeAgent, claudeDesktopAgent, codexAgent, geminiCliAgent, openCodeAgent, kimiAgent, gooseAgent, poeAgentAgent } from "./agents/index.js";
2
2
  export const allAgents = [
3
3
  claudeCodeAgent,
4
4
  claudeDesktopAgent,
5
5
  codexAgent,
6
+ geminiCliAgent,
6
7
  openCodeAgent,
7
8
  kimiAgent,
8
9
  gooseAgent,
@@ -1,3 +1,4 @@
1
+ export type ApiShapeId = "openai-chat-completions" | "openai-responses" | "anthropic-messages" | "google-generations";
1
2
  export interface AgentDefinition {
2
3
  id: string;
3
4
  name: string;
@@ -6,6 +7,7 @@ export interface AgentDefinition {
6
7
  aliases?: string[];
7
8
  /** Binary name for CLI agents. Optional for GUI-only apps like Claude Desktop. */
8
9
  binaryName?: string;
10
+ readonly apiShapes?: readonly ApiShapeId[];
9
11
  configPath: string;
10
12
  branding: {
11
13
  colors: {
@@ -0,0 +1,15 @@
1
+ interface BrowserProcess {
2
+ once(event: "error", listener: (error: Error) => void): this;
3
+ once(event: "spawn", listener: () => void): this;
4
+ unref(): void;
5
+ }
6
+ type SpawnBrowserProcess = (command: string, args: string[], options: {
7
+ detached: true;
8
+ stdio: "ignore";
9
+ }) => BrowserProcess;
10
+ export interface OpenExternalOptions {
11
+ platform?: NodeJS.Platform;
12
+ spawnProcess?: SpawnBrowserProcess;
13
+ }
14
+ export declare function openExternal(url: string, options?: OpenExternalOptions): Promise<void>;
15
+ export {};
@@ -0,0 +1,26 @@
1
+ import { spawn } from "node:child_process";
2
+ import process from "node:process";
3
+ export async function openExternal(url, options = {}) {
4
+ const parsed = new URL(url);
5
+ const { command, args } = browserCommand(parsed.href, options.platform ?? process.platform);
6
+ await launchBrowser(command, args, options.spawnProcess ?? spawn);
7
+ }
8
+ function browserCommand(url, platform) {
9
+ if (platform === "darwin") {
10
+ return { command: "open", args: [url] };
11
+ }
12
+ if (platform === "win32") {
13
+ return { command: "cmd", args: ["/c", "start", "", url] };
14
+ }
15
+ return { command: "xdg-open", args: [url] };
16
+ }
17
+ function launchBrowser(command, args, spawnProcess) {
18
+ return new Promise((resolve, reject) => {
19
+ const child = spawnProcess(command, args, { detached: true, stdio: "ignore" });
20
+ child.once("error", reject);
21
+ child.once("spawn", () => {
22
+ child.unref();
23
+ resolve();
24
+ });
25
+ });
26
+ }
@@ -4,5 +4,5 @@ export { createInitialState } from "./state.js";
4
4
  export { resolveBindings } from "./keymap.js";
5
5
  export type { Effect, ExplorerEvent } from "./events.js";
6
6
  export type { BindingTarget, ExplorerBindingDefaults, ExplorerBuiltinCommand, ResolvedBindings } from "./keymap.js";
7
- export type { Action, ActionContext, Detail, DetailCtx, DetailItem, Dirty, ExplorerConfig, ExplorerLayoutMode, ExplorerSize, ExplorerState, Row, Tone } from "./state.js";
7
+ export type { Action, ActionContext, Detail, DetailCtx, DetailItem, Dirty, ExplorerConfig, ExplorerLayoutMode, ExplorerSize, ExplorerState, ReorderContext, Row, Tone } from "./state.js";
8
8
  export declare function singleDetail<R>(fn: (row: Row, ctx: DetailCtx) => string | Promise<string>): Detail<R>;
@@ -19,8 +19,8 @@ const builtinBindings = {
19
19
  detailScrollUp: ["Ctrl+b"],
20
20
  extendSelectionUp: ["Shift+up"],
21
21
  extendSelectionDown: ["Shift+down"],
22
- reorderUp: ["Ctrl+up", "K"],
23
- reorderDown: ["Ctrl+down", "J"]
22
+ reorderUp: ["Shift+up", "K"],
23
+ reorderDown: ["Shift+down", "J"]
24
24
  };
25
25
  const baseBuiltinCommands = [
26
26
  "quit",
@@ -49,7 +49,10 @@ const reservedActionIds = new Set(["quit"]);
49
49
  export function resolveBindings(config, defaults = {}) {
50
50
  const commands = config.reorder === undefined
51
51
  ? baseBuiltinCommands
52
- : [...baseBuiltinCommands, ...reorderCommands];
52
+ : [
53
+ ...baseBuiltinCommands.filter((command) => command !== "extendSelectionUp" && command !== "extendSelectionDown"),
54
+ ...reorderCommands
55
+ ];
53
56
  const commandBindings = new Map();
54
57
  const flatBindings = new Map();
55
58
  const targetKeys = new Map();
@@ -13,6 +13,12 @@ export function renderFooter(state, screen, layout) {
13
13
  if (x >= rect.x + rect.width) {
14
14
  break;
15
15
  }
16
+ if (hint.bracketed === false) {
17
+ const text = `${hint.key} ${hint.label}`;
18
+ screen.put(x, y, text, hint.running ? styles.muted : {});
19
+ x += text.length + 2;
20
+ continue;
21
+ }
16
22
  screen.put(x, y, `[${hint.key}]`, hint.running ? styles.muted : styles.accent);
17
23
  x += hint.key.length + 2;
18
24
  screen.put(x, y, ` ${hint.label}`, hint.running ? styles.muted : {});
@@ -37,9 +43,17 @@ function footerHints(state) {
37
43
  }
38
44
  hints.push({ key: "?", label: "help", running: false });
39
45
  hints.push({ key: "Ctrl+P", label: "palette", running: false });
46
+ if (hasShiftReorderBindings(state)) {
47
+ hints.push({ key: "⇧↑↓", label: "reorder (within state)", running: false, bracketed: false });
48
+ }
40
49
  hints.push({ key: "q", label: "quit", running: false });
41
50
  return hints;
42
51
  }
52
+ function hasShiftReorderBindings(state) {
53
+ const up = state.bindings.keysByTarget.get("builtin:reorderUp") ?? [];
54
+ const down = state.bindings.keysByTarget.get("builtin:reorderDown") ?? [];
55
+ return up.includes("Shift+up") && down.includes("Shift+down");
56
+ }
43
57
  function actionKey(entry, fallback) {
44
58
  const key = entry.action?.key;
45
59
  if (Array.isArray(key)) {
@@ -36,7 +36,7 @@ class ExplorerRuntime {
36
36
  });
37
37
  this.runtimeHandles = {
38
38
  refresh: async () => {
39
- await this.refreshRows();
39
+ await this.refreshRowsFromSource();
40
40
  },
41
41
  suspendAnd: async (fn) => this.suspendAnd(fn),
42
42
  toast: (msg, tone) => {
@@ -54,7 +54,7 @@ class ExplorerRuntime {
54
54
  try {
55
55
  this.startTerminal();
56
56
  this.render();
57
- this.refreshRows().catch((error) => {
57
+ this.loadRows().catch((error) => {
58
58
  this.fail(error);
59
59
  });
60
60
  }
@@ -76,10 +76,14 @@ class ExplorerRuntime {
76
76
  this.dispatch({ type: "resize", cols: size.cols, rows: size.rows });
77
77
  });
78
78
  }
79
- async refreshRows() {
79
+ async loadRows() {
80
80
  const rows = await this.config.rows();
81
81
  this.dispatch({ type: "rowsLoaded", rows });
82
82
  }
83
+ async refreshRowsFromSource() {
84
+ await this.config.refresh?.();
85
+ await this.loadRows();
86
+ }
83
87
  dispatch(event) {
84
88
  if (this.stopped) {
85
89
  return;
@@ -128,7 +132,10 @@ class ExplorerRuntime {
128
132
  }
129
133
  async persistOrder(orderedIds, previousRows) {
130
134
  try {
131
- await this.config.reorder?.onReorder(orderedIds);
135
+ await this.config.reorder?.onReorder(orderedIds, {
136
+ refresh: this.runtimeHandles.refresh,
137
+ toast: this.runtimeHandles.toast
138
+ });
132
139
  }
133
140
  catch (error) {
134
141
  this.showToast(error instanceof Error ? error.message : "Could not persist order", "error");
@@ -51,13 +51,18 @@ export interface ActionContext<R> {
51
51
  confirm: (prompt: string) => Promise<boolean>;
52
52
  exit: (after?: () => void | Promise<void>) => void;
53
53
  }
54
+ export interface ReorderContext {
55
+ refresh: () => Promise<void>;
56
+ toast: (msg: string, tone?: Tone) => void;
57
+ }
54
58
  export interface ExplorerConfig<R> {
55
59
  title: string;
56
60
  rows: () => Promise<Row[]>;
61
+ refresh?: () => Promise<void>;
57
62
  detail: Detail<R>;
58
63
  actions: Action<R>[];
59
64
  reorder?: {
60
- onReorder: (orderedIds: string[]) => void | Promise<void>;
65
+ onReorder: (orderedIds: string[], ctx?: ReorderContext) => void | Promise<void>;
61
66
  };
62
67
  multiSelect?: boolean;
63
68
  keybindOverrides?: Record<string, string | string[]>;
@@ -19,13 +19,14 @@ export { renderTable } from "./components/table.js";
19
19
  export type { TableColumn, RenderTableOptions } from "./components/table.js";
20
20
  export { renderTemplate } from "./components/template.js";
21
21
  export type { RenderTemplateOptions, TemplateEscape } from "./components/template.js";
22
+ export { openExternal } from "./components/browser.js";
22
23
  export * as acp from "./acp/index.js";
23
24
  export * as dashboard from "./dashboard/index.js";
24
25
  export { createDashboard, shouldUseInteractiveDashboard } from "./dashboard/index.js";
25
26
  export type { Dashboard, DashboardOptions } from "./dashboard/index.js";
26
27
  export * as explorer from "./explorer/index.js";
27
28
  export { runExplorer, singleDetail } from "./explorer/index.js";
28
- export type { Row, DetailItem, Detail, DetailCtx, Action, ActionContext, ExplorerConfig, Tone, } from "./explorer/index.js";
29
+ export type { Row, DetailItem, Detail, DetailCtx, Action, ActionContext, ExplorerConfig, ReorderContext, Tone, } from "./explorer/index.js";
29
30
  export * as prompts from "./prompts/index.js";
30
31
  export { intro, introPlain, outro, note, select, multiselect, text as promptText, confirm, confirmOrCancel, password, spinner, withSpinner, isCancel, cancel, log, PromptCancelledError } from "./prompts/index.js";
31
32
  export type { SelectOptions, MultiselectOptions, TextOptions, ConfirmOptions, PasswordOptions, SpinnerOptions, WithSpinnerOptions } from "./prompts/index.js";
@@ -15,6 +15,7 @@ export { formatCommandNotFound } from "./components/command-errors.js";
15
15
  export { formatCommandNotFoundPanel } from "./components/command-errors.js";
16
16
  export { renderTable } from "./components/table.js";
17
17
  export { renderTemplate } from "./components/template.js";
18
+ export { openExternal } from "./components/browser.js";
18
19
  // ACP rendering
19
20
  export * as acp from "./acp/index.js";
20
21
  // Dashboard
@@ -6,11 +6,11 @@ Multi-list task manager with pluggable storage backends.
6
6
 
7
7
  `@poe-code/task-list` exposes one API over these backends:
8
8
 
9
- | Backend | Storage |
10
- | --- | --- |
11
- | `markdown-dir` | One Markdown file per task, organized into subdirectories per list. |
12
- | `yaml-file` | One YAML document with a top-level `lists:` mapping. |
13
- | `gh-issues` | GitHub Issues in one repository, ordered and state-tracked through a GitHub Project v2 Status field. |
9
+ | Backend | Storage |
10
+ | -------------- | ---------------------------------------------------------------------------------------------------- |
11
+ | `markdown-dir` | One Markdown file per task, organized into subdirectories per list. |
12
+ | `yaml-file` | One YAML document with a top-level `lists:` mapping. |
13
+ | `gh-issues` | GitHub Issues in one repository, ordered and state-tracked through a GitHub Project v2 Status field. |
14
14
 
15
15
  The task lifecycle is `draft -> planned -> in-progress -> done -> archived`. `archived` is terminal.
16
16
 
@@ -18,13 +18,13 @@ The task lifecycle is `draft -> planned -> in-progress -> done -> archived`. `ar
18
18
 
19
19
  - `openTaskList(options)`: opens a task store and returns a `TaskList`
20
20
  - `TaskList`: top-level interface for listing lists, querying all tasks, and resolving qualified IDs
21
- - `Tasks`: per-list interface for create, update, `fire`, `canFire`, `events`, delete, and list operations
21
+ - `Tasks`: per-list interface for create, update, `fire`, `canFire`, `events`, delete, `move`, `reorder`, and list operations
22
22
  - `Task`: normalized task record with `list`, `id`, `qualifiedId`, `name`, `state`, `description`, and `metadata`
23
23
  - `TaskState`: `"draft" | "planned" | "in-progress" | "done" | "archived"`
24
24
  - `TaskDefaults`: default `metadata` applied when creating new tasks
25
25
  - `StateMachineDef` / `EventDef`: exported types for custom task lifecycle definitions passed via `openTaskList({ stateMachine })`
26
26
  - `defaultStateMachine`: exported default lifecycle with `plan`, `start`, `complete`, and `archive` events
27
- - Error classes: `TaskNotFoundError`, `TaskAlreadyExistsError`, `InvalidTransitionError`, `MalformedTaskError`
27
+ - Error classes: `TaskNotFoundError`, `TaskAlreadyExistsError`, `InvalidTransitionError`, `MalformedTaskError`, `OrderMismatchError`, `AnchorNotFoundError`
28
28
 
29
29
  ## State Machines
30
30
 
@@ -48,21 +48,23 @@ Pass a custom machine with `openTaskList({ stateMachine })`. If omitted, the pac
48
48
 
49
49
  ## Options
50
50
 
51
- | Option | Type | Default | Behavior |
52
- | --- | --- | --- | --- |
53
- | `type` | `"markdown-dir" \| "yaml-file" \| "gh-issues"` | required | Selects the backend implementation. |
54
- | `path` | `string` | required | Root directory for `markdown-dir` or YAML file path for `yaml-file`. |
55
- | `defaults` | `TaskDefaults` | `{ metadata: {} }` | Seeds omitted metadata on new tasks only. New tasks always start at the configured state machine's initial state. |
56
- | `create` | `boolean` | `false` | Creates missing storage for the selected backend when enabled. |
57
- | `lockStaleMs` | `number` | `30_000` | Stale threshold passed to backend file locking. |
58
- | `lockRetries` | `number` | `20` | Retry count passed to backend file locking. |
59
- | `fs` | `TaskListFs` | `node:fs/promises` adapter | Injectable filesystem, primarily for tests. |
60
- | `stateMachine` | `StateMachineDef` | `defaultStateMachine` | Overrides the task lifecycle used by `create`, `fire`, `canFire`, and `events`. |
51
+ | Option | Type | Default | Behavior |
52
+ | ----------------- | ---------------------------------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------- |
53
+ | `type` | `"markdown-dir" \| "yaml-file" \| "gh-issues"` | required | Selects the backend implementation. |
54
+ | `path` | `string` | required | Root directory for `markdown-dir` or YAML file path for `yaml-file`. |
55
+ | `defaults` | `TaskDefaults` | `{ metadata: {} }` | Seeds omitted metadata on new tasks only. New tasks always start at the configured state machine's initial state. |
56
+ | `create` | `boolean` | `false` | Creates missing storage for the selected backend when enabled. |
57
+ | `singleList` | `string` | unset | `markdown-dir` only. Treats `path` as one list with this name instead of one subdirectory per list. |
58
+ | `frontmatterMode` | `"strict" \| "passthrough"` | `"strict"` | `markdown-dir` only. `passthrough` preserves non-task frontmatter and allows files without a frontmatter block. |
59
+ | `lockStaleMs` | `number` | `30_000` | Stale threshold passed to backend file locking. |
60
+ | `lockRetries` | `number` | `20` | Retry count passed to backend file locking. |
61
+ | `fs` | `TaskListFs` | `node:fs/promises` adapter | Injectable filesystem, primarily for tests. |
62
+ | `stateMachine` | `StateMachineDef` | `defaultStateMachine` | Overrides the task lifecycle used by `create`, `fire`, `canFire`, and `events`. |
61
63
 
62
64
  ## Env vars
63
65
 
64
- | Env var | Behavior |
65
- | --- | --- |
66
+ | Env var | Behavior |
67
+ | --------- | --------------------------------------------------------------- |
66
68
  | `GH_HOST` | Defers to gh CLI's host configuration; set GH_HOST to override. |
67
69
 
68
70
  ## Usage
@@ -88,6 +90,21 @@ await planning.create({
88
90
  await planning.fire("ship-readme", "plan");
89
91
  ```
90
92
 
93
+ By default, `markdown-dir` stores tasks as `<path>/<list>/<nn-id>.md`. Set `singleList` to treat the root directory as one list, which is how plan folders such as `docs/plans` are exposed through the task API:
94
+
95
+ ```ts
96
+ const plans = await openTaskList({
97
+ type: "markdown-dir",
98
+ path: "/repo/docs/plans",
99
+ singleList: "plans",
100
+ frontmatterMode: "passthrough"
101
+ });
102
+
103
+ await plans.list("plans").reorder(["api-shape-providers", "memory"]);
104
+ ```
105
+
106
+ `frontmatterMode: "strict"` expects full task frontmatter (`kind`, `version`, `name`, `state`, and related task fields). `frontmatterMode: "passthrough"` keeps unrelated frontmatter keys as task metadata and writes back only the task-owned fields (`name`, `description`, `state`), so existing plan metadata is preserved.
107
+
91
108
  ### `yaml-file`
92
109
 
93
110
  ```ts
@@ -225,14 +242,14 @@ poe-code tasks sync <list> --workflow ./WORKFLOW.md --repo octo-org/octo-repo --
225
242
 
226
243
  `<list>` and `--project` both use `<owner>/<number>` project syntax. `--workflow` defaults to `./WORKFLOW.md`. `--repo` overrides the task repository from workflow frontmatter. `--states` overrides the required state list from workflow frontmatter. `--json` prints the report object as JSON. `--yes` confirms non-interactive sync; it is only used by `poe-code tasks sync`.
227
244
 
228
- | Option | Commands | Behavior |
229
- | --- | --- | --- |
230
- | `--workflow <path>` | `verify`, `sync` | Workflow file path. Defaults to `./WORKFLOW.md`. |
231
- | `--repo <owner/name>` | `verify`, `sync` | GitHub repository owner/name. |
245
+ | Option | Commands | Behavior |
246
+ | -------------------------- | ---------------- | --------------------------------------------------- |
247
+ | `--workflow <path>` | `verify`, `sync` | Workflow file path. Defaults to `./WORKFLOW.md`. |
248
+ | `--repo <owner/name>` | `verify`, `sync` | GitHub repository owner/name. |
232
249
  | `--project <owner/number>` | `verify`, `sync` | GitHub Project v2 owner/number. Overrides `<list>`. |
233
- | `--states <csv>` | `verify`, `sync` | Required task state names. |
234
- | `--json` | `verify`, `sync` | Prints the report as JSON. |
235
- | `--yes` | `sync` | Confirms non-interactive provisioning. |
250
+ | `--states <csv>` | `verify`, `sync` | Required task state names. |
251
+ | `--json` | `verify`, `sync` | Prints the report as JSON. |
252
+ | `--yes` | `sync` | Confirms non-interactive provisioning. |
236
253
 
237
254
  The `Status` field name and option names are matched case-sensitively. The field must be named `Status`, and required option names must match exactly. For example, `status` is treated as a missing field, and `Done` is treated as missing when the required state is `done`.
238
255
 
@@ -159,6 +159,15 @@ const UPDATE_ISSUE_MUTATION = `mutation UpdateIssue($input: UpdateIssueInput!) {
159
159
  }
160
160
  }
161
161
  }`;
162
+ const ADD_COMMENT_MUTATION = `mutation AddComment($input: AddCommentInput!) {
163
+ addComment(input: $input) {
164
+ commentEdge {
165
+ node {
166
+ id
167
+ }
168
+ }
169
+ }
170
+ }`;
162
171
  const UPDATE_PROJECT_ITEM_POSITION_MUTATION = `mutation UpdateProjectItemPosition($input: UpdateProjectV2ItemPositionInput!) {
163
172
  updateProjectV2ItemPosition(input: $input) {
164
173
  clientMutationId
@@ -329,6 +338,14 @@ function createTasksView(name, session, context) {
329
338
  await updateProjectItemStatus(projectItemId, event, session, context);
330
339
  return fetchIssueTask(id, name, session, context);
331
340
  },
341
+ async comment(id, body) {
342
+ await context.client.graphql(ADD_COMMENT_MUTATION, {
343
+ input: {
344
+ subjectId: await resolveIssueId(id, name, context),
345
+ body
346
+ }
347
+ });
348
+ },
332
349
  async canFire(id, event) {
333
350
  return findEvent(session.stateMachine, id, event) !== undefined;
334
351
  },
@@ -587,7 +604,7 @@ function mapIssueToTask(options) {
587
604
  return {
588
605
  list: options.listName,
589
606
  id,
590
- qualifiedId: `${options.listName}/${id}`,
607
+ qualifiedId: `${options.listName}#${id}`,
591
608
  name: options.issue.title,
592
609
  description: options.issue.body ?? "",
593
610
  state: options.statusName ?? options.initialState,
@@ -639,7 +656,7 @@ function singleListError(listName) {
639
656
  return new Error(`gh-issues backend has a single list ${listName}`);
640
657
  }
641
658
  function parseQualifiedId(qualifiedId, listName) {
642
- const prefix = `${listName}/`;
659
+ const prefix = `${listName}#`;
643
660
  if (!qualifiedId.startsWith(prefix) || qualifiedId.length === prefix.length) {
644
661
  throw new Error(`Invalid qualified task id "${qualifiedId}".`);
645
662
  }
@@ -180,7 +180,7 @@ function metadataFromFrontmatter(frontmatter, mode) {
180
180
  }
181
181
  return metadata;
182
182
  }
183
- function createTask(list, id, frontmatter, body, mode) {
183
+ function createTask(list, id, frontmatter, body, mode, sourcePath) {
184
184
  return {
185
185
  list,
186
186
  id,
@@ -188,7 +188,8 @@ function createTask(list, id, frontmatter, body, mode) {
188
188
  name: frontmatter.name,
189
189
  state: frontmatter.state,
190
190
  description: body,
191
- metadata: metadataFromFrontmatter(frontmatter, mode)
191
+ metadata: metadataFromFrontmatter(frontmatter, mode),
192
+ ...(sourcePath !== undefined && { sourcePath: path.resolve(sourcePath) })
192
193
  };
193
194
  }
194
195
  function serializeTaskDocument(frontmatter, description) {
@@ -223,7 +224,7 @@ async function readTaskFile(fs, list, id, filePath, validStates, initialState, m
223
224
  return {
224
225
  path: filePath,
225
226
  frontmatter,
226
- task: createTask(list, id, frontmatter, document.body, mode)
227
+ task: createTask(list, id, frontmatter, document.body, mode, filePath)
227
228
  };
228
229
  }
229
230
  const parsedFilename = parseActiveFilename(path.basename(filePath));
@@ -238,7 +239,7 @@ async function readTaskFile(fs, list, id, filePath, validStates, initialState, m
238
239
  return {
239
240
  path: filePath,
240
241
  frontmatter,
241
- task: createTask(list, id, effectiveFrontmatter, document.body, mode)
242
+ task: createTask(list, id, effectiveFrontmatter, document.body, mode, filePath)
242
243
  };
243
244
  }
244
245
  async function findActiveTaskFilename(fs, listDirectoryPath, id) {
@@ -613,7 +614,7 @@ function createTasksView(deps, layout, list) {
613
614
  const frontmatter = createdFrontmatter(deps.defaults, input, stateMachine.initial, deps.frontmatterMode);
614
615
  const description = input.description ?? "";
615
616
  await writeAtomically(deps.fs, targetPath, serializeTaskDocument(frontmatter, description));
616
- return createTask(list, input.id, frontmatter, description, deps.frontmatterMode);
617
+ return createTask(list, input.id, frontmatter, description, deps.frontmatterMode, targetPath);
617
618
  });
618
619
  },
619
620
  async update(id, patch) {
@@ -623,7 +624,7 @@ function createTasksView(deps, layout, list) {
623
624
  const nextFrontmatter = updatedFrontmatter(existing.frontmatter, existing.task, patch, deps.frontmatterMode);
624
625
  const description = patch.description ?? existing.task.description;
625
626
  await writeAtomically(deps.fs, existing.path, serializeTaskDocument(nextFrontmatter, description));
626
- return createTask(list, id, nextFrontmatter, description, deps.frontmatterMode);
627
+ return createTask(list, id, nextFrontmatter, description, deps.frontmatterMode, existing.path);
627
628
  });
628
629
  },
629
630
  async fire(id, eventName, opts) {
@@ -641,7 +642,6 @@ function createTasksView(deps, layout, list) {
641
642
  }
642
643
  await event.onExit?.(existing.task);
643
644
  const nextFrontmatter = firedFrontmatter(existing.frontmatter, existing.task, event.to, deps.frontmatterMode, opts?.metadataPatch);
644
- const nextTask = createTask(list, id, nextFrontmatter, existing.task.description, deps.frontmatterMode);
645
645
  const serializedTask = serializeTaskDocument(nextFrontmatter, existing.task.description);
646
646
  if (event.to === "archived") {
647
647
  const targetPath = archivedTaskPath(deps.path, layout, list, id);
@@ -652,10 +652,12 @@ function createTasksView(deps, layout, list) {
652
652
  await writeAtomically(deps.fs, existing.path, serializedTask);
653
653
  await deps.fs.mkdir(archiveDirectoryPath(deps.path, layout, list), { recursive: true });
654
654
  await deps.fs.rename(existing.path, targetPath);
655
+ const nextTask = createTask(list, id, nextFrontmatter, existing.task.description, deps.frontmatterMode, targetPath);
655
656
  await event.onEnter?.(nextTask);
656
657
  return nextTask;
657
658
  }
658
659
  await writeAtomically(deps.fs, existing.path, serializedTask);
660
+ const nextTask = createTask(list, id, nextFrontmatter, existing.task.description, deps.frontmatterMode, existing.path);
659
661
  await event.onEnter?.(nextTask);
660
662
  return nextTask;
661
663
  };
@@ -695,7 +697,7 @@ function createTasksView(deps, layout, list) {
695
697
  async move(id, anchor) {
696
698
  validateTaskId(id);
697
699
  return withListLock(async () => {
698
- const { entries, tasks } = await readActiveTasks();
700
+ const { entries } = await readActiveTasks();
699
701
  const fromIndex = entries.findIndex((entry) => entry.id === id);
700
702
  if (fromIndex < 0) {
701
703
  throw new TaskNotFoundError(`Task "${list}/${id}" not found.`);
@@ -716,7 +718,7 @@ function createTasksView(deps, layout, list) {
716
718
  }
717
719
  ordered.splice(insertIndex, 0, id);
718
720
  await rewriteMovedPrefix(id, ordered);
719
- return tasks.get(id).task;
721
+ return (await getTaskFile(id)).task;
720
722
  });
721
723
  },
722
724
  async reorder(ids) {
@@ -724,7 +726,7 @@ function createTasksView(deps, layout, list) {
724
726
  validateTaskId(id);
725
727
  }
726
728
  return withListLock(async () => {
727
- const { entries, tasks } = await readActiveTasks();
729
+ const { entries } = await readActiveTasks();
728
730
  const currentIds = entries.map((entry) => entry.id);
729
731
  const currentSet = new Set(currentIds);
730
732
  const inputSet = new Set(ids);
@@ -734,7 +736,7 @@ function createTasksView(deps, layout, list) {
734
736
  throw new OrderMismatchError({ missing, extra });
735
737
  }
736
738
  await rewriteListPrefixes(ids);
737
- return ids.map((id) => tasks.get(id).task);
739
+ return Promise.all(ids.map(async (id) => (await getTaskFile(id)).task));
738
740
  });
739
741
  }
740
742
  };
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import { acquireFileLock } from "@poe-code/file-lock";
2
3
  import { isMap, parseDocument } from "yaml";
3
4
  import storeSchema from "../schema/store.schema.json" with { type: "json" };
@@ -61,7 +62,7 @@ function metadataFromTaskRecord(taskRecord) {
61
62
  }
62
63
  return metadata;
63
64
  }
64
- function createTask(list, id, taskRecord) {
65
+ function createTask(list, id, taskRecord, sourcePath) {
65
66
  return {
66
67
  list,
67
68
  id,
@@ -69,7 +70,8 @@ function createTask(list, id, taskRecord) {
69
70
  name: taskRecord.name,
70
71
  state: taskRecord.state,
71
72
  description: descriptionFromTaskRecord(taskRecord),
72
- metadata: metadataFromTaskRecord(taskRecord)
73
+ metadata: metadataFromTaskRecord(taskRecord),
74
+ ...(sourcePath !== undefined && { sourcePath: path.resolve(sourcePath) })
73
75
  };
74
76
  }
75
77
  function matchesFilter(task, filter) {
@@ -347,7 +349,7 @@ function createTasksView(deps, list) {
347
349
  }
348
350
  const entries = Object.entries(listRecord)
349
351
  .map(([id, taskRecord]) => ({
350
- task: createTask(list, id, taskRecord),
352
+ task: createTask(list, id, taskRecord, deps.path),
351
353
  raw: taskRecord
352
354
  }))
353
355
  .filter(({ task }) => matchesFilter(task, filter));
@@ -374,7 +376,7 @@ function createTasksView(deps, list) {
374
376
  async get(id) {
375
377
  validateTaskId(id);
376
378
  const { store } = await readStore(deps.fs, deps.path, validStates);
377
- return createTask(list, id, getTaskOrThrow(store, list, id));
379
+ return createTask(list, id, getTaskOrThrow(store, list, id), deps.path);
378
380
  },
379
381
  async create(input) {
380
382
  assertCreateDoesNotSetState(input);
@@ -388,7 +390,7 @@ function createTasksView(deps, list) {
388
390
  const taskRecord = createTaskRecord(deps.defaults, input, stateMachine.initial);
389
391
  document.setIn(["lists", list, input.id], taskRecord);
390
392
  await writeAtomically(deps.fs, deps.path, serializeDocument(document));
391
- return createTask(list, input.id, taskRecord);
393
+ return createTask(list, input.id, taskRecord, deps.path);
392
394
  });
393
395
  },
394
396
  async update(id, patch) {
@@ -410,7 +412,7 @@ function createTasksView(deps, list) {
410
412
  }
411
413
  }
412
414
  await writeAtomically(deps.fs, deps.path, serializeDocument(document));
413
- return createTask(list, id, nextTaskRecord);
415
+ return createTask(list, id, nextTaskRecord, deps.path);
414
416
  });
415
417
  },
416
418
  async fire(id, eventName, opts) {
@@ -418,7 +420,7 @@ function createTasksView(deps, list) {
418
420
  return withStoreLock(deps, async () => {
419
421
  const { document, store } = await readStore(deps.fs, deps.path, validStates);
420
422
  const existing = getTaskOrThrow(store, list, id);
421
- const task = createTask(list, id, existing);
423
+ const task = createTask(list, id, existing, deps.path);
422
424
  const event = assertFireableTaskEvent(task, eventName);
423
425
  const guardResult = event.guard?.(task) ?? true;
424
426
  if (guardResult !== true) {
@@ -438,7 +440,7 @@ function createTasksView(deps, list) {
438
440
  }
439
441
  }
440
442
  await writeAtomically(deps.fs, deps.path, serializeDocument(document));
441
- const nextTask = createTask(list, id, nextTaskRecord);
443
+ const nextTask = createTask(list, id, nextTaskRecord, deps.path);
442
444
  await event.onEnter?.(nextTask);
443
445
  return nextTask;
444
446
  });
@@ -446,7 +448,7 @@ function createTasksView(deps, list) {
446
448
  async canFire(id, eventName) {
447
449
  validateTaskId(id);
448
450
  const { store } = await readStore(deps.fs, deps.path, validStates);
449
- const task = createTask(list, id, getTaskOrThrow(store, list, id));
451
+ const task = createTask(list, id, getTaskOrThrow(store, list, id), deps.path);
450
452
  const event = findEvent(stateMachine, task.state, eventName);
451
453
  if (event === undefined) {
452
454
  return false;
@@ -456,7 +458,7 @@ function createTasksView(deps, list) {
456
458
  async events(id) {
457
459
  validateTaskId(id);
458
460
  const { store } = await readStore(deps.fs, deps.path, validStates);
459
- const task = createTask(list, id, getTaskOrThrow(store, list, id));
461
+ const task = createTask(list, id, getTaskOrThrow(store, list, id), deps.path);
460
462
  return eventsFromState(stateMachine, task.state);
461
463
  },
462
464
  async delete(id) {
@@ -497,7 +499,7 @@ function createTasksView(deps, list) {
497
499
  }
498
500
  listNode.items.splice(insertIndex, 0, movedPair);
499
501
  await writeAtomically(deps.fs, deps.path, serializeDocument(document));
500
- return createTask(list, id, taskRecord);
502
+ return createTask(list, id, taskRecord, deps.path);
501
503
  });
502
504
  },
503
505
  async reorder(ids) {
@@ -531,7 +533,7 @@ function createTasksView(deps, list) {
531
533
  });
532
534
  listNode.items.splice(0, listNode.items.length, ...orderedActive, ...archivedPairs);
533
535
  await writeAtomically(deps.fs, deps.path, serializeDocument(document));
534
- const tasks = ids.map((id) => createTask(list, id, getTaskOrThrow(store, list, id)));
536
+ const tasks = ids.map((id) => createTask(list, id, getTaskOrThrow(store, list, id), deps.path));
535
537
  return tasks;
536
538
  });
537
539
  }
@@ -559,7 +561,7 @@ export async function yamlFileBackend(deps) {
559
561
  continue;
560
562
  const entries = Object.entries(listRecord)
561
563
  .map(([id, taskRecord]) => ({
562
- task: createTask(listName, id, taskRecord),
564
+ task: createTask(listName, id, taskRecord, deps.path),
563
565
  raw: taskRecord
564
566
  }))
565
567
  .filter(({ task }) => matchesFilter(task, filter));
@@ -578,7 +580,7 @@ export async function yamlFileBackend(deps) {
578
580
  const { document, store } = await readStore(deps.fs, deps.path, validStates);
579
581
  const taskRecord = getTaskOrThrow(store, sourceListName, id);
580
582
  if (sourceListName === targetListName) {
581
- return createTask(targetListName, id, taskRecord);
583
+ return createTask(targetListName, id, taskRecord, deps.path);
582
584
  }
583
585
  if (getTaskRecord(store, targetListName, id)) {
584
586
  throw new TaskAlreadyExistsError(`Task "${targetListName}/${id}" already exists.`);
@@ -586,7 +588,7 @@ export async function yamlFileBackend(deps) {
586
588
  document.deleteIn(["lists", sourceListName, id]);
587
589
  document.setIn(["lists", targetListName, id], taskRecord);
588
590
  await writeAtomically(deps.fs, deps.path, serializeDocument(document));
589
- return createTask(targetListName, id, taskRecord);
591
+ return createTask(targetListName, id, taskRecord, deps.path);
590
592
  });
591
593
  };
592
594
  return {
@@ -8,6 +8,7 @@ export interface Task {
8
8
  state: string;
9
9
  description: string;
10
10
  metadata: Record<string, unknown>;
11
+ sourcePath?: string;
11
12
  }
12
13
  export interface TaskCreate {
13
14
  id?: string;
@@ -45,6 +46,7 @@ export interface Tasks {
45
46
  create(input: TaskCreate): Promise<Task>;
46
47
  update(id: string, patch: TaskUpdate): Promise<Task>;
47
48
  fire(id: string, event: string, opts?: TaskFireOptions): Promise<Task>;
49
+ comment?(id: string, body: string): Promise<void>;
48
50
  canFire(id: string, event: string): Promise<boolean>;
49
51
  events(id: string): Promise<readonly string[]>;
50
52
  delete(id: string): Promise<void>;
@@ -20,9 +20,11 @@ export class MigratingSecretStore {
20
20
  return legacyValue;
21
21
  }
22
22
  async set(value) {
23
- return this.store.set(value);
23
+ await this.store.set(value);
24
+ await this.legacyStore?.set(value);
24
25
  }
25
26
  async delete() {
26
- return this.store.delete();
27
+ await this.store.delete();
28
+ await this.legacyStore?.delete();
27
29
  }
28
30
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolcraft",
3
- "version": "0.0.20",
3
+ "version": "0.0.21",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -50,7 +50,7 @@
50
50
  "jsonc-parser": "^3.3.1",
51
51
  "smol-toml": "^1.3.0",
52
52
  "tiny-stdio-mcp-server": "^0.1.0",
53
- "toolcraft-schema": "^0.0.20",
53
+ "toolcraft-schema": "^0.0.21",
54
54
  "yaml": "^2.8.2"
55
55
  },
56
56
  "files": [