@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/README.md +24 -0
- package/defaults/opencode-ecosystem.yaml +3 -3
- package/defaults/profiles.yaml +17 -0
- package/dist/backlog.js +13 -0
- package/dist/cli/program.d.ts +8 -2
- package/dist/cli/program.js +33 -18
- package/dist/cli/run-command.d.ts +12 -0
- package/dist/cli/run-command.js +42 -0
- package/dist/cli/run-resolver.d.ts +36 -0
- package/dist/commands/pipeline-command.js +3 -1
- package/dist/commands/ticket-command.d.ts +15 -0
- package/dist/commands/ticket-command.js +308 -0
- package/dist/hooks.d.ts +1 -1
- package/dist/install-commands/opencode.js +3 -2
- package/dist/moka-submit.d.ts +6 -6
- package/dist/pipeline-init.js +109 -1
- package/dist/runner-event-schema.d.ts +8 -8
- package/dist/runtime/services/backlog-service.d.ts +16 -0
- package/dist/runtime/services/backlog-service.js +25 -0
- package/dist/standard-output-schemas.js +2 -0
- package/dist/tickets/apply-ticket-plan.js +75 -0
- package/dist/tickets/backlog-task-store.js +130 -0
- package/dist/tickets/ticket-graph.js +92 -0
- package/dist/tickets/ticket-plan-command-args.js +28 -0
- package/dist/tickets/ticket-plan-render.js +22 -0
- package/dist/tickets/ticket-plan.js +92 -0
- package/dist/tickets/ticket-selection.js +74 -0
- package/dist/tickets/ticket-task-index.js +24 -0
- package/dist/tickets/validation-error-format.js +12 -0
- package/docs/mcp-gateway.md +10 -9
- package/docs/operator-guide.md +36 -0
- package/package.json +1 -1
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
|
-
|
|
359
|
-
`-
|
|
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,
|
package/dist/moka-submit.d.ts
CHANGED
|
@@ -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<{
|
package/dist/pipeline-init.js
CHANGED
|
@@ -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 };
|