@difflab/difflab-cli 0.4.0 → 0.6.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 (3) hide show
  1. package/README.md +23 -2
  2. package/dist/index.js +260 -56
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -46,6 +46,17 @@ Clients are selected explicitly. Supported names: `pi`, `cursor`, `codex`, `clau
46
46
 
47
47
  MCP clients launch `npx -y @difflab/difflab-cli mcp serve` over stdio, so no daemon or port is needed. Bun must be on the client's PATH.
48
48
 
49
+ ## MCP todo tools
50
+
51
+ `mcp setup` does **not** create a todo file. Use `todo_init` to create a list. Each todo call requires an absolute `cwd` pointing to an existing directory and a relative `path` to a JSON todo file inside it. For example:
52
+
53
+ ```text
54
+ todo_init({"cwd":"/home/alex","path":"todos.json"})
55
+ todo_add({"cwd":"/home/alex","path":"todos.json","text":"Review PR"})
56
+ ```
57
+
58
+ `todo_list`, `todo_complete`, and `todo_remove` also require both fields; the latter two require a task `id` returned by `todo_list` or `todo_add`. Call `todo_init` before other operations on a new file. Parent traversal outside `cwd`, absolute `path` values, and symlinked paths are rejected. Keep personal todo files outside the repository or add their paths to `.gitignore`. Concurrent writes to the same file are not yet coordinated. Agents can load `skills/difflab-todo/SKILL.md` for usage instructions.
59
+
49
60
  ## Document templates
50
61
 
51
62
  List the available templates or scaffold a new document from one:
@@ -55,9 +66,19 @@ difflab-cli templates list
55
66
  difflab-cli templates scaffold spec-driven-plan docs/plans first-plan.md --cwd /absolute/project/path
56
67
  ```
57
68
 
58
- The CLI defaults `--cwd` to its current directory. The MCP server exposes `template_list({})` and `scaffold({"name":"spec-driven-plan","cwd":"/absolute/project/path","path":"docs/plans","filename":"first-plan.md"})`. Both interfaces take a hyphenated template name, a relative destination **directory** (`path`, or `.` for `cwd`), and a separate **filename**. MCP always requires an absolute existing `cwd`. Missing destination directories are created. Available names: `spec-driven-plan`, `software-architecture-design`, `architecture-decision-record`, `product-requirements-document`, `code-review`, `planning-intent`, `ui-component-architecture`, and `pull-request-description` (adapted from Diffpi's PR template).
69
+ The CLI defaults `--cwd` to its current directory. The MCP server exposes `template_list({})` and `scaffold({"name":"spec-driven-plan","cwd":"/absolute/project/path","path":"docs/plans","filename":"first-plan.md"})`. Both interfaces take a hyphenated template name, a relative destination **directory** (`path`, or `.` for `cwd`), and a separate **filename**. MCP always requires an absolute existing `cwd`. Missing destination directories are created. Available names: `spec-driven-plan`, `software-architecture-design`, `architecture-decision-record`, `product-requirements-document`, `code-review`, `planning-intent`, `ui-component-architecture`, `pull-request-description` (adapted from Diffpi's PR template), `flow-definition`, and `flow-instance`.
70
+
71
+ On first use, missing bundled templates and filled examples are copied to `~/.difflab/templates` and `~/.difflab/templates/examples`. Edit the installed templates to customize future documents. Later runs add missing files but never replace existing ones, even after an upgrade. Examples are for reference, not selectable templates. Copying is literal: prompts and placeholders are not substituted. An unknown name, missing or invalid source, unsafe path, symlink, or existing destination causes an error; destination files are never overwritten. The target directory must remain inside `cwd` and the filename must be a single file name without separators. The one symlink exception is an initialized repository's `.difflab` link to its own user project store: `templates scaffold spec-driven-plan .difflab/plans/YYMMDD-name PLAN.md --cwd /absolute/repo` works in Git worktrees as well. To restore a bundled default, move your installed copy aside before listing again.
72
+
73
+ The portable `skills/difflab-plan`, `skills/difflab-review`, and `skills/difflab-flow` directories contain natural-language agent workflows for these templates. Install each skill in your client's Agent Skills directory. The MCP-only `log_append({"path":"/absolute/physical/project-store/plans/YYMMDD-name/logs.txt","message":"Phase started"})` tool creates an append-only progress log on first use; pass the physical path, not the `.difflab` symlink. There is no log CLI command.
74
+
75
+ ## Flow definitions and runs
76
+
77
+ `flow new <name>` and `flow go <name>` are Agent Skill requests, not CLI subcommands. The agent creates a definition once at `~/.difflab/flows/<name>.md`. It calls MCP `scaffold` with `cwd=<absolute home>/.difflab`, `path="flows"`, and `filename="<name>.md"`. Do not use `cwd=<home>,path=".difflab/flows"`: that route is for a repository project store. You can edit a definition before another run. A new run uses a fresh snapshot and does not alter old runs.
78
+
79
+ From an initialized repository, ask “flow go autospec with input 'Export monthly sales' --local”. The agent selects one branch and creates `.difflab/flows/YYMMDD-autospec/FLOW.md`. It keeps inputs, selected permissions, step checkboxes, and verified outputs for later steps in that file. A same-day path requires an explicit resume choice or a distinct run ID. The first unchecked step is the resume point. The physical project-store path to the sibling `logs.txt` receives timestamped entries through MCP `log_append` only. The definition remains unchanged.
59
80
 
60
- On first use, missing bundled templates and filled examples are copied to `~/.difflab/templates` and `~/.difflab/templates/examples`. Edit the installed templates to customize future documents. Later runs add missing files but never replace existing ones, even after an upgrade. Examples are for reference, not selectable templates. Copying is literal: prompts and placeholders are not substituted. An unknown name, missing or invalid source, unsafe path, symlink, or existing destination causes an error; destination files are never overwritten. The target directory must remain inside `cwd` and the filename must be a single file name without separators. To restore a bundled default, move your installed copy aside before listing again.
81
+ `--local` removes remote steps before creating the run. `--commit` permits only an explicitly declared local commit step when passed, and never permits a push. Remote review needs separate permission and the review skill's forge checks. Plan and review skills still control their own operations; a flow cannot override them. Merge is not supported as a flow step. Example definitions live in `skills/difflab-flow/examples/`.
61
82
 
62
83
  ## Develop
63
84
 
package/dist/index.js CHANGED
@@ -12796,17 +12796,17 @@ var init_registry = __esm(() => {
12796
12796
  // ../../libs/difflab-ts/src/gitx.ts
12797
12797
  import { execFileSync } from "child_process";
12798
12798
  import { appendFile, readFile } from "fs/promises";
12799
- import { isAbsolute, join, resolve as resolve2 } from "path";
12799
+ import { isAbsolute, resolve as resolve2 } from "path";
12800
12800
  function gitRoot(cwd) {
12801
12801
  return runGit(cwd, ["rev-parse", "--show-toplevel"]);
12802
12802
  }
12803
12803
  function discoverGitRepository(cwd) {
12804
12804
  const root = gitRoot(cwd);
12805
- const gitDir = runGit(root, ["rev-parse", "--git-dir"]);
12805
+ const exclude = runGit(root, ["rev-parse", "--git-path", "info/exclude"]);
12806
12806
  return {
12807
12807
  root,
12808
12808
  originUrl: runGit(root, ["remote", "get-url", "origin"]),
12809
- excludePath: join(isAbsolute(gitDir) ? gitDir : resolve2(root, gitDir), "info", "exclude")
12809
+ excludePath: isAbsolute(exclude) ? exclude : resolve2(root, exclude)
12810
12810
  };
12811
12811
  }
12812
12812
  async function addPathToGitExcludes(path, entry) {
@@ -26485,9 +26485,9 @@ var init_dist3 = __esm(() => {
26485
26485
 
26486
26486
  // src/projects/config.ts
26487
26487
  import { lstat as lstat2, readFile as readFile2, writeFile } from "fs/promises";
26488
- import { join as join2 } from "path";
26488
+ import { join } from "path";
26489
26489
  function repoConfigPath(root = process.cwd()) {
26490
- return join2(root, "difflab.yaml");
26490
+ return join(root, "difflab.yaml");
26491
26491
  }
26492
26492
  async function readRepoConfig(root = process.cwd()) {
26493
26493
  const path = repoConfigPath(root);
@@ -26548,7 +26548,7 @@ var init_config = __esm(() => {
26548
26548
  import { realpathSync } from "fs";
26549
26549
  import { lstat as lstat3, mkdir } from "fs/promises";
26550
26550
  import { homedir } from "os";
26551
- import { join as join3, resolve as resolve3 } from "path";
26551
+ import { join as join2, resolve as resolve3 } from "path";
26552
26552
  function userPaths(home = process.env.HOME || homedir()) {
26553
26553
  let tilde;
26554
26554
  try {
@@ -26558,8 +26558,8 @@ function userPaths(home = process.env.HOME || homedir()) {
26558
26558
  throw error;
26559
26559
  tilde = resolve3(home);
26560
26560
  }
26561
- const root = join3(tilde, ".difflab");
26562
- return { root, projects: join3(root, "projects"), database: join3(root, "difflab.sqlite") };
26561
+ const root = join2(tilde, ".difflab");
26562
+ return { root, projects: join2(root, "projects"), database: join2(root, "difflab.sqlite") };
26563
26563
  }
26564
26564
  function createUserStore(home) {
26565
26565
  return {
@@ -26645,7 +26645,7 @@ var init_osx = () => {};
26645
26645
 
26646
26646
  // src/extensions/pathx.ts
26647
26647
  import { lstat as lstat4, realpath, stat } from "fs/promises";
26648
- import { isAbsolute as isAbsolute2, join as join4, relative, resolve as resolve4, sep } from "path";
26648
+ import { isAbsolute as isAbsolute2, join as join3, relative, resolve as resolve4, sep } from "path";
26649
26649
  async function resolvePath(cwd, path) {
26650
26650
  if (!isAbsolute2(cwd))
26651
26651
  throw new Error("cwd must be an absolute path to an existing directory");
@@ -26661,7 +26661,7 @@ async function resolvePath(cwd, path) {
26661
26661
  }
26662
26662
  let current = root;
26663
26663
  for (const segment of inside.split(sep)) {
26664
- current = join4(current, segment);
26664
+ current = join3(current, segment);
26665
26665
  try {
26666
26666
  if ((await lstat4(current)).isSymbolicLink()) {
26667
26667
  throw new Error(`Symlink paths are not allowed for files: ${current}`);
@@ -26732,7 +26732,7 @@ var init_fsx = __esm(() => {
26732
26732
 
26733
26733
  // src/projects/repo-store.ts
26734
26734
  import { mkdir as mkdir3, readlink, symlink } from "fs/promises";
26735
- import { dirname as dirname2, join as join5, resolve as resolve5 } from "path";
26735
+ import { dirname as dirname2, join as join4, resolve as resolve5 } from "path";
26736
26736
  async function preflightRepoStore(root, target) {
26737
26737
  for (const path of [root, dirname2(target)]) {
26738
26738
  const entry = await lstatOrNull(path);
@@ -26742,7 +26742,7 @@ async function preflightRepoStore(root, target) {
26742
26742
  const targetStat = await lstatOrNull(target);
26743
26743
  if (targetStat && !targetStat.isDirectory())
26744
26744
  throw new RepoStoreError(`Refusing existing target: ${target}`);
26745
- const link = join5(root, ".difflab");
26745
+ const link = join4(root, ".difflab");
26746
26746
  const entry = await lstatOrNull(link);
26747
26747
  if (entry) {
26748
26748
  if (!entry.isSymbolicLink())
@@ -26752,7 +26752,7 @@ async function preflightRepoStore(root, target) {
26752
26752
  }
26753
26753
  }
26754
26754
  async function checkRepoStore(root, target, excludePath) {
26755
- const link = join5(root, ".difflab");
26755
+ const link = join4(root, ".difflab");
26756
26756
  const entry = await lstatOrNull(link);
26757
26757
  if (!entry)
26758
26758
  throw new RepoStoreError(`Missing .difflab symlink at ${link}`);
@@ -26776,7 +26776,7 @@ async function ensureRepoStore(root, target, excludePath) {
26776
26776
  throw error;
26777
26777
  }
26778
26778
  }
26779
- const link = join5(root, ".difflab");
26779
+ const link = join4(root, ".difflab");
26780
26780
  if (!await lstatOrNull(link)) {
26781
26781
  try {
26782
26782
  await symlink(target, link);
@@ -26794,7 +26794,7 @@ var init_repo_store = __esm(() => {
26794
26794
  });
26795
26795
 
26796
26796
  // src/context.ts
26797
- import { join as join6 } from "path";
26797
+ import { join as join5 } from "path";
26798
26798
  async function readProjectContext(cwd = process.cwd(), home) {
26799
26799
  const git = discoverGitRepository(cwd);
26800
26800
  const config = await readRepoConfig(git.root);
@@ -26815,7 +26815,7 @@ async function getProjectContext(git, projectId, home) {
26815
26815
  const repository = project.repositories.find((item) => item.url === origin);
26816
26816
  if (!repository)
26817
26817
  throw new RepoStoreError("Repository is not registered in the selected project database");
26818
- const target = join6(userPaths(home).projects, project.id, repository.slug);
26818
+ const target = join5(userPaths(home).projects, project.id, repository.slug);
26819
26819
  await checkRepoStore(git.root, target);
26820
26820
  return {
26821
26821
  project: { id: project.id, name: project.name },
@@ -48181,14 +48181,100 @@ var init_tools = __esm(() => {
48181
48181
  ];
48182
48182
  });
48183
48183
 
48184
- // src/projects/tools.ts
48184
+ // src/extensions/zodx.ts
48185
48185
  import { isAbsolute as isAbsolute4 } from "path";
48186
+ var absolutePath, relativePath, fileName;
48187
+ var init_zodx = __esm(() => {
48188
+ init_zod();
48189
+ absolutePath = string2().refine(isAbsolute4, "path must be absolute");
48190
+ relativePath = string2().min(1).refine((value) => value.trim().length > 0 && !isAbsolute4(value), "path must be relative");
48191
+ fileName = string2().refine((value) => value.trim().length > 0 && value !== "." && value !== ".." && !/[/\\\0]/.test(value), "filename must be a single non-empty file name");
48192
+ });
48193
+
48194
+ // src/logs/store.ts
48195
+ import { constants } from "fs";
48196
+ import { lstat as lstat6, open as open2, realpath as realpath2 } from "fs/promises";
48197
+ import { basename, dirname as dirname5, isAbsolute as isAbsolute5, resolve as resolve8 } from "path";
48198
+ async function appendProgressLog(path, message, now = new Date) {
48199
+ if (!isAbsolute5(path) || resolve8(path) !== path || basename(path) !== "logs.txt") {
48200
+ throw new ProgressLogError("path must be an absolute, normalized path ending in logs.txt");
48201
+ }
48202
+ if (!message.trim() || /[\r\n\v\f\0\u0085\u2028\u2029]/.test(message) || Buffer.byteLength(message, "utf8") > 2048) {
48203
+ throw new ProgressLogError("message must be one non-empty line of at most 2048 bytes");
48204
+ }
48205
+ const parent = dirname5(path);
48206
+ if (await realpath2(parent) !== parent) {
48207
+ throw new ProgressLogError("path must not contain symlinked directories");
48208
+ }
48209
+ const pad = (value) => String(value).padStart(2, "0");
48210
+ const stamp = `${pad(now.getFullYear() % 100)}${pad(now.getMonth() + 1)}${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
48211
+ const entry = `[${stamp}]: ${message}`;
48212
+ try {
48213
+ const existing = await lstat6(path);
48214
+ if (existing.isSymbolicLink())
48215
+ throw new ProgressLogError("logs.txt must not be a symlink");
48216
+ if (!existing.isFile())
48217
+ throw new ProgressLogError("logs.txt must be a regular file");
48218
+ } catch (error) {
48219
+ if (!hasErrorCode(error, "ENOENT"))
48220
+ throw error;
48221
+ }
48222
+ let handle;
48223
+ try {
48224
+ handle = await open2(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 384);
48225
+ } catch (error) {
48226
+ if (hasErrorCode(error, "ELOOP"))
48227
+ throw new ProgressLogError("logs.txt must not be a symlink");
48228
+ throw error;
48229
+ }
48230
+ try {
48231
+ if (!(await handle.stat()).isFile())
48232
+ throw new ProgressLogError("logs.txt must be a regular file");
48233
+ const line = Buffer.from(`${entry}
48234
+ `, "utf8");
48235
+ const { bytesWritten } = await handle.write(line);
48236
+ if (bytesWritten !== line.length)
48237
+ throw new ProgressLogError("Could not append the full log line");
48238
+ } finally {
48239
+ await handle.close();
48240
+ }
48241
+ return { path, entry };
48242
+ }
48243
+ var ProgressLogError;
48244
+ var init_store2 = __esm(() => {
48245
+ init_osx();
48246
+ ProgressLogError = class ProgressLogError extends Error {
48247
+ };
48248
+ });
48249
+
48250
+ // src/logs/tools.ts
48251
+ var logOutput, logTools;
48252
+ var init_tools2 = __esm(() => {
48253
+ init_zod();
48254
+ init_zodx();
48255
+ init_store2();
48256
+ logOutput = object({ result: object({ path: string2(), entry: string2() }) });
48257
+ logTools = [
48258
+ defineTool("log_append", {
48259
+ description: "Append one locally timestamped progress line to logs.txt at an absolute physical path. The parent directory must already exist; create the file if absent, never overwrite prior entries.",
48260
+ inputSchema: strictObject({
48261
+ path: absolutePath.describe("Absolute physical path to an existing plan directory/logs.txt"),
48262
+ message: string2().min(1).describe("Single-line progress message")
48263
+ }),
48264
+ outputSchema: logOutput,
48265
+ annotations: { openWorldHint: false }
48266
+ }, async ({ path, message }) => appendProgressLog(path, message), { toStructuredContent: (result) => ({ result }) })
48267
+ ];
48268
+ });
48269
+
48270
+ // src/projects/tools.ts
48271
+ import { isAbsolute as isAbsolute6 } from "path";
48186
48272
  function createProjectTools(home) {
48187
48273
  return [
48188
48274
  defineTool("project_context", {
48189
48275
  description: "Read the configured Difflab project and GitHub repository for an existing absolute cwd. Never sets up or modifies a repository.",
48190
48276
  inputSchema: strictObject({
48191
- cwd: string2().refine(isAbsolute4, "cwd must be an absolute existing directory")
48277
+ cwd: string2().refine(isAbsolute6, "cwd must be an absolute existing directory")
48192
48278
  }),
48193
48279
  outputSchema: projectContextOutput,
48194
48280
  annotations: { readOnlyHint: true, openWorldHint: false }
@@ -48196,7 +48282,7 @@ function createProjectTools(home) {
48196
48282
  ];
48197
48283
  }
48198
48284
  var setupHint = "Use the difflab-init skill to set up this repository with the Difflab CLI.", projectContextOutput, projectTools;
48199
- var init_tools2 = __esm(() => {
48285
+ var init_tools3 = __esm(() => {
48200
48286
  init_zod();
48201
48287
  init_context();
48202
48288
  projectContextOutput = strictObject({
@@ -48213,16 +48299,6 @@ var init_tools2 = __esm(() => {
48213
48299
  projectTools = createProjectTools();
48214
48300
  });
48215
48301
 
48216
- // src/extensions/zodx.ts
48217
- import { isAbsolute as isAbsolute5 } from "path";
48218
- var absolutePath, relativePath, fileName;
48219
- var init_zodx = __esm(() => {
48220
- init_zod();
48221
- absolutePath = string2().refine(isAbsolute5, "path must be absolute");
48222
- relativePath = string2().min(1).refine((value) => value.trim().length > 0 && !isAbsolute5(value), "path must be relative");
48223
- fileName = string2().refine((value) => value.trim().length > 0 && value !== "." && value !== ".." && !/[/\\\0]/.test(value), "filename must be a single non-empty file name");
48224
- });
48225
-
48226
48302
  // templates/spec-driven-plan.md
48227
48303
  var spec_driven_plan_default = `# [Outcome to deliver]
48228
48304
 
@@ -48511,6 +48587,21 @@ var code_review_default = `# Review: [change or pull request]
48511
48587
 
48512
48588
  [Evaluate authentication and autorization, permissions, secrets, dependencies, input validation, proper error handling, and trust boundaries. State SKIPPED if skipped with reason, for example for chores, simple scripting, pure cosmetic UI work, documentation, etc.]
48513
48589
 
48590
+ ## Change Requests
48591
+
48592
+ Track actionable inline requests with one entry per request. End each generated request and response with its own model attribution quote; preserve human text unchanged.
48593
+
48594
+ ### [file:line \u2014 request]
48595
+
48596
+ - **Status:** [open/in progress/resolved]
48597
+ - **Request:** [verbatim request or concise generated finding]
48598
+
48599
+ > Generated via [actual model name, only for a generated request]
48600
+
48601
+ - **Response:** [resolution, evidence, or reason this remains open]
48602
+
48603
+ > Generated via [actual model name, only for a generated response]
48604
+
48514
48605
  ## Findings
48515
48606
 
48516
48607
  | Severity | Location | Evidence and impact | Required change or question |
@@ -48621,6 +48712,97 @@ var pull_request_description_default = `## Intent
48621
48712
  `;
48622
48713
  var init_pull_request_description = () => {};
48623
48714
 
48715
+ // templates/flow-definition.md
48716
+ var flow_definition_default = `# {{name}}
48717
+
48718
+ ## Intent
48719
+
48720
+ {{intent}}
48721
+
48722
+ ## Inputs
48723
+
48724
+ | Name | Required | Default | Description |
48725
+ | ----- | -------- | ------- | ------------------------------------------ |
48726
+ | input | yes | \u2014 | Free-form user request; bind as one value. |
48727
+
48728
+ ## Flags
48729
+
48730
+ | Flag | Default | Capability | Effect |
48731
+ | --------------- | ------- | ------------- | ---------------------------------------------------------------------------------- |
48732
+ | --local | false | local | Omit steps with \`unless: --local\`; never forward to every skill. |
48733
+ | --commit | false | local-commit | Permit only explicitly commit-capable selected steps to make scoped local commits. |
48734
+ | --remote-review | false | remote-review | Opt in to remote review with independent forge authorization. |
48735
+
48736
+ ## Steps
48737
+
48738
+ ### 1. {{step-id}}
48739
+
48740
+ - Handler: \`plan new\`
48741
+ - When: always
48742
+ - Unless: never
48743
+ - Requires: local
48744
+ - Receives: \`{{input}}\`
48745
+ - Produces: \`plan_path\` (verified path to the created plan)
48746
+ - Instructions: {{step-instructions}}
48747
+
48748
+ ### 2. {{next-step-id}}
48749
+
48750
+ - Handler: \`review new\`
48751
+ - When: \`--remote-review\`
48752
+ - Unless: \`--local\`
48753
+ - Requires: remote-review
48754
+ - Receives: \`{{steps.step-id.plan_path}}\`
48755
+ - Produces: \`review_path\` (verified review artifact)
48756
+ - Instructions: {{step-instructions}}
48757
+
48758
+ ## Contract
48759
+
48760
+ Replace example rows and steps when creating a definition. Use stable unique lower-case hyphenated input and step IDs; output names may also contain underscores (\`^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$\`). Declare every input, flag, capability, output and condition. \`When\` and \`Unless\` accept only \`always\`, \`never\`, or one declared flag; no expressions, shell interpolation or implicit flags. The only tokens are \`{{input-name}}\` and \`{{steps.step-id.output-name}}\`. A later step can consume only an earlier declared output, verified when that step finishes. Define any remote or commit capability explicitly and require its independent opt-in flag. This file is reusable and has no run state; editing it affects future runs only.
48761
+ `;
48762
+ var init_flow_definition = () => {};
48763
+
48764
+ // templates/flow-instance.md
48765
+ var flow_instance_default = `# Flow run: {{name}}
48766
+
48767
+ - **Status:** in progress
48768
+ - **Definition:** {{absolute-definition-path}}
48769
+ - **Definition SHA-256:** {{definition-sha256}}
48770
+ - **Repository:** {{absolute-repository-root}}
48771
+ - **Run ID:** {{run-id}}
48772
+
48773
+ ## Resolved inputs
48774
+
48775
+ | Input | Value |
48776
+ | ----- | --------- |
48777
+ | input | {{input}} |
48778
+
48779
+ ## Selected flags and permissions
48780
+
48781
+ {{flags-and-capabilities}}
48782
+
48783
+ ## Selection provenance
48784
+
48785
+ {{omitted-step-ids-and-reasons-only-no-instructions}}
48786
+
48787
+ ## Steps
48788
+
48789
+ - [ ] 1. {{selected-step-id}} \u2014 {{resolved-handler}}
48790
+ - Requires: {{authorized-capability}}
48791
+ - Instructions: {{resolved-instructions}}
48792
+ - Receives: {{concrete-input-or-prior-step-handoff}}
48793
+ - Expected output: {{declared-output-name}}
48794
+ - Result/handoff: pending
48795
+
48796
+ ## Blocker
48797
+
48798
+ None.
48799
+
48800
+ ## Contract
48801
+
48802
+ This is the frozen execution snapshot. Include only selected executable steps; never copy unselected instructions here. Resolve all declared inputs before creating this file. References to earlier step outputs are typed handoff slots, filled only from verified results when those steps complete. Check a step only after success; on failure leave it unchecked, record the blocker and stop. Resume this exact instance at its first unchecked step without rereading the global definition.
48803
+ `;
48804
+ var init_flow_instance = () => {};
48805
+
48624
48806
  // templates/examples/plan-feature.md
48625
48807
  var plan_feature_default = `# Invite teammates to a project
48626
48808
 
@@ -49136,6 +49318,8 @@ var init_source = __esm(() => {
49136
49318
  init_planning_intent();
49137
49319
  init_ui_component_architecture();
49138
49320
  init_pull_request_description();
49321
+ init_flow_definition();
49322
+ init_flow_instance();
49139
49323
  init_plan_feature();
49140
49324
  init_plan_migration();
49141
49325
  init_design_service();
@@ -49154,7 +49338,9 @@ var init_source = __esm(() => {
49154
49338
  { name: "code-review", description: "Thorough code review" },
49155
49339
  { name: "planning-intent", description: "Planning intent for a user to fill in" },
49156
49340
  { name: "ui-component-architecture", description: "UI component design and implementation" },
49157
- { name: "pull-request-description", description: "Draft pull request description" }
49341
+ { name: "pull-request-description", description: "Draft pull request description" },
49342
+ { name: "flow-definition", description: "Reusable global Agent Skill flow definition" },
49343
+ { name: "flow-instance", description: "Frozen repository-local flow run" }
49158
49344
  ];
49159
49345
  contents = {
49160
49346
  "spec-driven-plan.md": spec_driven_plan_default,
@@ -49165,6 +49351,8 @@ var init_source = __esm(() => {
49165
49351
  "planning-intent.md": planning_intent_default,
49166
49352
  "ui-component-architecture.md": ui_component_architecture_default,
49167
49353
  "pull-request-description.md": pull_request_description_default,
49354
+ "flow-definition.md": flow_definition_default,
49355
+ "flow-instance.md": flow_instance_default,
49168
49356
  "examples/plan-feature.md": plan_feature_default,
49169
49357
  "examples/plan-migration.md": plan_migration_default,
49170
49358
  "examples/design-service.md": design_service_default,
@@ -49193,9 +49381,9 @@ var exports_service = {};
49193
49381
  __export(exports_service, {
49194
49382
  TemplateService: () => TemplateService
49195
49383
  });
49196
- import { constants } from "fs";
49197
- import { lstat as lstat6, open as open2, rm as rm3 } from "fs/promises";
49198
- import { isAbsolute as isAbsolute6, join as join7 } from "path";
49384
+ import { constants as constants2 } from "fs";
49385
+ import { lstat as lstat7, open as open3, rm as rm3 } from "fs/promises";
49386
+ import { isAbsolute as isAbsolute7, join as join6 } from "path";
49199
49387
 
49200
49388
  class TemplateService {
49201
49389
  home;
@@ -49212,16 +49400,16 @@ class TemplateService {
49212
49400
  if (!this.source.catalog.some((item) => item.name === name)) {
49213
49401
  throw new UnknownTemplateError(`Unknown template: ${name}`);
49214
49402
  }
49215
- if (!path.trim() || isAbsolute6(path)) {
49403
+ if (!path.trim() || isAbsolute7(path)) {
49216
49404
  throw new TemplateError("path must be a non-empty relative directory inside cwd");
49217
49405
  }
49218
49406
  if (!filename.trim() || filename === "." || filename === ".." || /[/\\\0]/.test(filename)) {
49219
49407
  throw new TemplateError("filename must be a single non-empty file name");
49220
49408
  }
49221
49409
  await this.installMissing();
49222
- const installed = await resolvePath(this.home, join7(".difflab", "templates", `${name}.md`));
49410
+ const installed = await resolvePath(this.home, join6(".difflab", "templates", `${name}.md`));
49223
49411
  const contents = await readMarkdown(installed);
49224
- const destination = await ensurePathDirsExist(join7(path, filename), cwd);
49412
+ const destination = path.startsWith(".difflab/") ? await this.projectDestination(cwd, path.slice(".difflab/".length), filename) : await ensurePathDirsExist(join6(path, filename), cwd);
49225
49413
  try {
49226
49414
  await createExclusive(destination, contents);
49227
49415
  } catch (error) {
@@ -49234,12 +49422,23 @@ class TemplateService {
49234
49422
  }
49235
49423
  return destination;
49236
49424
  }
49425
+ async projectDestination(cwd, path, filename) {
49426
+ if (!isAbsolute7(cwd))
49427
+ throw new TemplateError("cwd must be an absolute path");
49428
+ const git = discoverGitRepository(cwd);
49429
+ const config = await readRepoConfig(git.root);
49430
+ if (!config)
49431
+ throw new TemplateError(`Initialize this repository with difflab init: ${git.root}`);
49432
+ const target = join6(userPaths(this.home).projects, config.project.id, repositorySlug(canonicalGitUrl(git.originUrl)));
49433
+ await checkRepoStore(git.root, target);
49434
+ return ensurePathDirsExist(join6(path, filename), target);
49435
+ }
49237
49436
  async installMissing() {
49238
49437
  for (const asset of this.source.assets) {
49239
- const relative = join7(".difflab", "templates", asset);
49438
+ const relative = join6(".difflab", "templates", asset);
49240
49439
  const destination = await ensurePathDirsExist(relative, this.home);
49241
49440
  try {
49242
- if ((await lstat6(destination)).isSymbolicLink()) {
49441
+ if ((await lstat7(destination)).isSymbolicLink()) {
49243
49442
  throw new TemplateSourceError(`Installed template asset is a symlink: ${destination}`);
49244
49443
  }
49245
49444
  await readMarkdown(destination);
@@ -49273,7 +49472,7 @@ async function readSource(source, asset) {
49273
49472
  async function readMarkdown(path) {
49274
49473
  let handle;
49275
49474
  try {
49276
- handle = await open2(path, constants.O_RDONLY | constants.O_NOFOLLOW);
49475
+ handle = await open3(path, constants2.O_RDONLY | constants2.O_NOFOLLOW);
49277
49476
  if (!(await handle.stat()).isFile())
49278
49477
  throw new TemplateSourceError(`Template is not a file: ${path}`);
49279
49478
  const content = await handle.readFile("utf8");
@@ -49289,7 +49488,7 @@ async function readMarkdown(path) {
49289
49488
  }
49290
49489
  }
49291
49490
  async function createExclusive(path, content) {
49292
- const handle = await open2(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 384);
49491
+ const handle = await open3(path, constants2.O_WRONLY | constants2.O_CREAT | constants2.O_EXCL | constants2.O_NOFOLLOW, 384);
49293
49492
  try {
49294
49493
  await handle.writeFile(content, "utf8");
49295
49494
  } catch (error) {
@@ -49305,6 +49504,10 @@ var init_service = __esm(() => {
49305
49504
  init_fsx();
49306
49505
  init_osx();
49307
49506
  init_pathx();
49507
+ init_gitx2();
49508
+ init_config();
49509
+ init_repo_store();
49510
+ init_user_store();
49308
49511
  init_source();
49309
49512
  TemplateError = class TemplateError extends Error {
49310
49513
  };
@@ -49318,7 +49521,7 @@ var init_service = __esm(() => {
49318
49521
 
49319
49522
  // src/templates/tools.ts
49320
49523
  var templateInfo, templateListOutput, scaffoldOutput, templateTools;
49321
- var init_tools3 = __esm(() => {
49524
+ var init_tools4 = __esm(() => {
49322
49525
  init_zod();
49323
49526
  init_zodx();
49324
49527
  init_service();
@@ -49332,7 +49535,7 @@ var init_tools3 = __esm(() => {
49332
49535
  });
49333
49536
  templateTools = [
49334
49537
  defineTool("template_list", {
49335
- description: "List eight document templates; install any missing defaults and examples in the user templates directory without overwriting local edits.",
49538
+ description: "List document templates, including flow definitions and instances; install any missing defaults and examples in the user templates directory without overwriting local edits.",
49336
49539
  inputSchema: strictObject({}),
49337
49540
  outputSchema: templateListOutput,
49338
49541
  annotations: { openWorldHint: false }
@@ -49357,7 +49560,7 @@ var init_tools3 = __esm(() => {
49357
49560
  // src/mcp/server.ts
49358
49561
  async function serveMcp(version = "0.1.0") {
49359
49562
  const server = new McpServer({ name: "difflab", version });
49360
- for (const tool of [...todoTools, ...projectTools, ...templateTools]) {
49563
+ for (const tool of [...todoTools, ...projectTools, ...templateTools, ...logTools]) {
49361
49564
  server.registerTool(tool.name, tool.config, tool.callback);
49362
49565
  }
49363
49566
  await server.connect(new StdioServerTransport);
@@ -49368,6 +49571,7 @@ var init_server3 = __esm(() => {
49368
49571
  init_tools();
49369
49572
  init_tools2();
49370
49573
  init_tools3();
49574
+ init_tools4();
49371
49575
  });
49372
49576
 
49373
49577
  // src/extensions/processx.ts
@@ -49442,7 +49646,7 @@ var init_json_config = __esm(() => {
49442
49646
  });
49443
49647
 
49444
49648
  // src/agents/claude-code.ts
49445
- import { join as join8 } from "path";
49649
+ import { join as join7 } from "path";
49446
49650
  var claudeCodeAdapter;
49447
49651
  var init_claude_code = __esm(() => {
49448
49652
  init_adapter();
@@ -49452,7 +49656,7 @@ var init_claude_code = __esm(() => {
49452
49656
  id: "claude-code",
49453
49657
  async setupMcpConfig(context = {}) {
49454
49658
  const home = setupHome(context);
49455
- const file = join8(home, ".claude.json");
49659
+ const file = join7(home, ".claude.json");
49456
49660
  const status = serverStatus(await readJsonConfig(file), file);
49457
49661
  if (status === "matching")
49458
49662
  return { file, status: "existing" };
@@ -49468,7 +49672,7 @@ var init_claude_code = __esm(() => {
49468
49672
 
49469
49673
  // src/agents/codex.ts
49470
49674
  import { readFile as readFile5 } from "fs/promises";
49471
- import { join as join9 } from "path";
49675
+ import { join as join8 } from "path";
49472
49676
  function codexServerStatus(content) {
49473
49677
  const lines = content.split(/\r?\n/);
49474
49678
  const start = lines.findIndex((line) => /^\s*\[mcp_servers\.(?:difflab|"difflab"|'difflab')\]\s*(?:#.*)?$/.test(line));
@@ -49492,7 +49696,7 @@ var init_codex = __esm(() => {
49492
49696
  id: "codex",
49493
49697
  async setupMcpConfig(context = {}) {
49494
49698
  const home = setupHome(context);
49495
- const file = join9(home, ".codex", "config.toml");
49699
+ const file = join8(home, ".codex", "config.toml");
49496
49700
  let content = "";
49497
49701
  try {
49498
49702
  content = await readFile5(file, "utf8");
@@ -49514,7 +49718,7 @@ var init_codex = __esm(() => {
49514
49718
  });
49515
49719
 
49516
49720
  // src/agents/index.ts
49517
- import { join as join10 } from "path";
49721
+ import { join as join9 } from "path";
49518
49722
  function agentAdapter(name) {
49519
49723
  return Object.hasOwn(agentAdapters, name) ? agentAdapters[name] : undefined;
49520
49724
  }
@@ -49525,20 +49729,20 @@ var init_agents = __esm(() => {
49525
49729
  init_codex();
49526
49730
  init_json_config();
49527
49731
  agentAdapters = {
49528
- pi: jsonAgentAdapter("pi", (context) => join10(setupHome(context), ".config", "mcp", "mcp.json")),
49529
- cursor: jsonAgentAdapter("cursor", (context) => join10(setupHome(context), ".cursor", "mcp.json")),
49732
+ pi: jsonAgentAdapter("pi", (context) => join9(setupHome(context), ".config", "mcp", "mcp.json")),
49733
+ cursor: jsonAgentAdapter("cursor", (context) => join9(setupHome(context), ".cursor", "mcp.json")),
49530
49734
  codex: codexAdapter,
49531
49735
  "claude-code": claudeCodeAdapter,
49532
49736
  "claude-desktop": jsonAgentAdapter("claude-desktop", (context) => {
49533
49737
  const home = setupHome(context);
49534
49738
  const platform = context.platform ?? process.platform;
49535
49739
  if (platform === "darwin") {
49536
- return join10(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
49740
+ return join9(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
49537
49741
  }
49538
49742
  if (platform === "win32") {
49539
- return join10(process.env.APPDATA ?? join10(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
49743
+ return join9(process.env.APPDATA ?? join9(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
49540
49744
  }
49541
- return join10(home, ".config", "Claude", "claude_desktop_config.json");
49745
+ return join9(home, ".config", "Claude", "claude_desktop_config.json");
49542
49746
  })
49543
49747
  };
49544
49748
  agentNames = Object.keys(agentAdapters);
@@ -51626,7 +51830,7 @@ init_config();
51626
51830
  init_context();
51627
51831
  init_repo_store();
51628
51832
  init_user_store();
51629
- import { join as join11 } from "path";
51833
+ import { join as join10 } from "path";
51630
51834
  function createProgram(write = console.log, version = "0.1.0", options = {}) {
51631
51835
  const program = new Command2().name("difflab").description("Difflab CLI and local MCP server").version(version);
51632
51836
  program.action(() => program.outputHelp());
@@ -51660,7 +51864,7 @@ function createProgram(write = console.log, version = "0.1.0", options = {}) {
51660
51864
  if (config && config.project.id !== key)
51661
51865
  throw new RepoStoreError(`Repository already belongs to project ${config.project.id}; requested ${key}`);
51662
51866
  const origin = canonicalGitUrl(git.originUrl);
51663
- const target = join11(userPaths(options.home).projects, key, repositorySlug(origin));
51867
+ const target = join10(userPaths(options.home).projects, key, repositorySlug(origin));
51664
51868
  await preflightRepoStore(git.root, target);
51665
51869
  const project = await withUserStore(async (db) => {
51666
51870
  const project = await db.projects.getProjectById(key);
@@ -51709,7 +51913,7 @@ function createProgram(write = console.log, version = "0.1.0", options = {}) {
51709
51913
 
51710
51914
  // src/index.ts
51711
51915
  init_errors3();
51712
- var version2 = "0.4.0";
51916
+ var version2 = "0.6.0";
51713
51917
  try {
51714
51918
  await createProgram(console.log, version2).parseAsync(process.argv);
51715
51919
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@difflab/difflab-cli",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "description": "A minimal Bun CLI starter built with Commander",
6
6
  "license": "Apache-2.0",