@jwilger/pi-development-system 0.54.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.
@@ -20,6 +20,7 @@ import { createRequestApprovalTool } from "../src/gates/request-approval-tool.ts
20
20
  import { registerTestGuard } from "../src/gates/test-guard.ts";
21
21
  import { createJevHolder } from "../src/jev/holder.ts";
22
22
  import { createIntakeTool } from "../src/planning/intake-tool.ts";
23
+ import { createTaskCheckTool } from "../src/planning/task-check-tool.ts";
23
24
  import { createReviewRecordTool, createReviewStartTool } from "../src/review/review-tools.ts";
24
25
  import { registerCiCommand } from "../src/state/ci-command.ts";
25
26
  import { loadConfig } from "../src/state/config.ts";
@@ -29,6 +30,7 @@ import { createRouteTaskTool } from "../src/state/route-task-tool.ts";
29
30
  import { createSessionState } from "../src/state/session-state.ts";
30
31
  import { registerTestEvidence } from "../src/state/test-evidence.ts";
31
32
  import piSubagent from "../src/subagents/index.ts";
33
+ import { createWorkItemTool } from "../src/tracker/work-item-tool.ts";
32
34
 
33
35
  const nonNegotiables = readFileSync(
34
36
  new URL("../principles/NON-NEGOTIABLES.md", import.meta.url),
@@ -140,6 +142,8 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
140
142
  pi.registerTool(createModelsTool());
141
143
  pi.registerTool(createRouteTaskTool({ jev: (ctx) => jevHolder.forContext(ctx) }));
142
144
  pi.registerTool(createIntakeTool({ state, jev: (ctx) => jevHolder.forContext(ctx) }));
145
+ pi.registerTool(createTaskCheckTool({ jev: (ctx) => jevHolder.forContext(ctx) }));
146
+ pi.registerTool(createWorkItemTool({ exec }));
143
147
  const reviewDeps = {
144
148
  state,
145
149
  jev: (ctx: ExtensionContext) => jevHolder.forContext(ctx),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.54.0",
3
+ "version": "0.56.0",
4
4
  "description": "A pi extension package representing a seasoned approach to software development using a full AI SDLC.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -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,82 @@
1
+ import type { ClassifierBoolQuestion } from "@earendil-works/pi-ai";
2
+ import { redactSecrets } from "../../core/redact.ts";
3
+ import { err, ok, type Result } from "../../core/result.ts";
4
+ import type { TaskRecord } from "../../planning/task-record.ts";
5
+ import type { Jev, JevError } from "../client.ts";
6
+
7
+ const vague = (instructions: string): ClassifierBoolQuestion => ({
8
+ type: "bool",
9
+ instructions,
10
+ criteria: {
11
+ true: "A weaker model implementing this alone would have to guess",
12
+ false: "It is specific enough to act on without guessing",
13
+ },
14
+ });
15
+
16
+ /** One narrow judgement per aspect; `tooBig` is separate because the fix is to split, not to add detail. */
17
+ export const READINESS_QUESTIONS = {
18
+ goal: vague(
19
+ "`task.goal` should be one observable sentence: something a person or test could see change. Is it vague, a restatement of the title, or about activity rather than outcome?",
20
+ ),
21
+ interfaces: vague(
22
+ "`task.interfaces` should give exact signatures or contracts for what is created or changed. Is it missing signatures, hand-wavy ('a helper', 'some function') or inconsistent with `task.files`?",
23
+ ),
24
+ firstFailingTest: vague(
25
+ "`task.firstFailingTest` should name a test file and test and say what it asserts. Does it fail to say what would be asserted, or assert something unrelated to `task.goal`?",
26
+ ),
27
+ steps: vague(
28
+ "`task.steps` should be small steps a reviewer could reject independently. Are any steps large, combined ('implement and wire everything'), or out of order with the test-first step?",
29
+ ),
30
+ tooBig: {
31
+ type: "bool",
32
+ instructions:
33
+ "Is this more than one task: does it touch unrelated areas, or need more than one failing test to describe, so that it should be split before anyone implements it?",
34
+ criteria: {
35
+ true: "It should be split into two or more task records",
36
+ false: "It is one coherent task a single implementer can finish",
37
+ },
38
+ },
39
+ } as const satisfies Record<string, ClassifierBoolQuestion>;
40
+
41
+ type Readiness = "ready" | "needs-detail" | "too-big";
42
+ export type ReadinessJudgement = { readiness: Readiness; missing: string[] };
43
+
44
+ /** Probability at or above which an aspect counts as vague. */
45
+ const VAGUE_AT = 0.6;
46
+
47
+ const clip = (text: string, max: number): string =>
48
+ redactSecrets(text.slice(0, max * 2)).slice(0, max);
49
+
50
+ export async function judgeTaskReadiness(
51
+ jev: Jev,
52
+ record: TaskRecord,
53
+ ): Promise<Result<ReadinessJudgement, JevError>> {
54
+ const asked = await jev.ask(
55
+ {
56
+ task: {
57
+ title: clip(record.title, 200),
58
+ goal: clip(record.goal, 600),
59
+ files: record.files.slice(0, 30).map((f) => clip(f, 200)),
60
+ interfaces: clip(record.interfaces, 1500),
61
+ firstFailingTest: clip(record.firstFailingTest, 600),
62
+ steps: record.steps.slice(0, 10).map((s) => clip(s, 300)),
63
+ },
64
+ },
65
+ READINESS_QUESTIONS,
66
+ );
67
+ if (!asked.ok) return asked;
68
+ const probability = new Map<string, number>();
69
+ for (const key of Object.keys(READINESS_QUESTIONS)) {
70
+ const answer = asked.value[key];
71
+ if (answer?.type !== "bool" || !Number.isFinite(answer.probability)) {
72
+ return err({ kind: "provider", message: `missing ${key} answer` });
73
+ }
74
+ probability.set(key, answer.probability);
75
+ }
76
+ if ((probability.get("tooBig") ?? 0) >= VAGUE_AT)
77
+ return ok({ readiness: "too-big", missing: [] });
78
+ const missing = Object.keys(READINESS_QUESTIONS).filter(
79
+ (k) => k !== "tooBig" && (probability.get(k) ?? 0) >= VAGUE_AT,
80
+ );
81
+ return ok({ readiness: missing.length > 0 ? "needs-detail" : "ready", missing });
82
+ }
@@ -0,0 +1,73 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { isAbsolute, relative, resolve } from "node:path";
3
+ import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
4
+ import { type Static, Type } from "typebox";
5
+ import { isParseError } from "../core/types.ts";
6
+ import type { Jev } from "../jev/client.ts";
7
+ import { judgeTaskReadiness } from "../jev/questions/readiness.ts";
8
+ import { parseTaskRecord } from "./task-record.ts";
9
+
10
+ const Parameters = Type.Object({
11
+ path: Type.String({
12
+ description: "Path to the task record markdown file, inside the repository.",
13
+ }),
14
+ });
15
+
16
+ const reply = (text: string, isError = false) => ({
17
+ content: [{ type: "text" as const, text }],
18
+ details: undefined,
19
+ isError,
20
+ });
21
+
22
+ const insideRepo = (cwd: string, target: string): boolean => {
23
+ const rel = relative(cwd, target);
24
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
25
+ };
26
+
27
+ /** `devsys_task_check`: structural readiness is deterministic; Jev adds "specific enough" and "one task". */
28
+ export function createTaskCheckTool(deps: {
29
+ jev: (ctx: ExtensionContext) => Jev;
30
+ }): ToolDefinition<typeof Parameters> {
31
+ return {
32
+ name: "devsys_task_check",
33
+ label: "Check task record",
34
+ description:
35
+ "Check a task record file against the task-record format and judge whether a weaker implementer could act on it. " +
36
+ "Run before handing a task record to an implementer subagent.",
37
+ promptSnippet: "Check a task record is ready for an implementer",
38
+ parameters: Parameters,
39
+ exposure: "direct",
40
+ async execute(_id, params: Static<typeof Parameters>, _signal, _onUpdate, ctx) {
41
+ const target = resolve(ctx.cwd, params.path);
42
+ if (!insideRepo(ctx.cwd, target))
43
+ return reply(`${params.path} is outside the repository`, true);
44
+ let markdown: string;
45
+ try {
46
+ markdown = await readFile(target, "utf8");
47
+ } catch (cause) {
48
+ return reply(
49
+ `cannot read ${params.path}: ${cause instanceof Error ? cause.message : String(cause)}`,
50
+ true,
51
+ );
52
+ }
53
+ const record = parseTaskRecord(markdown);
54
+ if (isParseError(record))
55
+ return reply(`${params.path} is not ready: ${record.message}`, true);
56
+ const judged = await judgeTaskReadiness(deps.jev(ctx), record);
57
+ if (!judged.ok) {
58
+ return reply(
59
+ `${record.id}: structure ok. Jev unavailable (${judged.error.kind}), so specificity and size were not judged; review the record yourself.`,
60
+ );
61
+ }
62
+ const { readiness, missing } = judged.value;
63
+ if (readiness === "ready")
64
+ return reply(`${record.id}: ready. Structure ok and specific enough for an implementer.`);
65
+ if (readiness === "too-big") {
66
+ return reply(
67
+ `${record.id}: too-big. Split it into task records that each need one failing test, then check each.`,
68
+ );
69
+ }
70
+ return reply(`${record.id}: needs-detail. Make these more specific: ${missing.join(", ")}.`);
71
+ },
72
+ };
73
+ }
@@ -0,0 +1,121 @@
1
+ import { type ParseError, parseError } from "../core/types.ts";
2
+
3
+ /** Appendix C of the plan: the one-task-per-implementer format a weaker model can follow. */
4
+ export type TaskRecord = {
5
+ readonly id: string;
6
+ readonly title: string;
7
+ readonly goal: string;
8
+ readonly files: readonly string[];
9
+ readonly interfaces: string;
10
+ readonly firstFailingTest: string;
11
+ readonly steps: readonly string[];
12
+ readonly run: string;
13
+ readonly expected: string;
14
+ readonly outOfScope: string;
15
+ };
16
+
17
+ const SECTIONS = [
18
+ "Goal",
19
+ "Files",
20
+ "Interfaces",
21
+ "First failing test",
22
+ "Steps",
23
+ "Run",
24
+ "Expected",
25
+ "Out of scope",
26
+ ] as const;
27
+ type Section = (typeof SECTIONS)[number];
28
+
29
+ const HEADER = /^## (\S+) [—-] (.+)$/;
30
+ const SECTION_LINE = /^\*\*([^:*]+):\*\*\s*(.*)$/;
31
+ const STEP = /^\s*(?:\d+[.)]|[-*])\s+(.*)$/;
32
+ const STEPS_MIN = 3;
33
+ const STEPS_MAX = 7;
34
+ /** Words that stand in for a command or an observable result. */
35
+ const PLACEHOLDER = /^(?:n\/?a|none|todo|tbc|-|…|\.\.\.|works?|it works|passes|ok)\.?$/i;
36
+
37
+ const isSection = (name: string): name is Section => SECTIONS.some((s) => s === name);
38
+
39
+ /** Splits the body into section texts keyed by name; text may continue over several lines. */
40
+ function splitSections(lines: readonly string[]): Map<Section, string> {
41
+ const found = new Map<Section, string[]>();
42
+ let current: string[] | undefined;
43
+ for (const line of lines) {
44
+ const match = SECTION_LINE.exec(line);
45
+ const name = match?.[1]?.trim();
46
+ if (match !== null && name !== undefined && isSection(name)) {
47
+ current = match[2] === "" ? [] : [match[2] ?? ""];
48
+ found.set(name, current);
49
+ } else current?.push(line);
50
+ }
51
+ return new Map([...found].map(([name, text]) => [name, text.join("\n").trim()]));
52
+ }
53
+
54
+ const stripTicks = (text: string): string => text.replace(/^`+|`+$/g, "").trim();
55
+
56
+ const stepsOf = (text: string): string[] =>
57
+ text.split("\n").flatMap((line) => {
58
+ const item = STEP.exec(line)?.[1]?.trim();
59
+ return item === undefined || item === "" ? [] : [item];
60
+ });
61
+
62
+ function concrete(name: "Run" | "Expected", text: string): string | undefined {
63
+ const bare = stripTicks(text);
64
+ if (PLACEHOLDER.test(bare))
65
+ return `${name} must be a concrete ${name === "Run" ? "command" : "observable result"}, not "${bare}"`;
66
+ if (name === "Expected" && bare.split(/\s+/).length < 2) {
67
+ return "Expected must say what is observed (at least two words, e.g. a count, output or exit status)";
68
+ }
69
+ return undefined;
70
+ }
71
+
72
+ const get = (sections: ReadonlyMap<Section, string>, s: Section): string => sections.get(s) ?? "";
73
+
74
+ /** Every readiness problem in the sections, so the author fixes them in one pass. */
75
+ function problemsOf(markdown: string, sections: ReadonlyMap<Section, string>): string[] {
76
+ const problems: string[] = [];
77
+ const missing = SECTIONS.filter((s) => get(sections, s) === "");
78
+ if (missing.length > 0) problems.push(`missing section: ${missing.join(", ")}`);
79
+ if (/\bTBD\b/.test(markdown)) problems.push("contains TBD; decide it or split the task");
80
+ const steps = stepsOf(get(sections, "Steps"));
81
+ if (!missing.includes("Steps") && (steps.length < STEPS_MIN || steps.length > STEPS_MAX)) {
82
+ problems.push(`Steps must be ${STEPS_MIN}-${STEPS_MAX} steps, found ${steps.length}`);
83
+ }
84
+ for (const name of ["Run", "Expected"] as const) {
85
+ const why = missing.includes(name) ? undefined : concrete(name, get(sections, name));
86
+ if (why !== undefined) problems.push(why);
87
+ }
88
+ return problems;
89
+ }
90
+
91
+ const filesOf = (text: string): string[] =>
92
+ text
93
+ .split(/[,\n]/)
94
+ .map((f) => stripTicks(f.trim()))
95
+ .filter((f) => f !== "");
96
+
97
+ /** Parses one task record, reporting every problem together. */
98
+ export function parseTaskRecord(markdown: string): TaskRecord | ParseError {
99
+ const lines = markdown.split("\n");
100
+ const headerAt = lines.findIndex((l) => l.startsWith("## "));
101
+ const header = headerAt < 0 ? null : HEADER.exec(lines[headerAt] ?? "");
102
+ if (header === null) {
103
+ return parseError("header must be `## <id> — <title>` (an id, an em dash, then the title)");
104
+ }
105
+ const sections = splitSections(lines.slice(headerAt + 1));
106
+ const problems = problemsOf(markdown, sections);
107
+ if (problems.length > 0) return parseError(problems.join("; "));
108
+ const text = (s: Section): string => get(sections, s);
109
+ return {
110
+ id: header[1] ?? "",
111
+ title: header[2]?.trim() ?? "",
112
+ goal: text("Goal"),
113
+ files: filesOf(text("Files")),
114
+ interfaces: text("Interfaces"),
115
+ firstFailingTest: text("First failing test"),
116
+ steps: stepsOf(text("Steps")),
117
+ run: stripTicks(text("Run")),
118
+ expected: text("Expected"),
119
+ outOfScope: text("Out of scope"),
120
+ };
121
+ }
@@ -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
+ }