@jwilger/pi-development-system 0.56.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.56.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"
@@ -5,8 +5,8 @@ argument-hint: "<slug> [what the plan covers]"
5
5
  Write the plan for the current work with the work-intake-and-slicing skill.
6
6
 
7
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.
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
9
  3. Write each task so that someone who sees only that task could finish it. Never write `TBD`.
10
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.
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
12
  6. Stop and ask the user to review the plan before any implementation starts.
@@ -39,7 +39,7 @@ If the plan spells out every line, it has become the implementation, and a revie
39
39
 
40
40
  ## Task records
41
41
 
42
- Format: `## <id> — <title>` then **Goal**, **Files**, **Interfaces**, **First failing test**, **Steps** (3 to 7, each reviewable alone), **Run**, **Expected**, **Out of scope**.
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
43
 
44
44
  - Goal is one observable sentence.
45
45
  - Run is a command and Expected is its concrete result.
@@ -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"),
@@ -131,37 +131,61 @@ const createIssue = async (gh: Gh, input: NewWorkItem): Promise<TrackerResult<Wo
131
131
  return view(gh, number);
132
132
  };
133
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]);
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
+ ];
144
150
  };
145
151
 
146
- const editArgs = (patch: WorkItemPatch): string[] => [
152
+ const editFlags = (current: WorkItem, patch: WorkItemPatch): string[] => [
147
153
  ...(patch.title === undefined ? [] : ["--title", patch.title]),
148
154
  ...(patch.body === undefined ? [] : ["--body", patch.body]),
149
- ...(patch.labels ?? []).flatMap((label) => ["--add-label", label]),
155
+ ...(patch.labels === undefined && patch.status === undefined ? [] : labelFlags(current, patch)),
150
156
  ];
151
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
+ */
152
170
  const updateIssue = async (
153
171
  gh: Gh,
154
172
  id: string,
155
173
  patch: WorkItemPatch,
156
174
  ): 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]);
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]);
161
184
  if (!edited.ok) return edited;
162
185
  }
163
- if (patch.status !== undefined) {
164
- const changed = await applyStatus(gh, id, patch.status);
186
+ const command = stateCommand(current.value, patch.status);
187
+ if (command !== undefined) {
188
+ const changed = await gh(["issue", command, id]);
165
189
  if (!changed.ok) return changed;
166
190
  }
167
191
  return view(gh, id);
@@ -9,8 +9,31 @@ import {
9
9
 
10
10
  const COMMENTS = "\n## Comments\n";
11
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
+
12
35
  export const renderItem = (item: WorkItem): string => {
13
- const comments = item.comments.map((c) => `\n### Comment\n\n${c}\n`).join("");
36
+ const comments = item.comments.map((c) => `\n### Comment\n\n${quote(c)}\n`).join("");
14
37
  return [
15
38
  `# ${item.title}`,
16
39
  "",
@@ -29,7 +52,7 @@ const commentsOf = (section: string): string[] =>
29
52
  section
30
53
  .split(/^### Comment\n\n/m)
31
54
  .slice(1)
32
- .map((c) => c.replace(/\n$/, "").replace(/\n$/, ""));
55
+ .map((c) => unquote(c.replace(/\n+$/, "")));
33
56
 
34
57
  export const parseItem = (id: string, text: string): TrackerResult<WorkItem> => {
35
58
  const marker = text.lastIndexOf(COMMENTS);
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { ok } from "../core/result.ts";
4
- import { parseItem, renderBacklog, renderItem } from "./item-format.ts";
4
+ import { parseItem, renderBacklog, renderItem, unwritable } from "./item-format.ts";
5
5
  import {
6
6
  type ItemFilter,
7
7
  type NewWorkItem,
@@ -64,6 +64,8 @@ const readAll = async (root: string): Promise<TrackerResult<WorkItem[]>> => {
64
64
  };
65
65
 
66
66
  const save = async (root: string, item: WorkItem): Promise<TrackerResult<WorkItem>> => {
67
+ const problem = unwritable(item);
68
+ if (problem !== undefined) return trackerError(problem);
67
69
  await mkdir(itemsDir(root), { recursive: true });
68
70
  await writeFile(fileOf(root, item.id), renderItem(item));
69
71
  const items = await readAll(root);
@@ -88,6 +90,8 @@ const listItems = async (root: string, filter: ItemFilter): Promise<TrackerResul
88
90
  };
89
91
 
90
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);
91
95
  const id = await freshId(root, input.title);
92
96
  if (id === undefined) return trackerError("a work item title needs letters or digits");
93
97
  return save(root, {