@oisincoveney/pipeline 2.9.0 → 2.11.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.
package/dist/hooks.d.ts CHANGED
@@ -13,8 +13,8 @@ declare const hookResultSchema: z.ZodObject<{
13
13
  taskContext: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
14
14
  }, z.core.$strict>>;
15
15
  status: z.ZodEnum<{
16
- fail: "fail";
17
16
  pass: "pass";
17
+ fail: "fail";
18
18
  skip: "skip";
19
19
  }>;
20
20
  summary: z.ZodOptional<z.ZodString>;
@@ -355,8 +355,9 @@ function projectAgentsMdDefinition(cwd, host) {
355
355
  "",
356
356
  `Use Qdrant collection \`${repoName}\` for this repository.`,
357
357
  "",
358
- `- Call \`qdrant-find\` before research with \`collection_name: ${repoName}\` unless the user explicitly disables memory.`,
359
- `- Call \`qdrant-store\` during LEARN with \`collection_name: ${repoName}\` for durable lessons worth reusing.`,
358
+ "- Use the Qdrant interface exposed by the active host; do not assume `qdrant-find` or `qdrant-store` are shell commands.",
359
+ `- Before research, call MCP tool \`qdrant_qdrant_find\` with \`collection_name: ${repoName}\` when MCP tools are available; otherwise use the host's \`qdrant-find\` command/alias if one exists.`,
360
+ `- During LEARN, call MCP tool \`qdrant_qdrant_store\` with \`collection_name: ${repoName}\` for durable lessons worth reusing; otherwise use the host's \`qdrant-store\` command/alias if one exists.`,
360
361
  "- Include metadata with at least `repo`, `phase`, `workflow` or `entrypoint`, `task`, and `outcome` when storing lessons.",
361
362
  "",
362
363
  AGENTS_MD_END,
@@ -5,13 +5,13 @@ import { z } from "zod";
5
5
  //#region src/moka-submit.d.ts
6
6
  declare const mokaSubmitDirectHooksSchema: z.ZodRecord<z.ZodEnum<{
7
7
  "workflow.start": "workflow.start";
8
- "node.finish": "node.finish";
9
- "node.start": "node.start";
10
8
  "workflow.success": "workflow.success";
11
9
  "workflow.failure": "workflow.failure";
12
10
  "workflow.complete": "workflow.complete";
11
+ "node.start": "node.start";
13
12
  "node.success": "node.success";
14
13
  "node.error": "node.error";
14
+ "node.finish": "node.finish";
15
15
  "gate.failure": "gate.failure";
16
16
  }> & z.core.$partial, z.ZodDiscriminatedUnion<[z.ZodObject<{
17
17
  failure: z.ZodDefault<z.ZodEnum<{
@@ -94,13 +94,13 @@ declare const mokaSubmitOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
94
94
  }, z.core.$strict>>;
95
95
  hooks: z.ZodOptional<z.ZodRecord<z.ZodEnum<{
96
96
  "workflow.start": "workflow.start";
97
- "node.finish": "node.finish";
98
- "node.start": "node.start";
99
97
  "workflow.success": "workflow.success";
100
98
  "workflow.failure": "workflow.failure";
101
99
  "workflow.complete": "workflow.complete";
100
+ "node.start": "node.start";
102
101
  "node.success": "node.success";
103
102
  "node.error": "node.error";
103
+ "node.finish": "node.finish";
104
104
  "gate.failure": "gate.failure";
105
105
  }> & z.core.$partial, z.ZodDiscriminatedUnion<[z.ZodObject<{
106
106
  failure: z.ZodDefault<z.ZodEnum<{
@@ -206,13 +206,13 @@ declare const mokaSubmitOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
206
206
  }, z.core.$strict>>;
207
207
  hooks: z.ZodOptional<z.ZodRecord<z.ZodEnum<{
208
208
  "workflow.start": "workflow.start";
209
- "node.finish": "node.finish";
210
- "node.start": "node.start";
211
209
  "workflow.success": "workflow.success";
212
210
  "workflow.failure": "workflow.failure";
213
211
  "workflow.complete": "workflow.complete";
212
+ "node.start": "node.start";
214
213
  "node.success": "node.success";
215
214
  "node.error": "node.error";
215
+ "node.finish": "node.finish";
216
216
  "gate.failure": "gate.failure";
217
217
  }> & z.core.$partial, z.ZodDiscriminatedUnion<[z.ZodObject<{
218
218
  failure: z.ZodDefault<z.ZodEnum<{
@@ -1,5 +1,7 @@
1
1
  import { installCommands } from "./install-commands.js";
2
+ import { existsSync } from "node:fs";
2
3
  import { execa } from "execa";
4
+ import { join } from "node:path";
3
5
  //#region src/pipeline-init.ts
4
6
  const DEFAULT_SKILL_INSTALL_SOURCE = "oisin-ee/skills";
5
7
  const SKILL_INSTALL_AGENT_ARGS = [
@@ -16,6 +18,18 @@ const SKILL_INSTALL_AGENT_ARGS = [
16
18
  function skillInstallArgs(scope) {
17
19
  return scope === "personal" ? [...SKILL_INSTALL_AGENT_ARGS, "--global"] : [...SKILL_INSTALL_AGENT_ARGS, "--copy"];
18
20
  }
21
+ const DEFAULT_HARNESS_COMMIT_MESSAGE = "chore: update agent harnesses";
22
+ const OWNED_HARNESS_PATHS = [
23
+ ".agents/skills",
24
+ ".claude/agents",
25
+ ".claude/commands",
26
+ ".claude/settings.json",
27
+ ".claude/skills",
28
+ ".codex/skills",
29
+ ".opencode",
30
+ "AGENTS.md",
31
+ "skills-lock.json"
32
+ ];
19
33
  async function installDefaultSkills(cwd, scope) {
20
34
  try {
21
35
  await execa("npx", [
@@ -46,6 +60,31 @@ async function initPipelineProject(options = {}) {
46
60
  scope
47
61
  };
48
62
  }
63
+ async function refreshAgentHarnesses(options = {}) {
64
+ const context = refreshAgentHarnessesContext(options);
65
+ const init = await initPipelineProject({
66
+ cwd: context.cwd,
67
+ scope: options.scope,
68
+ skillInstaller: options.skillInstaller
69
+ });
70
+ const committed = await refreshAgentHarnessesCommitResult(context);
71
+ return {
72
+ ...init,
73
+ commitMessage: context.commitMessage,
74
+ committed
75
+ };
76
+ }
77
+ function refreshAgentHarnessesContext(options) {
78
+ return {
79
+ commandRunner: options.commandRunner ?? runCommand,
80
+ commitMessage: options.commitMessage ?? DEFAULT_HARNESS_COMMIT_MESSAGE,
81
+ cwd: options.cwd ?? process.cwd()
82
+ };
83
+ }
84
+ async function refreshAgentHarnessesCommitResult(context) {
85
+ await assertGitWorktree(context.cwd, context.commandRunner);
86
+ return stageableOwnedPathsExist(context.cwd) ? await commitOwnedHarnessRefresh(context.cwd, context.commandRunner, context.commitMessage) : false;
87
+ }
49
88
  function formatPipelineInitResult(result) {
50
89
  return [
51
90
  "Initialized package-owned pipeline support:",
@@ -54,5 +93,74 @@ function formatPipelineInitResult(result) {
54
93
  "no repo-local pipeline config files were created"
55
94
  ].join("\n");
56
95
  }
96
+ function formatRefreshAgentHarnessesResult(result) {
97
+ return [formatPipelineInitResult(result), result.committed ? `committed refreshed harnesses: ${result.commitMessage}` : "refreshed harnesses already current; no commit created"].join("\n");
98
+ }
99
+ async function runCommand(command, args, options) {
100
+ const result = await execa(command, args, {
101
+ cwd: options.cwd,
102
+ reject: options.reject ?? true
103
+ });
104
+ return {
105
+ exitCode: result.exitCode ?? 0,
106
+ stderr: result.stderr,
107
+ stdout: result.stdout
108
+ };
109
+ }
110
+ async function assertGitWorktree(cwd, commandRunner) {
111
+ await commandRunner("git", ["rev-parse", "--is-inside-work-tree"], { cwd });
112
+ }
113
+ async function commitOwnedHarnessRefresh(cwd, commandRunner, commitMessage) {
114
+ await stageOwnedHarnessPaths(cwd, commandRunner);
115
+ await assertOnlyOwnedHarnessFilesStaged(cwd, commandRunner);
116
+ if (!await hasStagedChanges(cwd, commandRunner)) return false;
117
+ await commandRunner("git", [
118
+ "commit",
119
+ "--no-verify",
120
+ "-m",
121
+ commitMessage
122
+ ], { cwd });
123
+ return true;
124
+ }
125
+ async function hasStagedChanges(cwd, commandRunner) {
126
+ return (await commandRunner("git", [
127
+ "diff",
128
+ "--cached",
129
+ "--quiet"
130
+ ], {
131
+ cwd,
132
+ reject: false
133
+ })).exitCode !== 0;
134
+ }
135
+ async function assertOnlyOwnedHarnessFilesStaged(cwd, commandRunner) {
136
+ const unrelatedFiles = (await commandRunner("git", [
137
+ "diff",
138
+ "--cached",
139
+ "--name-only"
140
+ ], { cwd })).stdout.split("\n").map((path) => path.trim()).filter(Boolean).filter((path) => !isOwnedHarnessPath(path));
141
+ if (unrelatedFiles.length === 0) return;
142
+ throw new Error([
143
+ "Refusing to commit because unrelated files are already staged.",
144
+ ...unrelatedFiles.map((path) => `- ${path}`),
145
+ "Unstage unrelated files before running `moka refresh-harnesses`."
146
+ ].join("\n"));
147
+ }
148
+ function isOwnedHarnessPath(path) {
149
+ return OWNED_HARNESS_PATHS.some((ownedPath) => path === ownedPath || path.startsWith(`${ownedPath}/`));
150
+ }
151
+ function existingOwnedPaths(cwd) {
152
+ return OWNED_HARNESS_PATHS.filter((path) => existsSync(join(cwd, path)));
153
+ }
154
+ function stageableOwnedPathsExist(cwd) {
155
+ return existingOwnedPaths(cwd).length > 0;
156
+ }
157
+ async function stageOwnedHarnessPaths(cwd, commandRunner) {
158
+ await commandRunner("git", [
159
+ "add",
160
+ "-A",
161
+ "--",
162
+ ...existingOwnedPaths(cwd)
163
+ ], { cwd });
164
+ }
57
165
  //#endregion
58
- export { formatPipelineInitResult, initPipelineProject };
166
+ export { formatPipelineInitResult, formatRefreshAgentHarnessesResult, initPipelineProject, refreshAgentHarnesses };
@@ -10,8 +10,8 @@ declare const runnerEventRecordSchema: z.ZodUnion<readonly [z.ZodObject<{
10
10
  at: z.ZodOptional<z.ZodString>;
11
11
  sequence: z.ZodNumber;
12
12
  type: z.ZodEnum<{
13
- "workflow.planned": "workflow.planned";
14
13
  "workflow.start": "workflow.start";
14
+ "workflow.planned": "workflow.planned";
15
15
  }>;
16
16
  workflowPlan: z.ZodObject<{
17
17
  edges: z.ZodOptional<z.ZodArray<z.ZodObject<{
@@ -55,10 +55,10 @@ declare const runnerEventRecordSchema: z.ZodUnion<readonly [z.ZodObject<{
55
55
  }>;
56
56
  }, z.core.$strip>;
57
57
  type: z.ZodEnum<{
58
+ "node.start": "node.start";
59
+ "node.finish": "node.finish";
58
60
  "agent.finish": "agent.finish";
59
61
  "agent.start": "agent.start";
60
- "node.finish": "node.finish";
61
- "node.start": "node.start";
62
62
  }>;
63
63
  }, z.core.$strip>, z.ZodObject<{
64
64
  at: z.ZodOptional<z.ZodString>;
@@ -103,8 +103,8 @@ declare const runnerEventRecordSchema: z.ZodUnion<readonly [z.ZodObject<{
103
103
  nodeId: z.ZodOptional<z.ZodString>;
104
104
  outputs: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
105
105
  status: z.ZodEnum<{
106
- fail: "fail";
107
106
  pass: "pass";
107
+ fail: "fail";
108
108
  skip: "skip";
109
109
  }>;
110
110
  summary: z.ZodOptional<z.ZodString>;
@@ -180,8 +180,8 @@ declare const runnerEventBatchSchema: z.ZodObject<{
180
180
  at: z.ZodOptional<z.ZodString>;
181
181
  sequence: z.ZodNumber;
182
182
  type: z.ZodEnum<{
183
- "workflow.planned": "workflow.planned";
184
183
  "workflow.start": "workflow.start";
184
+ "workflow.planned": "workflow.planned";
185
185
  }>;
186
186
  workflowPlan: z.ZodObject<{
187
187
  edges: z.ZodOptional<z.ZodArray<z.ZodObject<{
@@ -225,10 +225,10 @@ declare const runnerEventBatchSchema: z.ZodObject<{
225
225
  }>;
226
226
  }, z.core.$strip>;
227
227
  type: z.ZodEnum<{
228
+ "node.start": "node.start";
229
+ "node.finish": "node.finish";
228
230
  "agent.finish": "agent.finish";
229
231
  "agent.start": "agent.start";
230
- "node.finish": "node.finish";
231
- "node.start": "node.start";
232
232
  }>;
233
233
  }, z.core.$strip>, z.ZodObject<{
234
234
  at: z.ZodOptional<z.ZodString>;
@@ -273,8 +273,8 @@ declare const runnerEventBatchSchema: z.ZodObject<{
273
273
  nodeId: z.ZodOptional<z.ZodString>;
274
274
  outputs: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
275
275
  status: z.ZodEnum<{
276
- fail: "fail";
277
276
  pass: "pass";
277
+ fail: "fail";
278
278
  skip: "skip";
279
279
  }>;
280
280
  summary: z.ZodOptional<z.ZodString>;
@@ -0,0 +1,16 @@
1
+ import { Context, Effect, Layer } from "effect";
2
+
3
+ //#region src/runtime/services/backlog-service.d.ts
4
+ declare const BacklogCommandError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }>) => import("effect/Cause").YieldableError & {
5
+ readonly _tag: "BacklogCommandError";
6
+ } & Readonly<A>;
7
+ declare class BacklogCommandError extends BacklogCommandError_base<{
8
+ readonly message: string;
9
+ readonly stdout: string;
10
+ }> {}
11
+ declare const BacklogService_base: Context.TagClass<BacklogService, "BacklogService", {
12
+ readonly run: (args: readonly string[], cwd: string) => Effect.Effect<string, BacklogCommandError>;
13
+ }>;
14
+ declare class BacklogService extends BacklogService_base {}
15
+ //#endregion
16
+ export { BacklogService };
@@ -0,0 +1,25 @@
1
+ import { Context, Data, Effect, Layer } from "effect";
2
+ import { execa } from "execa";
3
+ //#region src/runtime/services/backlog-service.ts
4
+ var BacklogCommandError = class extends Data.TaggedError("BacklogCommandError") {};
5
+ Data.TaggedError("BacklogParseError");
6
+ function commandErrorMessage(error) {
7
+ return error instanceof Error ? error.message : String(error);
8
+ }
9
+ function commandErrorStdout(error) {
10
+ const stdout = error.stdout;
11
+ return typeof stdout === "string" ? stdout : "";
12
+ }
13
+ function toBacklogCommandError(error) {
14
+ return new BacklogCommandError({
15
+ message: commandErrorMessage(error),
16
+ stdout: commandErrorStdout(error)
17
+ });
18
+ }
19
+ var BacklogService = class extends Context.Tag("BacklogService")() {};
20
+ const BacklogServiceLive = Layer.succeed(BacklogService, { run: (args, cwd) => Effect.tryPromise({
21
+ catch: toBacklogCommandError,
22
+ try: () => execa("backlog", [...args], { cwd })
23
+ }).pipe(Effect.map((result) => result.stdout)) });
24
+ //#endregion
25
+ export { BacklogService, BacklogServiceLive };
@@ -1,3 +1,4 @@
1
+ import { ticketPlanSchema } from "./tickets/ticket-plan.js";
1
2
  import { z } from "zod";
2
3
  //#region src/standard-output-schemas.ts
3
4
  const VERDICT_SCHEMA = z.enum(["PASS", "FAIL"]);
@@ -57,6 +58,7 @@ const STANDARD_OUTPUT_SCHEMAS = {
57
58
  summary: z.string().optional(),
58
59
  verdict: VERDICT_SCHEMA
59
60
  }).strict(),
61
+ "ticket-plan": ticketPlanSchema,
60
62
  verify: z.object({
61
63
  evidence: STRING_ARRAY_SCHEMA,
62
64
  verdict: VERDICT_SCHEMA,
@@ -0,0 +1,75 @@
1
+ import { BacklogService } from "../runtime/services/backlog-service.js";
2
+ import { parseBacklogTaskId } from "../backlog.js";
3
+ import { formatBacklogCommand, ticketCreateArgs } from "./ticket-plan-command-args.js";
4
+ import { Data, Effect } from "effect";
5
+ //#region src/tickets/apply-ticket-plan.ts
6
+ var ApplyTicketPlanError = class extends Data.TaggedError("ApplyTicketPlanError") {};
7
+ function applyTicketPlanEffect(plan, worktreePath, options) {
8
+ return Effect.gen(function* () {
9
+ const backlog = yield* BacklogService;
10
+ const createdIds = [];
11
+ const parentId = yield* resolveParentId(plan, options, worktreePath, backlog, createdIds);
12
+ return {
13
+ createdIds,
14
+ parentId,
15
+ taskIdsByKey: yield* createChildTickets(plan, parentId, worktreePath, backlog, createdIds)
16
+ };
17
+ });
18
+ }
19
+ function resolveParentId(plan, options, worktreePath, backlog, createdIds) {
20
+ if (options.parentId) return Effect.succeed(options.parentId);
21
+ if (!plan.epic) return Effect.fail(new ApplyTicketPlanError({
22
+ createdIds,
23
+ message: "Cannot apply ticket plan without --parent because the plan does not include an epic."
24
+ }));
25
+ return runCreateAndParseId(ticketCreateArgs(plan.epic), worktreePath, backlog, createdIds);
26
+ }
27
+ function createChildTickets(plan, parentId, worktreePath, backlog, createdIds) {
28
+ return Effect.gen(function* () {
29
+ const remaining = new Map(plan.tickets.map((ticket) => [ticket.key, ticket]));
30
+ const taskIdsByKey = {};
31
+ while (remaining.size > 0) {
32
+ const ready = [...remaining.values()].filter((ticket) => ticket.depends_on.every((key) => taskIdsByKey[key]));
33
+ if (ready.length === 0) return yield* Effect.fail(new ApplyTicketPlanError({
34
+ createdIds,
35
+ message: "Cannot apply ticket plan because local dependency keys contain a cycle or unresolved prerequisite."
36
+ }));
37
+ for (const ticket of ready) {
38
+ const taskId = yield* runCreateAndParseId(ticketCreateArgs(ticket, {
39
+ dependencyIds: ticket.depends_on.map((key) => taskIdsByKey[key]),
40
+ parentId
41
+ }), worktreePath, backlog, createdIds);
42
+ taskIdsByKey[ticket.key] = taskId;
43
+ remaining.delete(ticket.key);
44
+ }
45
+ }
46
+ return taskIdsByKey;
47
+ });
48
+ }
49
+ function runCreateAndParseId(args, worktreePath, backlog, createdIds) {
50
+ return Effect.gen(function* () {
51
+ const stdout = yield* backlog.run(args, worktreePath).pipe(Effect.mapError((error) => commandFailure(args, createdIds, error)));
52
+ const taskId = parseBacklogTaskId(stdout);
53
+ if (!taskId) return yield* Effect.fail(new ApplyTicketPlanError({
54
+ command: formatBacklogCommand(args),
55
+ createdIds,
56
+ message: `could not parse created task id from Backlog output; created ids: ${formatCreatedIds(createdIds)}; failed command: ${formatBacklogCommand(args)}`,
57
+ stdout
58
+ }));
59
+ createdIds.push(taskId);
60
+ return taskId;
61
+ });
62
+ }
63
+ function commandFailure(args, createdIds, error) {
64
+ return new ApplyTicketPlanError({
65
+ command: formatBacklogCommand(args),
66
+ createdIds,
67
+ message: `backlog command failed after created ids: ${formatCreatedIds(createdIds)}; failed command: ${formatBacklogCommand(args)}; ${error.message}`,
68
+ stdout: error.stdout
69
+ });
70
+ }
71
+ function formatCreatedIds(createdIds) {
72
+ return createdIds.length > 0 ? createdIds.join(", ") : "none";
73
+ }
74
+ //#endregion
75
+ export { applyTicketPlanEffect };
@@ -0,0 +1,130 @@
1
+ import { errorMessage, formatZodIssues } from "./validation-error-format.js";
2
+ import { RepoIoService } from "../runtime/services/repo-io-service.js";
3
+ import { indexChildrenByParentId } from "./ticket-task-index.js";
4
+ import { z } from "zod";
5
+ import { Data, Effect } from "effect";
6
+ import { join } from "node:path";
7
+ import matter from "gray-matter";
8
+ //#region src/tickets/backlog-task-store.ts
9
+ const LINE_RE = /\r?\n/;
10
+ const ACCEPTANCE_ITEM_RE = /^\s*-\s*\[[ xX]\]\s*(?:#?[\w.-]+\s+)?(.+)$/;
11
+ const DESCRIPTION_MARKERS = {
12
+ end: "<!-- SECTION:DESCRIPTION:END -->",
13
+ start: "<!-- SECTION:DESCRIPTION:BEGIN -->"
14
+ };
15
+ const ACCEPTANCE_MARKERS = {
16
+ end: "<!-- AC:END -->",
17
+ start: "<!-- AC:BEGIN -->"
18
+ };
19
+ const nonEmptyStringSchema = z.string().trim().min(1);
20
+ const taskFrontmatterSchema = z.object({
21
+ dependencies: z.array(nonEmptyStringSchema).default([]),
22
+ id: nonEmptyStringSchema,
23
+ modified_files: z.array(nonEmptyStringSchema).default([]),
24
+ ordinal: z.number().finite().optional(),
25
+ parent_task_id: nonEmptyStringSchema.optional(),
26
+ priority: z.enum([
27
+ "high",
28
+ "medium",
29
+ "low"
30
+ ]).optional(),
31
+ references: z.array(nonEmptyStringSchema).default([]),
32
+ status: z.enum([
33
+ "To Do",
34
+ "In Progress",
35
+ "Done"
36
+ ]),
37
+ title: nonEmptyStringSchema
38
+ }).passthrough();
39
+ var BacklogTaskStoreError = class extends Data.TaggedError("BacklogTaskStoreError") {};
40
+ function loadBacklogTaskStoreEffect(worktreePath) {
41
+ return Effect.gen(function* () {
42
+ return yield* buildBacklogTaskStoreEffect(yield* readBacklogTasksEffect(worktreePath));
43
+ });
44
+ }
45
+ function parseBacklogTaskMarkdownEffect(source, filePath) {
46
+ return Effect.gen(function* () {
47
+ const parsed = yield* Effect.try({
48
+ catch: (error) => storeError(filePath, `Could not parse Backlog task frontmatter: ${errorMessage(error)}`),
49
+ try: () => matter(source)
50
+ });
51
+ return taskRecordFromDocument(yield* decodeFrontmatterEffect(parsed.data, filePath), parsed.content, filePath);
52
+ });
53
+ }
54
+ function readBacklogTasksEffect(worktreePath) {
55
+ const tasksDir = join(worktreePath, "backlog", "tasks");
56
+ return Effect.gen(function* () {
57
+ const repoIo = yield* RepoIoService;
58
+ if (!(yield* repoIo.exists(tasksDir).pipe(Effect.mapError((error) => ioStoreError(tasksDir, error))))) return [];
59
+ const entries = yield* repoIo.readDir(tasksDir).pipe(Effect.mapError((error) => ioStoreError(tasksDir, error)));
60
+ return yield* Effect.all(entries.filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) => {
61
+ const filePath = join(tasksDir, entry.name);
62
+ return repoIo.readText(filePath).pipe(Effect.mapError((error) => ioStoreError(filePath, error)), Effect.flatMap((source) => parseBacklogTaskMarkdownEffect(source, filePath)));
63
+ }));
64
+ });
65
+ }
66
+ function decodeFrontmatterEffect(frontmatter, filePath) {
67
+ const decoded = taskFrontmatterSchema.safeParse(frontmatter);
68
+ if (decoded.success) return Effect.succeed(decoded.data);
69
+ return Effect.fail(storeError(filePath, `Invalid Backlog task frontmatter in ${filePath}: ${formatZodIssues(decoded.error.issues)}`));
70
+ }
71
+ function buildBacklogTaskStoreEffect(tasks) {
72
+ return Effect.gen(function* () {
73
+ const tasksById = yield* indexTasksByIdEffect(tasks);
74
+ return {
75
+ childrenByParentId: indexChildrenByParentId(tasks),
76
+ tasks,
77
+ tasksById
78
+ };
79
+ });
80
+ }
81
+ function indexTasksByIdEffect(tasks) {
82
+ const tasksById = /* @__PURE__ */ new Map();
83
+ for (const task of tasks) {
84
+ const existing = tasksById.get(task.id);
85
+ if (existing) return Effect.fail(storeError(task.filePath, `Duplicate Backlog task id ${task.id}: ${existing.filePath} and ${task.filePath}`));
86
+ tasksById.set(task.id, task);
87
+ }
88
+ return Effect.succeed(tasksById);
89
+ }
90
+ function taskRecordFromDocument(frontmatter, content, filePath) {
91
+ return {
92
+ acceptanceCriteria: extractAcceptanceCriteria(content),
93
+ dependencies: frontmatter.dependencies,
94
+ description: extractDescription(content),
95
+ filePath,
96
+ id: frontmatter.id,
97
+ modifiedFiles: frontmatter.modified_files,
98
+ ordinal: frontmatter.ordinal,
99
+ parentTaskId: frontmatter.parent_task_id,
100
+ priority: frontmatter.priority,
101
+ references: frontmatter.references,
102
+ status: frontmatter.status,
103
+ title: frontmatter.title
104
+ };
105
+ }
106
+ function extractDescription(content) {
107
+ return extractMarkedBlock(content, DESCRIPTION_MARKERS);
108
+ }
109
+ function extractAcceptanceCriteria(content) {
110
+ return extractMarkedBlock(content, ACCEPTANCE_MARKERS).split(LINE_RE).map((line) => ACCEPTANCE_ITEM_RE.exec(line)?.[1]?.trim()).filter((criterion) => Boolean(criterion));
111
+ }
112
+ function extractMarkedBlock(content, markers) {
113
+ const start = content.indexOf(markers.start);
114
+ if (start < 0) return "";
115
+ const bodyStart = start + markers.start.length;
116
+ const end = content.indexOf(markers.end, bodyStart);
117
+ if (end < 0) return "";
118
+ return content.slice(bodyStart, end).trim();
119
+ }
120
+ function ioStoreError(path, error) {
121
+ return storeError(path, `Could not read Backlog task data: ${errorMessage(error)}`);
122
+ }
123
+ function storeError(path, message) {
124
+ return new BacklogTaskStoreError({
125
+ message,
126
+ path
127
+ });
128
+ }
129
+ //#endregion
130
+ export { loadBacklogTaskStoreEffect };
@@ -0,0 +1,92 @@
1
+ import { compareBacklogTasks, compareTicketIds, indexChildrenByParentId } from "./ticket-task-index.js";
2
+ import { Data, Effect } from "effect";
3
+ import { Graph, alg } from "@dagrejs/graphlib";
4
+ //#region src/tickets/ticket-graph.ts
5
+ var TicketGraphError = class extends Data.TaggedError("TicketGraphError") {};
6
+ function buildTicketGraphEffect(tasks) {
7
+ return Effect.gen(function* () {
8
+ const graph = buildUncheckedTicketGraph(tasks);
9
+ const missingDependencies = missingDependencyMessages(graph);
10
+ if (missingDependencies.length > 0) return yield* Effect.fail(new TicketGraphError({ message: `Backlog dependency graph has missing references: ${missingDependencies.join("; ")}` }));
11
+ const cycles = alg.findCycles(graph.dependencyGraph);
12
+ if (cycles.length > 0) return yield* Effect.fail(new TicketGraphError({ message: `Backlog dependency graph contains cycle: ${cycles.map((cycle) => cycle.sort(compareTaskIds).join(" -> ")).join("; ")}` }));
13
+ return graph;
14
+ });
15
+ }
16
+ function sequenceTicketBatchesEffect(graph, ticketIds = graph.dependencyGraph.nodes()) {
17
+ return Effect.gen(function* () {
18
+ const batches = sequenceUncheckedTicketBatches(graph, ticketIds);
19
+ if (batches.length === 0 && ticketIds.length > 0) return yield* Effect.fail(new TicketGraphError({ message: `Backlog dependency graph cannot be sequenced: ${[...ticketIds].sort(compareTaskIds).join(", ")}` }));
20
+ return batches;
21
+ });
22
+ }
23
+ function scopedTicketIds(graph, rootId) {
24
+ if (!rootId) return graph.dependencyGraph.nodes().sort(compareTaskIds);
25
+ if (!graph.tasksById.has(rootId)) return [];
26
+ const ids = new Set([rootId]);
27
+ const visit = (id) => {
28
+ for (const child of graph.childrenByParentId.get(id) ?? []) {
29
+ ids.add(child.id);
30
+ visit(child.id);
31
+ }
32
+ };
33
+ visit(rootId);
34
+ return [...ids].sort(compareTaskIds);
35
+ }
36
+ function predecessorIds(graph, id) {
37
+ return [...graph.dependencyGraph.predecessors(id) ?? []].sort(compareTaskIds);
38
+ }
39
+ function compareTaskIds(a, b) {
40
+ return compareTicketIds(a, b);
41
+ }
42
+ function buildUncheckedTicketGraph(tasks) {
43
+ const sortedTasks = [...tasks].sort(compareTasks);
44
+ const tasksById = indexTasksById(sortedTasks);
45
+ const dependencyGraph = buildDependencyGraph(sortedTasks, tasksById);
46
+ return {
47
+ childrenByParentId: indexChildrenByParentId(sortedTasks),
48
+ dependencyGraph,
49
+ tasksById
50
+ };
51
+ }
52
+ function indexTasksById(tasks) {
53
+ const tasksById = /* @__PURE__ */ new Map();
54
+ for (const task of tasks) tasksById.set(task.id, task);
55
+ return tasksById;
56
+ }
57
+ function buildDependencyGraph(tasks, tasksById) {
58
+ const dependencyGraph = new Graph({ directed: true });
59
+ addDependencyNodes(dependencyGraph, tasks);
60
+ addDependencyEdges(dependencyGraph, tasks, tasksById);
61
+ return dependencyGraph;
62
+ }
63
+ function addDependencyNodes(dependencyGraph, tasks) {
64
+ for (const task of tasks) dependencyGraph.setNode(task.id, task);
65
+ }
66
+ function addDependencyEdges(dependencyGraph, tasks, tasksById) {
67
+ for (const task of tasks) addTaskDependencyEdges(dependencyGraph, task, tasksById);
68
+ }
69
+ function addTaskDependencyEdges(dependencyGraph, task, tasksById) {
70
+ for (const dependency of task.dependencies) if (tasksById.has(dependency)) dependencyGraph.setEdge(dependency, task.id);
71
+ }
72
+ function missingDependencyMessages(graph) {
73
+ const messages = [];
74
+ for (const task of graph.tasksById.values()) for (const dependency of task.dependencies) if (!graph.tasksById.has(dependency)) messages.push(`${task.id} depends on missing ${dependency}`);
75
+ return messages;
76
+ }
77
+ function sequenceUncheckedTicketBatches(graph, ticketIds) {
78
+ const remaining = new Set(ticketIds);
79
+ const batches = [];
80
+ while (remaining.size > 0) {
81
+ const ready = [...remaining].filter((id) => predecessorIds(graph, id).every((dependency) => !remaining.has(dependency))).sort(compareTaskIds);
82
+ if (ready.length === 0) return [];
83
+ batches.push(ready);
84
+ for (const id of ready) remaining.delete(id);
85
+ }
86
+ return batches;
87
+ }
88
+ function compareTasks(a, b) {
89
+ return compareBacklogTasks(a, b);
90
+ }
91
+ //#endregion
92
+ export { buildTicketGraphEffect, compareTaskIds, predecessorIds, scopedTicketIds, sequenceTicketBatchesEffect };
@@ -0,0 +1,28 @@
1
+ //#region src/tickets/ticket-plan-command-args.ts
2
+ function ticketCreateArgs(task, options = {}) {
3
+ const dependencyIds = options.dependencyIds ?? localDependencyKeys(task);
4
+ return [
5
+ "task",
6
+ "create",
7
+ task.title,
8
+ ...options.parentId ? ["--parent", options.parentId] : [],
9
+ "--description",
10
+ task.description,
11
+ ...task.priority ? ["--priority", task.priority] : [],
12
+ ...dependencyIds.flatMap((dependencyId) => ["--dep", dependencyId]),
13
+ ...task.acceptance_criteria.flatMap((criterion) => ["--ac", `${criterion.text}; evidence: ${criterion.evidence}`]),
14
+ "--plan",
15
+ task.plan,
16
+ ...task.references.flatMap((reference) => ["--ref", reference]),
17
+ ...task.likely_files.flatMap((path) => ["--modified-file", path]),
18
+ "--plain"
19
+ ];
20
+ }
21
+ function formatBacklogCommand(args) {
22
+ return ["backlog", ...args].join(" ");
23
+ }
24
+ function localDependencyKeys(task) {
25
+ return "depends_on" in task ? task.depends_on : [];
26
+ }
27
+ //#endregion
28
+ export { formatBacklogCommand, ticketCreateArgs };
@@ -0,0 +1,22 @@
1
+ import { ticketCreateArgs } from "./ticket-plan-command-args.js";
2
+ //#region src/tickets/ticket-plan-render.ts
3
+ const SAFE_SHELL_ARG_RE = /^[A-Za-z0-9_./:=@+-]+$/;
4
+ function renderTicketPlanDryRun(plan) {
5
+ return [
6
+ "# Dry run: no Backlog files were written.",
7
+ ...plan.epic ? [renderCreateCommand(plan.epic)] : [],
8
+ ...plan.tickets.map((ticket) => renderCreateCommand(ticket, plan.epic?.key))
9
+ ].join("\n");
10
+ }
11
+ function renderCreateCommand(task, parentKey) {
12
+ return renderCommand(["backlog", ...ticketCreateArgs(task, { parentId: parentKey })]);
13
+ }
14
+ function renderCommand(args) {
15
+ return args.map(shellArg).join(" ");
16
+ }
17
+ function shellArg(value) {
18
+ if (SAFE_SHELL_ARG_RE.test(value)) return value;
19
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
20
+ }
21
+ //#endregion
22
+ export { renderTicketPlanDryRun };