trekoon 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,6 +52,7 @@ npm i -g trekoon
52
52
  - `trekoon subtask <create|list|update|delete>`
53
53
  - `trekoon dep <add|remove|list>`
54
54
  - `trekoon sync <status|pull|resolve>`
55
+ - `trekoon skills install [--link --editor opencode|claude] [--to <path>]`
55
56
  - `trekoon wipe --yes`
56
57
 
57
58
  Global output modes:
@@ -157,7 +158,57 @@ trekoon sync pull --from main
157
158
  trekoon sync resolve <conflict-id> --use ours
158
159
  ```
159
160
 
160
- ### 6) Pre-merge checklist
161
+ ### 6) Install project-local Trekoon skill for agents
162
+
163
+ `trekoon skills install` always writes the bundled skill file into the current
164
+ repository at:
165
+
166
+ - `.agents/skills/trekoon/SKILL.md`
167
+
168
+ You can also create a project-local editor link:
169
+
170
+ ```bash
171
+ trekoon skills install
172
+ trekoon skills install --link --editor opencode
173
+ trekoon skills install --link --editor claude
174
+ trekoon skills install --link --editor opencode --to ./.custom-editor/skills
175
+ ```
176
+
177
+ Path behavior:
178
+
179
+ - Default opencode link path: `.opencode/skills/trekoon`
180
+ - Default claude link path: `.claude/skills/trekoon`
181
+ - `--to <path>` overrides the editor root for link creation only.
182
+ - `--to` does **not** move or copy `SKILL.md` to that path.
183
+ - Re-running install is idempotent: it refreshes `SKILL.md` and reuses/replaces
184
+ the same symlink target.
185
+ - If the link destination exists as a non-link path, install fails with an
186
+ actionable conflict error.
187
+
188
+ How `--to` works (step-by-step):
189
+
190
+ 1. Trekoon always installs/copies to:
191
+ - `<repo>/.agents/skills/trekoon/SKILL.md`
192
+ 2. If `--link` is present, Trekoon creates a `trekoon` symlink directory entry.
193
+ 3. `--to <path>` sets the symlink root directory.
194
+ 4. Final link path is:
195
+ - `<resolved-to-path>/trekoon -> <repo>/.agents/skills/trekoon`
196
+
197
+ Example:
198
+
199
+ ```bash
200
+ trekoon skills install --link --editor opencode --to ./.custom-editor/skills
201
+ ```
202
+
203
+ This produces:
204
+
205
+ - `<repo>/.agents/skills/trekoon/SKILL.md` (copied file)
206
+ - `<repo>/.custom-editor/skills/trekoon` (symlink)
207
+ - symlink target: `<repo>/.agents/skills/trekoon`
208
+
209
+ Trekoon does not mutate global editor config directories.
210
+
211
+ ### 7) Pre-merge checklist
161
212
 
162
213
  - [ ] `trekoon sync status` shows no unresolved conflicts
163
214
  - [ ] done tasks/subtasks are marked completed
package/package.json CHANGED
@@ -1,12 +1,19 @@
1
1
  {
2
2
  "name": "trekoon",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "AI-first local issue tracker CLI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "trekoon": "./bin/trekoon"
9
9
  },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ ".agents/skills/trekoon/SKILL.md",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
10
17
  "scripts": {
11
18
  "run": "bun run ./src/index.ts",
12
19
  "build": "bun build ./src/index.ts --outdir ./dist --target bun",
@@ -22,6 +22,7 @@ const ROOT_HELP = [
22
22
  " subtask Subtask lifecycle commands",
23
23
  " dep Dependency graph commands",
24
24
  " sync Cross-branch sync commands",
25
+ " skills Project-local skill install/link commands",
25
26
  ].join("\n");
26
27
 
27
28
  const COMMAND_HELP: Record<string, string> = {
@@ -35,6 +36,8 @@ const COMMAND_HELP: Record<string, string> = {
35
36
  subtask: "Usage: trekoon subtask <subcommand> [options] (list supports --view table|compact)",
36
37
  dep: "Usage: trekoon dep <subcommand> [options]",
37
38
  sync: "Usage: trekoon sync <subcommand> [options]",
39
+ skills:
40
+ "Usage: trekoon skills install [--link --editor opencode|claude] [--to <path>] (--to sets symlink root for --link only; install path always <cwd>/.agents/skills/trekoon/SKILL.md)",
38
41
  help: "Usage: trekoon help [command] [--json|--toon]",
39
42
  };
40
43
 
@@ -0,0 +1,265 @@
1
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, readlinkSync, rmSync, symlinkSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { hasFlag, parseArgs, readMissingOptionValue, readOption } from "./arg-parser";
6
+
7
+ import { failResult, okResult } from "../io/output";
8
+ import { type CliContext, type CliResult } from "../runtime/command-types";
9
+
10
+ const SKILLS_USAGE = "Usage: trekoon skills install [--link --editor opencode|claude] [--to <path>]";
11
+ const EDITOR_NAMES = ["opencode", "claude"] as const;
12
+
13
+ type EditorName = (typeof EDITOR_NAMES)[number];
14
+
15
+ interface InstallOutcome {
16
+ readonly sourcePath: string;
17
+ readonly installedPath: string;
18
+ readonly installedDir: string;
19
+ readonly linkPath: string | null;
20
+ readonly linkTarget: string | null;
21
+ }
22
+
23
+ function invalidArgs(message: string): CliResult {
24
+ return failResult({
25
+ command: "skills",
26
+ human: `${message}\n${SKILLS_USAGE}`,
27
+ data: { message },
28
+ error: {
29
+ code: "invalid_args",
30
+ message,
31
+ },
32
+ });
33
+ }
34
+
35
+ function invalidInput(command: string, message: string, data: Record<string, unknown>): CliResult {
36
+ return failResult({
37
+ command,
38
+ human: message,
39
+ data: {
40
+ code: "invalid_input",
41
+ ...data,
42
+ },
43
+ error: {
44
+ code: "invalid_input",
45
+ message,
46
+ },
47
+ });
48
+ }
49
+
50
+ function resolveBundledSkillFilePath(): string {
51
+ return fileURLToPath(new URL("../../.agents/skills/trekoon/SKILL.md", import.meta.url));
52
+ }
53
+
54
+ function toAbsolutePath(cwd: string, pathValue: string): string {
55
+ if (isAbsolute(pathValue)) {
56
+ return pathValue;
57
+ }
58
+
59
+ return resolve(cwd, pathValue);
60
+ }
61
+
62
+ function resolveLinkRoot(cwd: string, editor: EditorName, toOverride: string | undefined): string {
63
+ if (toOverride !== undefined) {
64
+ return toAbsolutePath(cwd, toOverride);
65
+ }
66
+
67
+ if (editor === "opencode") {
68
+ return join(cwd, ".opencode", "skills");
69
+ }
70
+
71
+ return join(cwd, ".claude", "skills");
72
+ }
73
+
74
+ function replaceOrCreateSymlink(linkPath: string, targetPath: string): CliResult | null {
75
+ if (!existsSync(linkPath)) {
76
+ mkdirSync(dirname(linkPath), { recursive: true });
77
+ symlinkSync(targetPath, linkPath, "dir");
78
+ return null;
79
+ }
80
+
81
+ const existing = lstatSync(linkPath);
82
+ if (!existing.isSymbolicLink()) {
83
+ return failResult({
84
+ command: "skills.install",
85
+ human: `Cannot create symlink: path exists and is not a link (${linkPath}).`,
86
+ data: {
87
+ code: "path_conflict",
88
+ linkPath,
89
+ targetPath,
90
+ },
91
+ error: {
92
+ code: "path_conflict",
93
+ message: "Symlink destination exists as a non-link path",
94
+ },
95
+ });
96
+ }
97
+
98
+ const existingRawTarget: string = readlinkSync(linkPath);
99
+ const existingAbsoluteTarget: string = toAbsolutePath(dirname(linkPath), existingRawTarget);
100
+ const expectedTarget: string = resolve(targetPath);
101
+ if (existingAbsoluteTarget !== expectedTarget) {
102
+ return failResult({
103
+ command: "skills.install",
104
+ human: `Cannot replace existing link at ${linkPath}; it points to ${existingAbsoluteTarget}.`,
105
+ data: {
106
+ code: "path_conflict",
107
+ linkPath,
108
+ existingTarget: existingAbsoluteTarget,
109
+ expectedTarget,
110
+ },
111
+ error: {
112
+ code: "path_conflict",
113
+ message: "Symlink destination points to a different target",
114
+ },
115
+ });
116
+ }
117
+
118
+ rmSync(linkPath, { force: true });
119
+ symlinkSync(targetPath, linkPath, "dir");
120
+ return null;
121
+ }
122
+
123
+ function runSkillsInstall(context: CliContext): CliResult {
124
+ const parsed = parseArgs(context.args);
125
+ const missingValue = readMissingOptionValue(parsed.missingOptionValues, "editor", "to");
126
+ if (missingValue !== undefined) {
127
+ return invalidInput("skills.install", `Option --${missingValue} requires a value.`, {
128
+ option: missingValue,
129
+ });
130
+ }
131
+
132
+ if (parsed.positional.length > 1) {
133
+ return invalidArgs("Unexpected positional arguments for skills install.");
134
+ }
135
+
136
+ const wantsLink: boolean = hasFlag(parsed.flags, "link");
137
+ const rawEditor: string | undefined = readOption(parsed.options, "editor");
138
+ const rawTo: string | undefined = readOption(parsed.options, "to");
139
+
140
+ if (!wantsLink && rawEditor !== undefined) {
141
+ return invalidInput("skills.install", "--editor requires --link.", {
142
+ editor: rawEditor,
143
+ });
144
+ }
145
+
146
+ if (!wantsLink && rawTo !== undefined) {
147
+ return invalidInput("skills.install", "--to requires --link.", {
148
+ to: rawTo,
149
+ });
150
+ }
151
+
152
+ if (wantsLink && rawEditor === undefined) {
153
+ return invalidArgs("skills install --link requires --editor opencode|claude.");
154
+ }
155
+
156
+ if (rawEditor !== undefined && !EDITOR_NAMES.includes(rawEditor as EditorName)) {
157
+ return invalidInput("skills.install", "Invalid --editor value. Use: opencode, claude", {
158
+ editor: rawEditor,
159
+ allowedEditors: EDITOR_NAMES,
160
+ });
161
+ }
162
+
163
+ const editor: EditorName | undefined = rawEditor as EditorName | undefined;
164
+
165
+ const sourcePath: string = resolveBundledSkillFilePath();
166
+ if (!existsSync(sourcePath)) {
167
+ return failResult({
168
+ command: "skills.install",
169
+ human: `Bundled skill asset not found at ${sourcePath}`,
170
+ data: {
171
+ code: "missing_asset",
172
+ sourcePath,
173
+ },
174
+ error: {
175
+ code: "missing_asset",
176
+ message: "Bundled skill asset not found",
177
+ },
178
+ });
179
+ }
180
+
181
+ const installPath = join(context.cwd, ".agents", "skills", "trekoon", "SKILL.md");
182
+ const installDir = dirname(installPath);
183
+
184
+ let outcome: InstallOutcome;
185
+
186
+ try {
187
+ mkdirSync(installDir, { recursive: true });
188
+ copyFileSync(sourcePath, installPath);
189
+
190
+ let linkPath: string | null = null;
191
+ let linkTarget: string | null = null;
192
+
193
+ if (wantsLink && editor !== undefined) {
194
+ const linkRoot: string = resolveLinkRoot(context.cwd, editor, rawTo);
195
+ linkPath = join(linkRoot, "trekoon");
196
+ linkTarget = installDir;
197
+ const linkFailure = replaceOrCreateSymlink(linkPath, linkTarget);
198
+ if (linkFailure) {
199
+ return linkFailure;
200
+ }
201
+ }
202
+
203
+ outcome = {
204
+ sourcePath,
205
+ installedPath: installPath,
206
+ installedDir: installDir,
207
+ linkPath,
208
+ linkTarget,
209
+ };
210
+ } catch (error: unknown) {
211
+ const message = error instanceof Error ? error.message : "Unknown skills install failure";
212
+ return failResult({
213
+ command: "skills.install",
214
+ human: `Failed to install skill: ${message}`,
215
+ data: {
216
+ code: "install_failed",
217
+ message,
218
+ },
219
+ error: {
220
+ code: "install_failed",
221
+ message,
222
+ },
223
+ });
224
+ }
225
+
226
+ return okResult({
227
+ command: "skills.install",
228
+ human: outcome.linkPath
229
+ ? [
230
+ "Installed Trekoon skill and linked editor path.",
231
+ `Source: ${outcome.sourcePath}`,
232
+ `Installed file: ${outcome.installedPath}`,
233
+ `Link path: ${outcome.linkPath}`,
234
+ `Link target: ${outcome.linkTarget}`,
235
+ ].join("\n")
236
+ : [
237
+ "Installed Trekoon skill.",
238
+ `Source: ${outcome.sourcePath}`,
239
+ `Installed file: ${outcome.installedPath}`,
240
+ ].join("\n"),
241
+ data: {
242
+ sourcePath: outcome.sourcePath,
243
+ installedPath: outcome.installedPath,
244
+ installedDir: outcome.installedDir,
245
+ linked: outcome.linkPath !== null,
246
+ linkPath: outcome.linkPath,
247
+ linkTarget: outcome.linkTarget,
248
+ },
249
+ });
250
+ }
251
+
252
+ export async function runSkills(context: CliContext): Promise<CliResult> {
253
+ const parsed = parseArgs(context.args);
254
+ const subcommand: string | undefined = parsed.positional[0];
255
+ if (!subcommand) {
256
+ return invalidArgs("Missing skills subcommand.");
257
+ }
258
+
259
+ switch (subcommand) {
260
+ case "install":
261
+ return runSkillsInstall(context);
262
+ default:
263
+ return invalidArgs(`Unknown skills subcommand '${subcommand}'.`);
264
+ }
265
+ }
@@ -3,6 +3,7 @@ import { runDep } from "../commands/dep";
3
3
  import { runEpic } from "../commands/epic";
4
4
  import { runInit } from "../commands/init";
5
5
  import { runQuickstart } from "../commands/quickstart";
6
+ import { runSkills } from "../commands/skills";
6
7
  import { runSubtask } from "../commands/subtask";
7
8
  import { runSync } from "../commands/sync";
8
9
  import { runTask } from "../commands/task";
@@ -13,6 +14,7 @@ import { type CliContext, type CliResult, type OutputMode } from "./command-type
13
14
  const CLI_VERSION = "0.1.0";
14
15
 
15
16
  const SUPPORTED_ROOT_COMMANDS: readonly string[] = [
17
+ "help",
16
18
  "init",
17
19
  "quickstart",
18
20
  "epic",
@@ -20,6 +22,7 @@ const SUPPORTED_ROOT_COMMANDS: readonly string[] = [
20
22
  "subtask",
21
23
  "dep",
22
24
  "sync",
25
+ "skills",
23
26
  "wipe",
24
27
  ];
25
28
 
@@ -128,6 +131,8 @@ export async function executeShell(parsed: ParsedInvocation, cwd: string = proce
128
131
  };
129
132
 
130
133
  switch (parsed.command) {
134
+ case "help":
135
+ return runHelp(context);
131
136
  case "init":
132
137
  return runInit(context);
133
138
  case "quickstart":
@@ -144,6 +149,8 @@ export async function executeShell(parsed: ParsedInvocation, cwd: string = proce
144
149
  return runDep(context);
145
150
  case "sync":
146
151
  return runSync(context);
152
+ case "skills":
153
+ return runSkills(context);
147
154
  default:
148
155
  return failResult({
149
156
  command: "shell",
package/AGENTS.md DELETED
@@ -1,54 +0,0 @@
1
- # AGENTS.md
2
-
3
- This file contains guidelines for agents.
4
-
5
- ## Mandatory: Atomic Commit Policy
6
-
7
- Every code change MUST be followed by an immediate commit.
8
-
9
- **Commit format**:
10
- ```
11
- <imperative verb> <what changed> ← Line 1: max 50 chars
12
- <blank line> ← Line 2: blank
13
- <why/context, one point per line> ← Body: max 72 chars per line
14
- ```
15
-
16
- **Rules**:
17
- 1. One commit per logical change
18
- 2. Small, atomic commits - one file per commit preferred
19
- 3. Never batch unrelated changes
20
-
21
- **Enforcement**:
22
- - After any file modification, stop and commit before modifying another file
23
- - Run `git status --short` after each commit to verify clean tree
24
- - At milestones: run `bun run build && bun run lint && bun run test`
25
-
26
- **Anti-patterns**:
27
- - ❌ Multiple unrelated files in one commit
28
- - ❌ Generic messages like "Update file", "WIP", "Fix stuff"
29
- - ❌ Commit message over 50 chars on first line
30
-
31
- ## Coding Conventions
32
-
33
- For Bun/TypeScript code:
34
- - **Imports**: Group order (stdlib → third-party → local), explicit named imports, remove unused
35
- - **Formatting**: Consistent quotes, avoid mixed tabs/spaces
36
- - **Types**: Prefer explicit types at API boundaries, avoid `any` unless justified
37
- - **Naming**: `camelCase` (vars/functions), `PascalCase` (types), `UPPER_SNAKE_CASE` (constants)
38
-
39
- **Error handling**:
40
- - Never silently swallow errors
41
- - Include actionable context (operation + endpoint + status code)
42
- - Redact secrets from errors and logs
43
-
44
- ## Agent Behavior
45
-
46
- - Prefer minimal, targeted edits; avoid broad rewrites
47
- - Preserve existing examples unless fixing factual issues
48
- - For CLI changes, prioritize startup speed and low-latency UX
49
- - Keep commands compatible with macOS and Linux shells
50
-
51
- ## Security
52
-
53
- - Never commit secrets (tokens, credentials)
54
- - Redact secrets from errors and logs
package/CONTRIBUTING.md DELETED
@@ -1,18 +0,0 @@
1
- # Contributing to Trekoon
2
-
3
- ## No-copy implementation policy
4
-
5
- Trekoon is implemented in this repository root. The `trekker/` directory is
6
- reference-only.
7
-
8
- - Do not copy code or files from `trekker/` into root `src/`.
9
- - Do not mirror file layout one-to-one from `trekker/`.
10
- - Write root implementation code directly, with Trekoon-native structure.
11
-
12
- ## PR checklist
13
-
14
- - [ ] Any new logic was written directly in root project files.
15
- - [ ] Changes were reviewed for suspiciously identical blocks/comments versus
16
- `trekker/` reference code.
17
- - [ ] Sync-related writes preserve git context (`branch`, `head`, `worktree`).
18
- - [ ] README command/flag examples match actual implemented CLI behavior.
package/bun.lock DELETED
@@ -1,29 +0,0 @@
1
- {
2
- "lockfileVersion": 1,
3
- "configVersion": 0,
4
- "workspaces": {
5
- "": {
6
- "name": "trekoon",
7
- "dependencies": {
8
- "@toon-format/toon": "^2.1.0",
9
- },
10
- "devDependencies": {
11
- "@types/bun": "^1.3.9",
12
- "typescript": "^5.9.3",
13
- },
14
- },
15
- },
16
- "packages": {
17
- "@toon-format/toon": ["@toon-format/toon@2.1.0", "", {}, "sha512-JwWptdF5eOA0HaQxbKAzkpQtR4wSWTEfDlEy/y3/4okmOAX1qwnpLZMmtEWr+ncAhTTY1raCKH0kteHhSXnQqg=="],
18
-
19
- "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
20
-
21
- "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
22
-
23
- "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
24
-
25
- "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
26
-
27
- "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
28
- }
29
- }
@@ -1,101 +0,0 @@
1
- import { mkdtempSync, rmSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
-
5
- import { afterEach, describe, expect, test } from "bun:test";
6
-
7
- import { runDep } from "../../src/commands/dep";
8
- import { runEpic } from "../../src/commands/epic";
9
- import { runSubtask } from "../../src/commands/subtask";
10
- import { runTask } from "../../src/commands/task";
11
-
12
- const tempDirs: string[] = [];
13
-
14
- function createWorkspace(): string {
15
- const workspace = mkdtempSync(join(tmpdir(), "trekoon-dep-"));
16
- tempDirs.push(workspace);
17
- return workspace;
18
- }
19
-
20
- afterEach((): void => {
21
- while (tempDirs.length > 0) {
22
- const next = tempDirs.pop();
23
- if (next) {
24
- rmSync(next, { recursive: true, force: true });
25
- }
26
- }
27
- });
28
-
29
- async function createTaskGraph(cwd: string): Promise<{ taskA: string; taskB: string; subtask: string }> {
30
- const epic = await runEpic({
31
- cwd,
32
- mode: "human",
33
- args: ["create", "--title", "Roadmap", "--description", "desc"],
34
- });
35
- const epicId = (epic.data as { epic: { id: string } }).epic.id;
36
-
37
- const taskA = await runTask({
38
- cwd,
39
- mode: "human",
40
- args: ["create", "--epic", epicId, "--title", "Task A", "--description", "desc a"],
41
- });
42
- const taskAId = (taskA.data as { task: { id: string } }).task.id;
43
-
44
- const taskB = await runTask({
45
- cwd,
46
- mode: "human",
47
- args: ["create", "--epic", epicId, "--title", "Task B", "--description", "desc b"],
48
- });
49
- const taskBId = (taskB.data as { task: { id: string } }).task.id;
50
-
51
- const subtask = await runSubtask({
52
- cwd,
53
- mode: "human",
54
- args: ["create", "--task", taskBId, "--title", "Subtask"],
55
- });
56
-
57
- return {
58
- taskA: taskAId,
59
- taskB: taskBId,
60
- subtask: (subtask.data as { subtask: { id: string } }).subtask.id,
61
- };
62
- }
63
-
64
- describe("dep command", (): void => {
65
- test("supports add/list/remove", async (): Promise<void> => {
66
- const cwd = createWorkspace();
67
- const nodes = await createTaskGraph(cwd);
68
-
69
- const added = await runDep({ cwd, mode: "human", args: ["add", nodes.taskA, nodes.subtask] });
70
- expect(added.ok).toBeTrue();
71
-
72
- const listed = await runDep({ cwd, mode: "human", args: ["list", nodes.taskA] });
73
- expect(listed.ok).toBeTrue();
74
- expect((listed.data as { dependencies: unknown[] }).dependencies.length).toBe(1);
75
-
76
- const removed = await runDep({ cwd, mode: "human", args: ["remove", nodes.taskA, nodes.subtask] });
77
- expect(removed.ok).toBeTrue();
78
- expect((removed.data as { removed: number }).removed).toBe(1);
79
- });
80
-
81
- test("enforces referential checks for task/subtask nodes", async (): Promise<void> => {
82
- const cwd = createWorkspace();
83
- const nodes = await createTaskGraph(cwd);
84
-
85
- const bad = await runDep({ cwd, mode: "human", args: ["add", nodes.taskA, "missing-node-id"] });
86
- expect(bad.ok).toBeFalse();
87
- expect(bad.error?.code).toBe("not_found");
88
- });
89
-
90
- test("detects dependency cycles", async (): Promise<void> => {
91
- const cwd = createWorkspace();
92
- const nodes = await createTaskGraph(cwd);
93
-
94
- const first = await runDep({ cwd, mode: "human", args: ["add", nodes.taskA, nodes.taskB] });
95
- expect(first.ok).toBeTrue();
96
-
97
- const cycle = await runDep({ cwd, mode: "human", args: ["add", nodes.taskB, nodes.taskA] });
98
- expect(cycle.ok).toBeFalse();
99
- expect(cycle.error?.code).toBe("invalid_dependency");
100
- });
101
- });