@jwilger/pi-development-system 0.55.0 → 0.57.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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.55.0",
3
+ "version": "0.57.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, each with an **Acceptance** list (observable checks that say the increment is done) and a final Release step (commit, push, CI green, publish, update) followed by a STOP for the user; for every task a task record whose header is `## <id> — <title>` and whose sections are written exactly as `**Goal:**`, `**Files:**`, `**Interfaces:**`, `**First failing test:**`, `**Steps:**`, `**Run:**`, `**Expected:**`, `**Out of scope:**` (label and colon inside the bold, one section per label); 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, one increment at a time, stopping after each increment's Release step". 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 the sections, each written as a bold label with the colon inside, `**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.
@@ -1,11 +1,11 @@
1
1
  import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
2
  import { type Static, Type } from "typebox";
3
3
  import { parseSizing } from "../core/sizing.ts";
4
- import { isParseError, type Sizing, type SliceRef } from "../core/types.ts";
4
+ import { type DevsysState, isParseError, type Sizing, type SliceRef } from "../core/types.ts";
5
5
  import type { Jev } from "../jev/client.ts";
6
6
  import { judgeSizing } from "../jev/questions/sizing.ts";
7
7
  import type { SessionState } from "../state/session-state.ts";
8
- import { phaseFor, proposeArtifacts, renderProposal, sliceSlug } from "./intake.ts";
8
+ import { phaseFor, proposeArtifacts, renderProposal, sliceSlug, uniqueSlice } from "./intake.ts";
9
9
 
10
10
  const Parameters = Type.Object({
11
11
  request: Type.String({ description: "What the user asked for, in their words." }),
@@ -28,6 +28,13 @@ const sizeChoices = (proposed: Sizing): string[] => [
28
28
  ...SIZES.filter((s) => s !== proposed),
29
29
  ];
30
30
 
31
+ const slicesInUse = (state: DevsysState): Set<string> =>
32
+ new Set([
33
+ ...(state.activeSlice === undefined ? [] : [state.activeSlice]),
34
+ ...(state.reviews ?? []).map((r) => r.slice),
35
+ ...state.openDepartures.flatMap((d) => (d.scope.kind === "slice" ? [d.scope.slice] : [])),
36
+ ]);
37
+
31
38
  /** `devsys_intake`: Jev proposes a size and artifact set; the user confirms; state moves to the first phase. */
32
39
  export function createIntakeTool(deps: {
33
40
  state: SessionState;
@@ -65,7 +72,7 @@ export function createIntakeTool(deps: {
65
72
  if (picked === undefined) return reply(`${proposal}\nIntake cancelled; nothing changed.`);
66
73
  const sizing = parseSizing(picked);
67
74
  if (isParseError(sizing)) return reply(sizing.message, true);
68
- const slice = sliceSlug(request) as SliceRef;
75
+ const slice = uniqueSlice(sliceSlug(request), slicesInUse(deps.state.get())) as SliceRef;
69
76
  deps.state.update((s) => ({
70
77
  ...s,
71
78
  phase: phaseFor(sizing),
@@ -5,6 +5,13 @@ const SLUG_MAX = 40;
5
5
  /** Jev need at or above this offers an artifact the sizing table did not list. */
6
6
  const CONSIDER_AT = 0.5;
7
7
 
8
+ /** `base`, or `base-2`, `base-3`… when earlier work already used the name, so a new slice never inherits old review or departure state. */
9
+ export const uniqueSlice = (base: string, taken: ReadonlySet<string>): string => {
10
+ let slice = base;
11
+ for (let n = 2; taken.has(slice); n++) slice = `${base}-${n}`;
12
+ return slice;
13
+ };
14
+
8
15
  export const sliceSlug = (request: string): string => {
9
16
  const slug = request
10
17
  .toLowerCase()
@@ -20,14 +27,26 @@ export type ArtifactProposal = {
20
27
  readonly consider: readonly ArtifactId[];
21
28
  };
22
29
 
30
+ /** Light and full forms of one artifact; offering several of a family at once is noise. */
31
+ const FAMILIES: readonly (readonly ArtifactId[])[] = [
32
+ ["brief-lite", "brief", "decision-register"],
33
+ ["lens-review-optional", "lens-review"],
34
+ ];
35
+
36
+ const familyOf = (id: ArtifactId): readonly ArtifactId[] =>
37
+ FAMILIES.find((f) => f.includes(id)) ?? [id];
38
+
23
39
  export const proposeArtifacts = (
24
40
  sizing: Sizing,
25
41
  artifactNeed: Readonly<Partial<Record<ArtifactId, number>>>,
26
42
  ): ArtifactProposal => {
27
43
  const recommended = recommendedArtifacts(sizing);
28
- const consider = ARTIFACTS.filter(
29
- (id) => !recommended.includes(id) && (artifactNeed[id] ?? 0) >= CONSIDER_AT,
30
- );
44
+ const consider = ARTIFACTS.filter((id) => {
45
+ const family = familyOf(id);
46
+ if (family.some((member) => recommended.includes(member))) return false;
47
+ const first = family.find((member) => (artifactNeed[member] ?? 0) >= CONSIDER_AT);
48
+ return first === id;
49
+ });
31
50
  return { recommended, consider };
32
51
  };
33
52
 
@@ -11,6 +11,11 @@ const Parameters = Type.Object({
11
11
  path: Type.String({
12
12
  description: "Path to the task record markdown file, inside the repository.",
13
13
  }),
14
+ id: Type.Optional(
15
+ Type.String({
16
+ description: "Which task record to check when the file (such as a plan) holds several.",
17
+ }),
18
+ ),
14
19
  });
15
20
 
16
21
  const reply = (text: string, isError = false) => ({
@@ -50,7 +55,7 @@ export function createTaskCheckTool(deps: {
50
55
  true,
51
56
  );
52
57
  }
53
- const record = parseTaskRecord(markdown);
58
+ const record = parseTaskRecord(markdown, params.id);
54
59
  if (isParseError(record))
55
60
  return reply(`${params.path} is not ready: ${record.message}`, true);
56
61
  const judged = await judgeTaskReadiness(deps.jev(ctx), record);
@@ -1,4 +1,4 @@
1
- import { type ParseError, parseError } from "../core/types.ts";
1
+ import { isParseError, type ParseError, parseError } from "../core/types.ts";
2
2
 
3
3
  /** Appendix C of the plan: the one-task-per-implementer format a weaker model can follow. */
4
4
  export type TaskRecord = {
@@ -27,7 +27,7 @@ const SECTIONS = [
27
27
  type Section = (typeof SECTIONS)[number];
28
28
 
29
29
  const HEADER = /^## (\S+) [—-] (.+)$/;
30
- const SECTION_LINE = /^\*\*([^:*]+):\*\*\s*(.*)$/;
30
+ const SECTION_LINE = /^\*\*([^:*]+)(?::\*\*|\*\*:)\s*(.*)$/;
31
31
  const STEP = /^\s*(?:\d+[.)]|[-*])\s+(.*)$/;
32
32
  const STEPS_MIN = 3;
33
33
  const STEPS_MAX = 7;
@@ -36,19 +36,43 @@ const PLACEHOLDER = /^(?:n\/?a|none|todo|tbc|-|…|\.\.\.|works?|it works|passes
36
36
 
37
37
  const isSection = (name: string): name is Section => SECTIONS.some((s) => s === name);
38
38
 
39
+ type Sections = { readonly text: Map<Section, string>; readonly duplicates: Section[] };
40
+
39
41
  /** Splits the body into section texts keyed by name; text may continue over several lines. */
40
- function splitSections(lines: readonly string[]): Map<Section, string> {
42
+ function splitSections(lines: readonly string[]): Sections {
41
43
  const found = new Map<Section, string[]>();
44
+ const duplicates: Section[] = [];
42
45
  let current: string[] | undefined;
43
46
  for (const line of lines) {
44
47
  const match = SECTION_LINE.exec(line);
45
48
  const name = match?.[1]?.trim();
46
49
  if (match !== null && name !== undefined && isSection(name)) {
50
+ if (found.has(name)) duplicates.push(name);
47
51
  current = match[2] === "" ? [] : [match[2] ?? ""];
48
52
  found.set(name, current);
49
53
  } else current?.push(line);
50
54
  }
51
- return new Map([...found].map(([name, text]) => [name, text.join("\n").trim()]));
55
+ const text = new Map([...found].map(([name, body]) => [name, body.join("\n").trim()] as const));
56
+ return { text, duplicates };
57
+ }
58
+
59
+ type Located = { readonly id: string; readonly title: string; readonly lines: readonly string[] };
60
+
61
+ /** Every `## <id> — <title>` block; any other `## ` heading ends the block before it. */
62
+ function recordsIn(lines: readonly string[]): Located[] {
63
+ const records: { id: string; title: string; lines: string[] }[] = [];
64
+ let open: { id: string; title: string; lines: string[] } | undefined;
65
+ for (const line of lines) {
66
+ if (line.startsWith("## ")) {
67
+ const header = HEADER.exec(line);
68
+ open =
69
+ header === null
70
+ ? undefined
71
+ : { id: header[1] ?? "", title: header[2]?.trim() ?? "", lines: [] };
72
+ if (open !== undefined) records.push(open);
73
+ } else open?.lines.push(line);
74
+ }
75
+ return records;
52
76
  }
53
77
 
54
78
  const stripTicks = (text: string): string => text.replace(/^`+|`+$/g, "").trim();
@@ -72,8 +96,12 @@ function concrete(name: "Run" | "Expected", text: string): string | undefined {
72
96
  const get = (sections: ReadonlyMap<Section, string>, s: Section): string => sections.get(s) ?? "";
73
97
 
74
98
  /** Every readiness problem in the sections, so the author fixes them in one pass. */
75
- function problemsOf(markdown: string, sections: ReadonlyMap<Section, string>): string[] {
99
+ function problemsOf(markdown: string, split: Sections): string[] {
100
+ const sections = split.text;
76
101
  const problems: string[] = [];
102
+ if (split.duplicates.length > 0) {
103
+ problems.push(`duplicate section: ${[...new Set(split.duplicates)].join(", ")}`);
104
+ }
77
105
  const missing = SECTIONS.filter((s) => get(sections, s) === "");
78
106
  if (missing.length > 0) problems.push(`missing section: ${missing.join(", ")}`);
79
107
  if (/\bTBD\b/.test(markdown)) problems.push("contains TBD; decide it or split the task");
@@ -94,21 +122,35 @@ const filesOf = (text: string): string[] =>
94
122
  .map((f) => stripTicks(f.trim()))
95
123
  .filter((f) => f !== "");
96
124
 
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)");
125
+ const NO_HEADER = "header must be `## <id> — <title>` (an id, an em dash, then the title)";
126
+
127
+ function locate(markdown: string, id: string | undefined): Located | ParseError {
128
+ const records = recordsIn(markdown.split("\n"));
129
+ const names = records.map((r) => r.id).join(", ");
130
+ if (id !== undefined) {
131
+ return (
132
+ records.find((r) => r.id === id) ??
133
+ parseError(`no task record "${id}" (found: ${names || "none"})`)
134
+ );
104
135
  }
105
- const sections = splitSections(lines.slice(headerAt + 1));
106
- const problems = problemsOf(markdown, sections);
136
+ const [only, ...rest] = records;
137
+ if (only === undefined) return parseError(NO_HEADER);
138
+ return rest.length === 0
139
+ ? only
140
+ : parseError(`the file holds ${records.length} task records (${names}); name the one to check`);
141
+ }
142
+
143
+ /** Parses one task record (the only one in the file, or the one named by `id`), reporting every problem together. */
144
+ export function parseTaskRecord(markdown: string, id?: string): TaskRecord | ParseError {
145
+ const found = locate(markdown, id);
146
+ if (isParseError(found)) return found;
147
+ const split = splitSections(found.lines);
148
+ const problems = problemsOf(found.lines.join("\n"), split);
107
149
  if (problems.length > 0) return parseError(problems.join("; "));
108
- const text = (s: Section): string => get(sections, s);
150
+ const text = (s: Section): string => get(split.text, s);
109
151
  return {
110
- id: header[1] ?? "",
111
- title: header[2]?.trim() ?? "",
152
+ id: found.id,
153
+ title: found.title,
112
154
  goal: text("Goal"),
113
155
  files: filesOf(text("Files")),
114
156
  interfaces: text("Interfaces"),
@@ -0,0 +1,211 @@
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
+ /** Label changes that make the issue's labels exactly `wanted` (plus the in-progress marker when asked for). */
135
+ const labelFlags = (current: WorkItem, patch: WorkItemPatch): string[] => {
136
+ const wanted = patch.labels ?? current.labels;
137
+ const marked = (patch.status ?? current.status) === "in-progress";
138
+ const add = [
139
+ ...wanted.filter((l) => !current.labels.includes(l)),
140
+ ...(marked ? [IN_PROGRESS] : []),
141
+ ];
142
+ const remove = [
143
+ ...current.labels.filter((l) => !wanted.includes(l)),
144
+ ...(marked ? [] : [IN_PROGRESS]),
145
+ ];
146
+ return [
147
+ ...add.flatMap((l) => ["--add-label", l]),
148
+ ...remove.flatMap((l) => ["--remove-label", l]),
149
+ ];
150
+ };
151
+
152
+ const editFlags = (current: WorkItem, patch: WorkItemPatch): string[] => [
153
+ ...(patch.title === undefined ? [] : ["--title", patch.title]),
154
+ ...(patch.body === undefined ? [] : ["--body", patch.body]),
155
+ ...(patch.labels === undefined && patch.status === undefined ? [] : labelFlags(current, patch)),
156
+ ];
157
+
158
+ /** Closing or reopening only when the issue is not already in the wanted state. */
159
+ const stateCommand = (current: WorkItem, status: WorkStatus | undefined): string | undefined => {
160
+ if (status === undefined) return undefined;
161
+ if (status === "done") return current.status === "done" ? undefined : "close";
162
+ return current.status === "done" ? "reopen" : undefined;
163
+ };
164
+
165
+ /**
166
+ * Labels are replaced, as in the repo-files tracker. Order: make sure the marker label exists, apply every
167
+ * edit in one `gh issue edit`, then close or reopen last, so a failure before the state change leaves the
168
+ * issue's open/closed state alone.
169
+ */
170
+ const updateIssue = async (
171
+ gh: Gh,
172
+ id: string,
173
+ patch: WorkItemPatch,
174
+ ): Promise<TrackerResult<WorkItem>> => {
175
+ const current = await view(gh, id);
176
+ if (!current.ok) return current;
177
+ if (patch.status === "in-progress") {
178
+ const made = await gh(["label", "create", IN_PROGRESS, "--force"]);
179
+ if (!made.ok) return made;
180
+ }
181
+ const flags = editFlags(current.value, patch);
182
+ if (flags.length > 0) {
183
+ const edited = await gh(["issue", "edit", id, ...flags]);
184
+ if (!edited.ok) return edited;
185
+ }
186
+ const command = stateCommand(current.value, patch.status);
187
+ if (command !== undefined) {
188
+ const changed = await gh(["issue", command, id]);
189
+ if (!changed.ok) return changed;
190
+ }
191
+ return view(gh, id);
192
+ };
193
+
194
+ const commentIssue = async (gh: Gh, id: string, body: string): Promise<TrackerResult<void>> => {
195
+ if (!ISSUE_NUMBER.test(id)) return notNumber(id);
196
+ const out = await gh(["issue", "comment", id, "--body", body]);
197
+ return out.ok ? ok(undefined) : out;
198
+ };
199
+
200
+ /** Work items as GitHub issues, through the `gh` CLI. Every value is its own argument, never shell text. */
201
+ export function createGithubTracker(deps: GithubTrackerDeps): Tracker {
202
+ const gh = bindGh(deps);
203
+ return {
204
+ kind: "github",
205
+ list: (filter) => listIssues(gh, filter),
206
+ get: (id) => view(gh, id),
207
+ create: (input) => createIssue(gh, input),
208
+ update: (id, patch) => updateIssue(gh, id, patch),
209
+ comment: (id, body) => commentIssue(gh, id, body),
210
+ };
211
+ }
@@ -0,0 +1,90 @@
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
+ /** Comment lines are quoted so nothing a comment says can look like one of our headings. */
13
+ const quote = (text: string): string =>
14
+ text
15
+ .split("\n")
16
+ .map((line) => (line === "" ? ">" : `> ${line}`))
17
+ .join("\n");
18
+
19
+ const unquote = (text: string): string =>
20
+ text
21
+ .split("\n")
22
+ .map((line) => line.replace(/^> ?/, ""))
23
+ .join("\n");
24
+
25
+ /** What the file format cannot hold, or undefined when the item can be written and read back. */
26
+ export const unwritable = (item: Pick<WorkItem, "title" | "labels">): string | undefined => {
27
+ if (/[\r\n]/.test(item.title)) return "a work item title must be a single line";
28
+ if (item.title.trim() === "") return "a work item title cannot be blank";
29
+ const bad = item.labels.find((l) => /[\r\n,]/.test(l) || l.trim() === "");
30
+ return bad === undefined
31
+ ? undefined
32
+ : `label ${JSON.stringify(bad)} is blank or holds a comma or line break`;
33
+ };
34
+
35
+ export const renderItem = (item: WorkItem): string => {
36
+ const comments = item.comments.map((c) => `\n### Comment\n\n${quote(c)}\n`).join("");
37
+ return [
38
+ `# ${item.title}`,
39
+ "",
40
+ `Status: ${item.status}`,
41
+ `Labels: ${item.labels.join(", ")}`,
42
+ "",
43
+ item.body,
44
+ COMMENTS + comments,
45
+ ].join("\n");
46
+ };
47
+
48
+ const isStatus = (value: string): value is WorkStatus =>
49
+ (WORK_STATUSES as readonly string[]).includes(value);
50
+
51
+ const commentsOf = (section: string): string[] =>
52
+ section
53
+ .split(/^### Comment\n\n/m)
54
+ .slice(1)
55
+ .map((c) => unquote(c.replace(/\n+$/, "")));
56
+
57
+ export const parseItem = (id: string, text: string): TrackerResult<WorkItem> => {
58
+ const marker = text.lastIndexOf(COMMENTS);
59
+ const main = marker < 0 ? text : text.slice(0, marker);
60
+ const comments = marker < 0 ? [] : commentsOf(text.slice(marker + COMMENTS.length));
61
+ const head = /^# (.+)\n\nStatus: (.*)\nLabels: (.*)\n\n?/.exec(main);
62
+ if (head === null)
63
+ return trackerError(`work item ${id}: expected "# title", Status and Labels lines`);
64
+ const [whole, title = "", status = "", labels = ""] = head;
65
+ if (!isStatus(status)) {
66
+ return trackerError(
67
+ `work item ${id}: unknown status "${status}" (use ${WORK_STATUSES.join(", ")})`,
68
+ );
69
+ }
70
+ return ok({
71
+ id,
72
+ title,
73
+ body: main.slice(whole.length).replace(/\n$/, ""),
74
+ status,
75
+ labels: labels === "" ? [] : labels.split(", "),
76
+ comments,
77
+ });
78
+ };
79
+
80
+ const RANK: Record<WorkStatus, number> = { "in-progress": 1, open: 0, done: 2 };
81
+
82
+ /** Derived from the item files on every change, so it cannot drift from them. */
83
+ export const renderBacklog = (items: readonly WorkItem[]): string => {
84
+ const ordered = items.toSorted((a, b) => RANK[a.status] - RANK[b.status]);
85
+ const lines = ordered.map((i) => {
86
+ const note = i.status === "in-progress" ? " (in-progress)" : "";
87
+ return `- [${i.status === "done" ? "x" : " "}] ${i.id} — ${i.title}${note}`;
88
+ });
89
+ return ["# Backlog", "", ...lines, ""].join("\n");
90
+ };
@@ -0,0 +1,137 @@
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, unwritable } 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
+ const problem = unwritable(item);
68
+ if (problem !== undefined) return trackerError(problem);
69
+ await mkdir(itemsDir(root), { recursive: true });
70
+ await writeFile(fileOf(root, item.id), renderItem(item));
71
+ const items = await readAll(root);
72
+ if (!items.ok) return items;
73
+ await writeFile(join(root, "work", "backlog.md"), renderBacklog(items.value));
74
+ return ok(item);
75
+ };
76
+
77
+ const freshId = async (root: string, title: string): Promise<string | undefined> => {
78
+ const base = slugOf(title);
79
+ if (base === "") return undefined;
80
+ const taken = new Set(await itemIds(root));
81
+ let id = base;
82
+ for (let n = 2; taken.has(id); n++) id = `${base}-${n}`;
83
+ return id;
84
+ };
85
+
86
+ const listItems = async (root: string, filter: ItemFilter): Promise<TrackerResult<WorkItem[]>> => {
87
+ const items = await readAll(root);
88
+ if (!items.ok || filter.status === undefined) return items;
89
+ return ok(items.value.filter((i) => i.status === filter.status));
90
+ };
91
+
92
+ const createItem = async (root: string, input: NewWorkItem): Promise<TrackerResult<WorkItem>> => {
93
+ const problem = unwritable({ title: input.title, labels: input.labels ?? [] });
94
+ if (problem !== undefined) return trackerError(problem);
95
+ const id = await freshId(root, input.title);
96
+ if (id === undefined) return trackerError("a work item title needs letters or digits");
97
+ return save(root, {
98
+ id,
99
+ title: input.title,
100
+ body: input.body ?? "",
101
+ status: "open",
102
+ labels: input.labels ?? [],
103
+ comments: [],
104
+ });
105
+ };
106
+
107
+ const updateItem = async (
108
+ root: string,
109
+ id: string,
110
+ patch: WorkItemPatch,
111
+ ): Promise<TrackerResult<WorkItem>> => {
112
+ const current = await readItem(root, id);
113
+ return current.ok ? save(root, { ...current.value, ...patch }) : current;
114
+ };
115
+
116
+ const commentItem = async (
117
+ root: string,
118
+ id: string,
119
+ body: string,
120
+ ): Promise<TrackerResult<void>> => {
121
+ const current = await readItem(root, id);
122
+ if (!current.ok) return current;
123
+ const saved = await save(root, { ...current.value, comments: [...current.value.comments, body] });
124
+ return saved.ok ? ok(undefined) : saved;
125
+ };
126
+
127
+ /** Work items as markdown files in the repo: `work/items/<id>.md`, with a derived `work/backlog.md`. */
128
+ export function createRepoFilesTracker(root: string): Tracker {
129
+ return {
130
+ kind: "repo-files",
131
+ list: (filter) => listItems(root, filter),
132
+ get: (id) => readItem(root, id),
133
+ create: (input) => createItem(root, input),
134
+ update: (id, patch) => updateItem(root, id, patch),
135
+ comment: (id, body) => commentItem(root, id, body),
136
+ };
137
+ }
@@ -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
+ }