@jwilger/pi-development-system 0.55.0 → 0.56.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/extensions/development-system.ts +2 -0
- package/package.json +1 -1
- package/prompts/devsys-plan.md +12 -0
- package/skills/work-intake-and-slicing/SKILL.md +57 -0
- package/src/tracker/github.ts +187 -0
- package/src/tracker/item-format.ts +67 -0
- package/src/tracker/repo-files.ts +133 -0
- package/src/tracker/select.ts +34 -0
- package/src/tracker/types.ts +42 -0
- package/src/tracker/work-item-tool.ts +141 -0
|
@@ -30,6 +30,7 @@ import { createRouteTaskTool } from "../src/state/route-task-tool.ts";
|
|
|
30
30
|
import { createSessionState } from "../src/state/session-state.ts";
|
|
31
31
|
import { registerTestEvidence } from "../src/state/test-evidence.ts";
|
|
32
32
|
import piSubagent from "../src/subagents/index.ts";
|
|
33
|
+
import { createWorkItemTool } from "../src/tracker/work-item-tool.ts";
|
|
33
34
|
|
|
34
35
|
const nonNegotiables = readFileSync(
|
|
35
36
|
new URL("../principles/NON-NEGOTIABLES.md", import.meta.url),
|
|
@@ -142,6 +143,7 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
142
143
|
pi.registerTool(createRouteTaskTool({ jev: (ctx) => jevHolder.forContext(ctx) }));
|
|
143
144
|
pi.registerTool(createIntakeTool({ state, jev: (ctx) => jevHolder.forContext(ctx) }));
|
|
144
145
|
pi.registerTool(createTaskCheckTool({ jev: (ctx) => jevHolder.forContext(ctx) }));
|
|
146
|
+
pi.registerTool(createWorkItemTool({ exec }));
|
|
145
147
|
const reviewDeps = {
|
|
146
148
|
state,
|
|
147
149
|
jev: (ctx: ExtensionContext) => jevHolder.forContext(ctx),
|
package/package.json
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Write the plan file for sized work, one task record per slice, and hand it to a goal
|
|
3
|
+
argument-hint: "<slug> [what the plan covers]"
|
|
4
|
+
---
|
|
5
|
+
Write the plan for the current work with the work-intake-and-slicing skill.
|
|
6
|
+
|
|
7
|
+
1. Slug and scope come from: $ARGUMENTS. If the slug is missing, derive one from the active slice and say which you chose.
|
|
8
|
+
2. Write `docs/plan/<slug>.md` with these sections: Goal and why; Constraints (what must not change); Increments, each shippable on its own and small enough to release; for every task a task record in the format `## <id> — <title>` with **Goal**, **Files**, **Interfaces**, **First failing test**, **Steps**, **Run**, **Expected**, **Out of scope**; a Progress checklist with one box per increment.
|
|
9
|
+
3. Write each task so that someone who sees only that task could finish it. Never write `TBD`.
|
|
10
|
+
4. Run `devsys_task_check` on every task record you wrote and fix each one until it says ready. Split any that come back too-big.
|
|
11
|
+
5. If a `create_goal` tool exists in this session, call it with the objective "complete docs/plan/<slug>.md honouring its Constraints and Progress checklist". Otherwise tell the user the plan path so they can start a goal on it. Do not depend on any goal tool's file format.
|
|
12
|
+
6. Stop and ask the user to review the plan before any implementation starts.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: work-intake-and-slicing
|
|
3
|
+
description: Size new work, choose the planning artifacts it deserves, and cut it into task records a weaker implementer can finish. Use when starting any piece of work, when asked to plan, when a task feels too big, or before handing work to an implementer subagent.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Work intake and slicing
|
|
7
|
+
|
|
8
|
+
Planning is proportional: the size of the work decides the paperwork. A one-line fix does not get a product brief, and a new capability does not get coded from a sentence.
|
|
9
|
+
|
|
10
|
+
## Size first
|
|
11
|
+
|
|
12
|
+
Ask these in order and stop at the first yes:
|
|
13
|
+
|
|
14
|
+
1. Does it change what the product is for, who uses it, or how it earns its place? That is a `product`.
|
|
15
|
+
2. Does it add something a user can newly do, across several parts of the system? That is a `capability`.
|
|
16
|
+
3. Does it change existing behaviour in one area? That is a `change`.
|
|
17
|
+
4. Otherwise it is a `fix`.
|
|
18
|
+
|
|
19
|
+
`/devsys-start` proposes a size with Jev and asks you to confirm. If you disagree, change it; the size is a judgement, not a rule.
|
|
20
|
+
|
|
21
|
+
## Artifacts follow size
|
|
22
|
+
|
|
23
|
+
- `fix`: a task record.
|
|
24
|
+
- `change`: task record, review, an ADR if a decision is hard to reverse.
|
|
25
|
+
- `capability`: add a brief-lite, journeys, an event model, and optionally lens review.
|
|
26
|
+
- `product`: the full set, including brief, decision register and architecture.
|
|
27
|
+
|
|
28
|
+
Skipping a recommended artifact is allowed and recorded: `devsys_record_departure` with gate `artifact.skipped:<artifact>` and the reason. The event model and architecture are never blocked by a gate; they are recommendations you accept or decline on the record.
|
|
29
|
+
|
|
30
|
+
## A plan longer than the code has written the code
|
|
31
|
+
|
|
32
|
+
If the plan spells out every line, it has become the implementation, and a reviewer cannot tell design from typing. A task record names files, interfaces, the first failing test and small steps. It does not contain the function bodies.
|
|
33
|
+
|
|
34
|
+
## One slice, one clean context
|
|
35
|
+
|
|
36
|
+
- Cut work into slices that each ship and release on their own.
|
|
37
|
+
- Do one slice per clean context. When a slice is done and released, start the next from its task record, not from the memory of the last.
|
|
38
|
+
- The implementer sees only its task record. If it needs something not in the record, the record is incomplete: fix the record, do not paste the conversation.
|
|
39
|
+
|
|
40
|
+
## Task records
|
|
41
|
+
|
|
42
|
+
Format: `## <id> — <title>` then **Goal**, **Files**, **Interfaces**, **First failing test**, **Steps** (3 to 7, each reviewable alone), **Run**, **Expected**, **Out of scope**.
|
|
43
|
+
|
|
44
|
+
- Goal is one observable sentence.
|
|
45
|
+
- Run is a command and Expected is its concrete result.
|
|
46
|
+
- No `TBD`.
|
|
47
|
+
- If describing the first failing test needs two tests, it is two tasks.
|
|
48
|
+
|
|
49
|
+
Run `devsys_task_check` on each record. `needs-detail` names what to sharpen; `too-big` means split before anyone implements.
|
|
50
|
+
|
|
51
|
+
## Where work items live
|
|
52
|
+
|
|
53
|
+
Use `devsys_work_item` for the backlog. It reads `[tracker] kind` from `.development-system.toml`: `repo-files` (default, `work/backlog.md` and `work/items/<id>.md`) or `github` issues. Jira and Linear are configurable but not implemented yet.
|
|
54
|
+
|
|
55
|
+
## Handing off
|
|
56
|
+
|
|
57
|
+
`/devsys-plan <slug>` writes `docs/plan/<slug>.md` in this structure and, when a goal tool is available, starts a goal to complete it. Review the plan with the user before any implementation.
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { Exec } from "../core/exec.ts";
|
|
2
|
+
import { ok } from "../core/result.ts";
|
|
3
|
+
import {
|
|
4
|
+
type ItemFilter,
|
|
5
|
+
type NewWorkItem,
|
|
6
|
+
type Tracker,
|
|
7
|
+
type TrackerResult,
|
|
8
|
+
trackerError,
|
|
9
|
+
type WorkItem,
|
|
10
|
+
type WorkItemPatch,
|
|
11
|
+
type WorkStatus,
|
|
12
|
+
} from "./types.ts";
|
|
13
|
+
|
|
14
|
+
const FIELDS = "number,title,body,state,labels,comments";
|
|
15
|
+
const IN_PROGRESS = "in-progress";
|
|
16
|
+
const ISSUE_NUMBER = /^[1-9][0-9]*$/;
|
|
17
|
+
const STATE_ARG = { open: "open", "in-progress": "open", done: "closed" } as const;
|
|
18
|
+
|
|
19
|
+
type GhIssue = {
|
|
20
|
+
number: number;
|
|
21
|
+
title: string;
|
|
22
|
+
state: string;
|
|
23
|
+
body?: string | null;
|
|
24
|
+
labels?: { name: string }[];
|
|
25
|
+
comments?: { body: string }[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const isIssue = (value: unknown): value is GhIssue =>
|
|
29
|
+
typeof value === "object" &&
|
|
30
|
+
value !== null &&
|
|
31
|
+
"number" in value &&
|
|
32
|
+
typeof value.number === "number" &&
|
|
33
|
+
"title" in value &&
|
|
34
|
+
typeof value.title === "string" &&
|
|
35
|
+
"state" in value &&
|
|
36
|
+
typeof value.state === "string";
|
|
37
|
+
|
|
38
|
+
const statusOf = (state: string, labels: readonly string[]): WorkStatus => {
|
|
39
|
+
if (state === "CLOSED") return "done";
|
|
40
|
+
return labels.includes(IN_PROGRESS) ? "in-progress" : "open";
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const toItem = (issue: GhIssue): WorkItem => {
|
|
44
|
+
const names = (issue.labels ?? []).map((l) => l.name);
|
|
45
|
+
return {
|
|
46
|
+
id: String(issue.number),
|
|
47
|
+
title: issue.title,
|
|
48
|
+
body: issue.body ?? "",
|
|
49
|
+
status: statusOf(issue.state, names),
|
|
50
|
+
labels: names.filter((n) => n !== IN_PROGRESS),
|
|
51
|
+
comments: (issue.comments ?? []).map((c) => c.body),
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const parseIssue = (text: string): GhIssue | undefined => {
|
|
56
|
+
try {
|
|
57
|
+
const value: unknown = JSON.parse(text);
|
|
58
|
+
return isIssue(value) ? value : undefined;
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const parseIssues = (text: string): GhIssue[] | undefined => {
|
|
65
|
+
try {
|
|
66
|
+
const value: unknown = JSON.parse(text);
|
|
67
|
+
return Array.isArray(value) && value.every(isIssue) ? value : undefined;
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type GithubTrackerDeps = {
|
|
74
|
+
readonly exec: Exec;
|
|
75
|
+
readonly cwd: string;
|
|
76
|
+
/** `owner/name`; omitted means the repository of the working directory. */
|
|
77
|
+
readonly repo?: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
type Gh = (args: string[]) => Promise<TrackerResult<string>>;
|
|
81
|
+
|
|
82
|
+
const notNumber = (id: string) => trackerError(`"${id}" is not a GitHub issue number`);
|
|
83
|
+
|
|
84
|
+
const bindGh =
|
|
85
|
+
(deps: GithubTrackerDeps): Gh =>
|
|
86
|
+
async (args) => {
|
|
87
|
+
const repoArgs = deps.repo === undefined ? [] : ["--repo", deps.repo];
|
|
88
|
+
const result = await deps.exec("gh", [...args, ...repoArgs], {
|
|
89
|
+
cwd: deps.cwd,
|
|
90
|
+
timeout: 30_000,
|
|
91
|
+
});
|
|
92
|
+
if (result.code === 0) return ok(result.stdout);
|
|
93
|
+
const why = result.stderr.trim() || `exit ${result.code}`;
|
|
94
|
+
return trackerError(`gh ${args.slice(0, 2).join(" ")} failed: ${why}`);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const view = async (gh: Gh, id: string): Promise<TrackerResult<WorkItem>> => {
|
|
98
|
+
if (!ISSUE_NUMBER.test(id)) return notNumber(id);
|
|
99
|
+
const out = await gh(["issue", "view", id, "--json", FIELDS]);
|
|
100
|
+
if (!out.ok) return out;
|
|
101
|
+
const parsed = parseIssue(out.value);
|
|
102
|
+
return parsed === undefined
|
|
103
|
+
? trackerError(`gh issue view ${id}: unexpected output`)
|
|
104
|
+
: ok(toItem(parsed));
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const listIssues = async (gh: Gh, filter: ItemFilter): Promise<TrackerResult<WorkItem[]>> => {
|
|
108
|
+
const state = filter.status === undefined ? "all" : STATE_ARG[filter.status];
|
|
109
|
+
const out = await gh(["issue", "list", "--state", state, "--limit", "200", "--json", FIELDS]);
|
|
110
|
+
if (!out.ok) return out;
|
|
111
|
+
const parsed = parseIssues(out.value);
|
|
112
|
+
if (parsed === undefined) return trackerError("gh issue list: unexpected output");
|
|
113
|
+
const items = parsed.map(toItem);
|
|
114
|
+
return ok(filter.status === undefined ? items : items.filter((i) => i.status === filter.status));
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const createIssue = async (gh: Gh, input: NewWorkItem): Promise<TrackerResult<WorkItem>> => {
|
|
118
|
+
const labels = (input.labels ?? []).flatMap((l) => ["--label", l]);
|
|
119
|
+
const out = await gh([
|
|
120
|
+
"issue",
|
|
121
|
+
"create",
|
|
122
|
+
"--title",
|
|
123
|
+
input.title,
|
|
124
|
+
"--body",
|
|
125
|
+
input.body ?? "",
|
|
126
|
+
...labels,
|
|
127
|
+
]);
|
|
128
|
+
if (!out.ok) return out;
|
|
129
|
+
const number = /\/issues\/(\d+)\s*$/.exec(out.value)?.[1];
|
|
130
|
+
if (number === undefined) return trackerError("gh issue create: no issue URL in the output");
|
|
131
|
+
return view(gh, number);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const applyStatus = async (
|
|
135
|
+
gh: Gh,
|
|
136
|
+
id: string,
|
|
137
|
+
status: WorkStatus,
|
|
138
|
+
): Promise<TrackerResult<string>> => {
|
|
139
|
+
if (status === "done") return gh(["issue", "close", id]);
|
|
140
|
+
// Reopening an issue that is already open fails; only the label change below decides the result.
|
|
141
|
+
await gh(["issue", "reopen", id]);
|
|
142
|
+
const flag = status === "in-progress" ? "--add-label" : "--remove-label";
|
|
143
|
+
return gh(["issue", "edit", id, flag, IN_PROGRESS]);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const editArgs = (patch: WorkItemPatch): string[] => [
|
|
147
|
+
...(patch.title === undefined ? [] : ["--title", patch.title]),
|
|
148
|
+
...(patch.body === undefined ? [] : ["--body", patch.body]),
|
|
149
|
+
...(patch.labels ?? []).flatMap((label) => ["--add-label", label]),
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
const updateIssue = async (
|
|
153
|
+
gh: Gh,
|
|
154
|
+
id: string,
|
|
155
|
+
patch: WorkItemPatch,
|
|
156
|
+
): Promise<TrackerResult<WorkItem>> => {
|
|
157
|
+
if (!ISSUE_NUMBER.test(id)) return notNumber(id);
|
|
158
|
+
const edits = editArgs(patch);
|
|
159
|
+
if (edits.length > 0) {
|
|
160
|
+
const edited = await gh(["issue", "edit", id, ...edits]);
|
|
161
|
+
if (!edited.ok) return edited;
|
|
162
|
+
}
|
|
163
|
+
if (patch.status !== undefined) {
|
|
164
|
+
const changed = await applyStatus(gh, id, patch.status);
|
|
165
|
+
if (!changed.ok) return changed;
|
|
166
|
+
}
|
|
167
|
+
return view(gh, id);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const commentIssue = async (gh: Gh, id: string, body: string): Promise<TrackerResult<void>> => {
|
|
171
|
+
if (!ISSUE_NUMBER.test(id)) return notNumber(id);
|
|
172
|
+
const out = await gh(["issue", "comment", id, "--body", body]);
|
|
173
|
+
return out.ok ? ok(undefined) : out;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** Work items as GitHub issues, through the `gh` CLI. Every value is its own argument, never shell text. */
|
|
177
|
+
export function createGithubTracker(deps: GithubTrackerDeps): Tracker {
|
|
178
|
+
const gh = bindGh(deps);
|
|
179
|
+
return {
|
|
180
|
+
kind: "github",
|
|
181
|
+
list: (filter) => listIssues(gh, filter),
|
|
182
|
+
get: (id) => view(gh, id),
|
|
183
|
+
create: (input) => createIssue(gh, input),
|
|
184
|
+
update: (id, patch) => updateIssue(gh, id, patch),
|
|
185
|
+
comment: (id, body) => commentIssue(gh, id, body),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { ok } from "../core/result.ts";
|
|
2
|
+
import {
|
|
3
|
+
type TrackerResult,
|
|
4
|
+
trackerError,
|
|
5
|
+
WORK_STATUSES,
|
|
6
|
+
type WorkItem,
|
|
7
|
+
type WorkStatus,
|
|
8
|
+
} from "./types.ts";
|
|
9
|
+
|
|
10
|
+
const COMMENTS = "\n## Comments\n";
|
|
11
|
+
|
|
12
|
+
export const renderItem = (item: WorkItem): string => {
|
|
13
|
+
const comments = item.comments.map((c) => `\n### Comment\n\n${c}\n`).join("");
|
|
14
|
+
return [
|
|
15
|
+
`# ${item.title}`,
|
|
16
|
+
"",
|
|
17
|
+
`Status: ${item.status}`,
|
|
18
|
+
`Labels: ${item.labels.join(", ")}`,
|
|
19
|
+
"",
|
|
20
|
+
item.body,
|
|
21
|
+
COMMENTS + comments,
|
|
22
|
+
].join("\n");
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const isStatus = (value: string): value is WorkStatus =>
|
|
26
|
+
(WORK_STATUSES as readonly string[]).includes(value);
|
|
27
|
+
|
|
28
|
+
const commentsOf = (section: string): string[] =>
|
|
29
|
+
section
|
|
30
|
+
.split(/^### Comment\n\n/m)
|
|
31
|
+
.slice(1)
|
|
32
|
+
.map((c) => c.replace(/\n$/, "").replace(/\n$/, ""));
|
|
33
|
+
|
|
34
|
+
export const parseItem = (id: string, text: string): TrackerResult<WorkItem> => {
|
|
35
|
+
const marker = text.lastIndexOf(COMMENTS);
|
|
36
|
+
const main = marker < 0 ? text : text.slice(0, marker);
|
|
37
|
+
const comments = marker < 0 ? [] : commentsOf(text.slice(marker + COMMENTS.length));
|
|
38
|
+
const head = /^# (.+)\n\nStatus: (.*)\nLabels: (.*)\n\n?/.exec(main);
|
|
39
|
+
if (head === null)
|
|
40
|
+
return trackerError(`work item ${id}: expected "# title", Status and Labels lines`);
|
|
41
|
+
const [whole, title = "", status = "", labels = ""] = head;
|
|
42
|
+
if (!isStatus(status)) {
|
|
43
|
+
return trackerError(
|
|
44
|
+
`work item ${id}: unknown status "${status}" (use ${WORK_STATUSES.join(", ")})`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return ok({
|
|
48
|
+
id,
|
|
49
|
+
title,
|
|
50
|
+
body: main.slice(whole.length).replace(/\n$/, ""),
|
|
51
|
+
status,
|
|
52
|
+
labels: labels === "" ? [] : labels.split(", "),
|
|
53
|
+
comments,
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const RANK: Record<WorkStatus, number> = { "in-progress": 1, open: 0, done: 2 };
|
|
58
|
+
|
|
59
|
+
/** Derived from the item files on every change, so it cannot drift from them. */
|
|
60
|
+
export const renderBacklog = (items: readonly WorkItem[]): string => {
|
|
61
|
+
const ordered = items.toSorted((a, b) => RANK[a.status] - RANK[b.status]);
|
|
62
|
+
const lines = ordered.map((i) => {
|
|
63
|
+
const note = i.status === "in-progress" ? " (in-progress)" : "";
|
|
64
|
+
return `- [${i.status === "done" ? "x" : " "}] ${i.id} — ${i.title}${note}`;
|
|
65
|
+
});
|
|
66
|
+
return ["# Backlog", "", ...lines, ""].join("\n");
|
|
67
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ok } from "../core/result.ts";
|
|
4
|
+
import { parseItem, renderBacklog, renderItem } from "./item-format.ts";
|
|
5
|
+
import {
|
|
6
|
+
type ItemFilter,
|
|
7
|
+
type NewWorkItem,
|
|
8
|
+
type Tracker,
|
|
9
|
+
type TrackerResult,
|
|
10
|
+
trackerError,
|
|
11
|
+
type WorkItem,
|
|
12
|
+
type WorkItemPatch,
|
|
13
|
+
} from "./types.ts";
|
|
14
|
+
|
|
15
|
+
const ID = /^[a-z0-9][a-z0-9-]*$/;
|
|
16
|
+
const ID_MAX = 60;
|
|
17
|
+
|
|
18
|
+
const slugOf = (title: string): string =>
|
|
19
|
+
title
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
22
|
+
.replace(/^-+/, "")
|
|
23
|
+
.slice(0, ID_MAX)
|
|
24
|
+
.replace(/-+$/, "");
|
|
25
|
+
|
|
26
|
+
const isMissing = (cause: unknown): boolean =>
|
|
27
|
+
typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT";
|
|
28
|
+
|
|
29
|
+
const itemsDir = (root: string): string => join(root, "work", "items");
|
|
30
|
+
const fileOf = (root: string, id: string): string => join(itemsDir(root), `${id}.md`);
|
|
31
|
+
const byCodeUnit = (a: string, b: string): number => Number(a > b) - Number(a < b);
|
|
32
|
+
|
|
33
|
+
const describe = (cause: unknown): string =>
|
|
34
|
+
cause instanceof Error ? cause.message : String(cause);
|
|
35
|
+
|
|
36
|
+
const readItem = async (root: string, id: string): Promise<TrackerResult<WorkItem>> => {
|
|
37
|
+
if (!ID.test(id)) return trackerError(`work item "${id}": not a valid id`);
|
|
38
|
+
try {
|
|
39
|
+
return parseItem(id, await readFile(fileOf(root, id), "utf8"));
|
|
40
|
+
} catch (cause) {
|
|
41
|
+
if (isMissing(cause)) return trackerError(`work item "${id}" does not exist`);
|
|
42
|
+
return trackerError(`work item "${id}": ${describe(cause)}`);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const itemIds = async (root: string): Promise<string[]> => {
|
|
47
|
+
try {
|
|
48
|
+
const files = await readdir(itemsDir(root));
|
|
49
|
+
return files.flatMap((f) => (f.endsWith(".md") ? [f.slice(0, -3)] : []));
|
|
50
|
+
} catch (cause) {
|
|
51
|
+
if (isMissing(cause)) return [];
|
|
52
|
+
throw cause;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const readAll = async (root: string): Promise<TrackerResult<WorkItem[]>> => {
|
|
57
|
+
const items: WorkItem[] = [];
|
|
58
|
+
for (const id of (await itemIds(root)).toSorted(byCodeUnit)) {
|
|
59
|
+
const item = await readItem(root, id);
|
|
60
|
+
if (!item.ok) return item;
|
|
61
|
+
items.push(item.value);
|
|
62
|
+
}
|
|
63
|
+
return ok(items);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const save = async (root: string, item: WorkItem): Promise<TrackerResult<WorkItem>> => {
|
|
67
|
+
await mkdir(itemsDir(root), { recursive: true });
|
|
68
|
+
await writeFile(fileOf(root, item.id), renderItem(item));
|
|
69
|
+
const items = await readAll(root);
|
|
70
|
+
if (!items.ok) return items;
|
|
71
|
+
await writeFile(join(root, "work", "backlog.md"), renderBacklog(items.value));
|
|
72
|
+
return ok(item);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const freshId = async (root: string, title: string): Promise<string | undefined> => {
|
|
76
|
+
const base = slugOf(title);
|
|
77
|
+
if (base === "") return undefined;
|
|
78
|
+
const taken = new Set(await itemIds(root));
|
|
79
|
+
let id = base;
|
|
80
|
+
for (let n = 2; taken.has(id); n++) id = `${base}-${n}`;
|
|
81
|
+
return id;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const listItems = async (root: string, filter: ItemFilter): Promise<TrackerResult<WorkItem[]>> => {
|
|
85
|
+
const items = await readAll(root);
|
|
86
|
+
if (!items.ok || filter.status === undefined) return items;
|
|
87
|
+
return ok(items.value.filter((i) => i.status === filter.status));
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const createItem = async (root: string, input: NewWorkItem): Promise<TrackerResult<WorkItem>> => {
|
|
91
|
+
const id = await freshId(root, input.title);
|
|
92
|
+
if (id === undefined) return trackerError("a work item title needs letters or digits");
|
|
93
|
+
return save(root, {
|
|
94
|
+
id,
|
|
95
|
+
title: input.title,
|
|
96
|
+
body: input.body ?? "",
|
|
97
|
+
status: "open",
|
|
98
|
+
labels: input.labels ?? [],
|
|
99
|
+
comments: [],
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const updateItem = async (
|
|
104
|
+
root: string,
|
|
105
|
+
id: string,
|
|
106
|
+
patch: WorkItemPatch,
|
|
107
|
+
): Promise<TrackerResult<WorkItem>> => {
|
|
108
|
+
const current = await readItem(root, id);
|
|
109
|
+
return current.ok ? save(root, { ...current.value, ...patch }) : current;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const commentItem = async (
|
|
113
|
+
root: string,
|
|
114
|
+
id: string,
|
|
115
|
+
body: string,
|
|
116
|
+
): Promise<TrackerResult<void>> => {
|
|
117
|
+
const current = await readItem(root, id);
|
|
118
|
+
if (!current.ok) return current;
|
|
119
|
+
const saved = await save(root, { ...current.value, comments: [...current.value.comments, body] });
|
|
120
|
+
return saved.ok ? ok(undefined) : saved;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** Work items as markdown files in the repo: `work/items/<id>.md`, with a derived `work/backlog.md`. */
|
|
124
|
+
export function createRepoFilesTracker(root: string): Tracker {
|
|
125
|
+
return {
|
|
126
|
+
kind: "repo-files",
|
|
127
|
+
list: (filter) => listItems(root, filter),
|
|
128
|
+
get: (id) => readItem(root, id),
|
|
129
|
+
create: (input) => createItem(root, input),
|
|
130
|
+
update: (id, patch) => updateItem(root, id, patch),
|
|
131
|
+
comment: (id, body) => commentItem(root, id, body),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Exec } from "../core/exec.ts";
|
|
2
|
+
import { ok } from "../core/result.ts";
|
|
3
|
+
import type { DevsysConfig } from "../state/config.ts";
|
|
4
|
+
import { createGithubTracker } from "./github.ts";
|
|
5
|
+
import { createRepoFilesTracker } from "./repo-files.ts";
|
|
6
|
+
import { type Tracker, type TrackerResult, trackerError } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
/** The adapter named by `[tracker] kind`. Jira and Linear are accepted in config but not built yet. */
|
|
9
|
+
export function createTracker(deps: {
|
|
10
|
+
tracker: DevsysConfig["tracker"];
|
|
11
|
+
exec: Exec;
|
|
12
|
+
cwd: string;
|
|
13
|
+
}): TrackerResult<Tracker> {
|
|
14
|
+
const { tracker, exec, cwd } = deps;
|
|
15
|
+
switch (tracker.kind) {
|
|
16
|
+
case "repo-files":
|
|
17
|
+
return ok(createRepoFilesTracker(cwd));
|
|
18
|
+
case "github":
|
|
19
|
+
return ok(
|
|
20
|
+
createGithubTracker({
|
|
21
|
+
exec,
|
|
22
|
+
cwd,
|
|
23
|
+
...(tracker.repo === undefined ? {} : { repo: tracker.repo }),
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
case "jira":
|
|
27
|
+
case "linear":
|
|
28
|
+
return trackerError(
|
|
29
|
+
`tracker kind "${tracker.kind}" is not implemented yet; use "repo-files" or "github" in .development-system.toml`,
|
|
30
|
+
);
|
|
31
|
+
default:
|
|
32
|
+
return trackerError(`unknown tracker kind "${String(tracker.kind satisfies never)}"`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Result } from "../core/result.ts";
|
|
2
|
+
|
|
3
|
+
export const WORK_STATUSES = ["open", "in-progress", "done"] as const;
|
|
4
|
+
export type WorkStatus = (typeof WORK_STATUSES)[number];
|
|
5
|
+
|
|
6
|
+
export type WorkItem = {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
readonly title: string;
|
|
9
|
+
readonly body: string;
|
|
10
|
+
readonly status: WorkStatus;
|
|
11
|
+
readonly labels: readonly string[];
|
|
12
|
+
readonly comments: readonly string[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type NewWorkItem = {
|
|
16
|
+
readonly title: string;
|
|
17
|
+
readonly body?: string;
|
|
18
|
+
readonly labels?: readonly string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type WorkItemPatch = Partial<Pick<WorkItem, "title" | "body" | "status" | "labels">>;
|
|
22
|
+
|
|
23
|
+
export type ItemFilter = { readonly status?: WorkStatus };
|
|
24
|
+
|
|
25
|
+
export type TrackerError = { readonly kind: "tracker-error"; readonly message: string };
|
|
26
|
+
|
|
27
|
+
export type TrackerResult<T> = Result<T, TrackerError>;
|
|
28
|
+
|
|
29
|
+
/** Where work items live. One adapter per `[tracker] kind`; callers never see the backend. */
|
|
30
|
+
export interface Tracker {
|
|
31
|
+
readonly kind: string;
|
|
32
|
+
list(filter: ItemFilter): Promise<TrackerResult<WorkItem[]>>;
|
|
33
|
+
get(id: string): Promise<TrackerResult<WorkItem>>;
|
|
34
|
+
create(item: NewWorkItem): Promise<TrackerResult<WorkItem>>;
|
|
35
|
+
update(id: string, patch: WorkItemPatch): Promise<TrackerResult<WorkItem>>;
|
|
36
|
+
comment(id: string, body: string): Promise<TrackerResult<void>>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const trackerError = (message: string): { ok: false; error: TrackerError } => ({
|
|
40
|
+
ok: false,
|
|
41
|
+
error: { kind: "tracker-error", message },
|
|
42
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Static, Type } from "typebox";
|
|
3
|
+
import type { Exec } from "../core/exec.ts";
|
|
4
|
+
import { assertNever } from "../core/exhaustive.ts";
|
|
5
|
+
import { CONFIG_FILE, loadConfig } from "../state/config.ts";
|
|
6
|
+
import { createTracker } from "./select.ts";
|
|
7
|
+
import type { Tracker, TrackerResult, WorkItem } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
const Parameters = Type.Object({
|
|
10
|
+
action: Type.Union(
|
|
11
|
+
[
|
|
12
|
+
Type.Literal("list"),
|
|
13
|
+
Type.Literal("get"),
|
|
14
|
+
Type.Literal("create"),
|
|
15
|
+
Type.Literal("update"),
|
|
16
|
+
Type.Literal("comment"),
|
|
17
|
+
],
|
|
18
|
+
{ description: "What to do with the work tracker." },
|
|
19
|
+
),
|
|
20
|
+
id: Type.Optional(Type.String({ description: "Work item id (get, update, comment)." })),
|
|
21
|
+
title: Type.Optional(Type.String({ description: "Title (create; optional rename on update)." })),
|
|
22
|
+
body: Type.Optional(
|
|
23
|
+
Type.String({ description: "Description (create, update) or comment text (comment)." }),
|
|
24
|
+
),
|
|
25
|
+
status: Type.Optional(
|
|
26
|
+
Type.Union([Type.Literal("open"), Type.Literal("in-progress"), Type.Literal("done")], {
|
|
27
|
+
description: "Status (update), or a filter (list).",
|
|
28
|
+
}),
|
|
29
|
+
),
|
|
30
|
+
labels: Type.Optional(Type.Array(Type.String(), { description: "Labels (create, update)." })),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
type Params = Static<typeof Parameters>;
|
|
34
|
+
|
|
35
|
+
const reply = (text: string, isError = false) => ({
|
|
36
|
+
content: [{ type: "text" as const, text }],
|
|
37
|
+
details: undefined,
|
|
38
|
+
isError,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const line = (i: WorkItem): string => `${i.id} — ${i.title} (${i.status})`;
|
|
42
|
+
|
|
43
|
+
const detail = (i: WorkItem): string =>
|
|
44
|
+
[
|
|
45
|
+
line(i),
|
|
46
|
+
i.labels.length > 0 ? `labels: ${i.labels.join(", ")}` : "",
|
|
47
|
+
"",
|
|
48
|
+
i.body,
|
|
49
|
+
...i.comments.map((c) => `\ncomment: ${c}`),
|
|
50
|
+
]
|
|
51
|
+
.join("\n")
|
|
52
|
+
.trim();
|
|
53
|
+
|
|
54
|
+
const missing = (action: string, field: string) => reply(`${action} needs ${field}`, true);
|
|
55
|
+
|
|
56
|
+
const show = <T>(result: TrackerResult<T>, render: (value: T) => string) =>
|
|
57
|
+
result.ok ? reply(render(result.value)) : reply(result.error.message, true);
|
|
58
|
+
|
|
59
|
+
const optional = <K extends string, V>(key: K, value: V | undefined): { [P in K]?: V } =>
|
|
60
|
+
value === undefined ? {} : ({ [key]: value } as { [P in K]?: V });
|
|
61
|
+
|
|
62
|
+
const patchOf = (p: Params) => ({
|
|
63
|
+
...optional("status", p.status),
|
|
64
|
+
...optional("title", p.title),
|
|
65
|
+
...optional("body", p.body),
|
|
66
|
+
...optional("labels", p.labels),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
async function create(tracker: Tracker, p: Params) {
|
|
70
|
+
if (p.title === undefined) return missing("create", "title");
|
|
71
|
+
const made = await tracker.create({
|
|
72
|
+
title: p.title,
|
|
73
|
+
...optional("body", p.body),
|
|
74
|
+
...optional("labels", p.labels),
|
|
75
|
+
});
|
|
76
|
+
return show(made, (i) => `created ${line(i)}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function update(tracker: Tracker, p: Params) {
|
|
80
|
+
if (p.id === undefined) return missing("update", "id");
|
|
81
|
+
const patch = patchOf(p);
|
|
82
|
+
if (Object.keys(patch).length === 0) return missing("update", "status, title, body or labels");
|
|
83
|
+
return show(await tracker.update(p.id, patch), (i) => `updated ${line(i)}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function comment(tracker: Tracker, p: Params) {
|
|
87
|
+
const { id, body } = p;
|
|
88
|
+
if (id === undefined) return missing("comment", "id");
|
|
89
|
+
if (body === undefined) return missing("comment", "body");
|
|
90
|
+
return show(await tracker.comment(id, body), () => `commented on ${id}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function run(tracker: Tracker, p: Params) {
|
|
94
|
+
switch (p.action) {
|
|
95
|
+
case "list": {
|
|
96
|
+
const items = await tracker.list(optional("status", p.status));
|
|
97
|
+
return show(items, (all) => (all.length === 0 ? "no work items" : all.map(line).join("\n")));
|
|
98
|
+
}
|
|
99
|
+
case "get":
|
|
100
|
+
return p.id === undefined ? missing("get", "id") : show(await tracker.get(p.id), detail);
|
|
101
|
+
case "create":
|
|
102
|
+
return create(tracker, p);
|
|
103
|
+
case "update":
|
|
104
|
+
return update(tracker, p);
|
|
105
|
+
case "comment":
|
|
106
|
+
return comment(tracker, p);
|
|
107
|
+
default:
|
|
108
|
+
return assertNever(p.action);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `devsys_work_item`: the one way planning reads and writes work items, whatever `[tracker] kind` says. */
|
|
113
|
+
export function createWorkItemTool(deps: { exec: Exec }): ToolDefinition<typeof Parameters> {
|
|
114
|
+
return {
|
|
115
|
+
name: "devsys_work_item",
|
|
116
|
+
label: "Work items",
|
|
117
|
+
description:
|
|
118
|
+
"List, read, create, update or comment on work items in the project's tracker (repo files by default, GitHub issues when configured). " +
|
|
119
|
+
"Use it for the backlog instead of editing tracker files or running gh by hand.",
|
|
120
|
+
promptSnippet: "Read and write the project's work items",
|
|
121
|
+
parameters: Parameters,
|
|
122
|
+
exposure: "direct",
|
|
123
|
+
async execute(
|
|
124
|
+
_id,
|
|
125
|
+
params: Static<typeof Parameters>,
|
|
126
|
+
_signal,
|
|
127
|
+
_onUpdate,
|
|
128
|
+
ctx: ExtensionContext,
|
|
129
|
+
) {
|
|
130
|
+
const config = await loadConfig(ctx.cwd);
|
|
131
|
+
if (!config.ok) return reply(`${CONFIG_FILE}: ${config.error.message}`, true);
|
|
132
|
+
const tracker = createTracker({
|
|
133
|
+
tracker: config.value.tracker,
|
|
134
|
+
exec: deps.exec,
|
|
135
|
+
cwd: ctx.cwd,
|
|
136
|
+
});
|
|
137
|
+
if (!tracker.ok) return reply(`${CONFIG_FILE}: ${tracker.error.message}`, true);
|
|
138
|
+
return run(tracker.value, params);
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|