@jwilger/pi-development-system 0.58.0 → 0.60.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.58.0",
3
+ "version": "0.60.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"
@@ -27,6 +27,9 @@ export const READINESS_QUESTIONS = {
27
27
  steps: vague(
28
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
29
  ),
30
+ check: vague(
31
+ "`task.run` should be an actual command a person can type, and `task.expected` an observable result such as a count, output or exit status. Is `run` prose or a placeholder rather than a command (for example 'run the tests'), or is `expected` a restatement of success (for example 'tests pass', 'should work') rather than something observed?",
32
+ ),
30
33
  tooBig: {
31
34
  type: "bool",
32
35
  instructions:
@@ -60,6 +63,8 @@ export async function judgeTaskReadiness(
60
63
  interfaces: clip(record.interfaces, 1500),
61
64
  firstFailingTest: clip(record.firstFailingTest, 600),
62
65
  steps: record.steps.slice(0, 10).map((s) => clip(s, 300)),
66
+ run: clip(record.run, 300),
67
+ expected: clip(record.expected, 300),
63
68
  },
64
69
  },
65
70
  READINESS_QUESTIONS,
@@ -28,15 +28,29 @@ type Section = (typeof SECTIONS)[number];
28
28
 
29
29
  const HEADER = /^## (\S+) [—-] (.+)$/;
30
30
  const SECTION_LINE = /^\*\*([^:*]+)(?::\*\*|\*\*:)\s*(.*)$/;
31
- const STEP = /^\s*(?:\d+[.)]|[-*])\s+(.*)$/;
31
+ /** Top-level list items only; indented sub-items belong to the step above them. */
32
+ const STEP = /^(?:\d+[.)]|[-*])\s+(.*)$/;
32
33
  const STEPS_MIN = 3;
33
34
  const STEPS_MAX = 7;
34
35
  /** Words that stand in for a command or an observable result. */
35
36
  const PLACEHOLDER =
36
- /^(?:(?:todo|tbd|tbc)\b.*|n\/?a|none|-|…|\.\.\.|works?|it works|passes|ok)\.?$/i;
37
+ /^(?:(?:tbd|tbc)\b.*|todo\s*(?::.*)?|n\/?a|none|-|…|\.\.\.|works?|it works|passes|ok)\.?$/i;
37
38
 
38
39
  const isSection = (name: string): name is Section => SECTIONS.some((s) => s === name);
39
40
 
41
+ const FENCE = /^\s*(?:```|~~~)/;
42
+ const HEADING = /^#{1,6}\s/;
43
+
44
+ /** For each line, whether it sits inside (or on the edge of) a fenced code block. */
45
+ const fencedFlags = (lines: readonly string[]): boolean[] => {
46
+ let open = false;
47
+ return lines.map((line) => {
48
+ if (!FENCE.test(line)) return open;
49
+ open = !open;
50
+ return true;
51
+ });
52
+ };
53
+
40
54
  type Sections = { readonly text: Map<Section, string>; readonly duplicates: Section[] };
41
55
 
42
56
  /** Splits the body into section texts keyed by name; text may continue over several lines. */
@@ -44,8 +58,9 @@ function splitSections(lines: readonly string[]): Sections {
44
58
  const found = new Map<Section, string[]>();
45
59
  const duplicates: Section[] = [];
46
60
  let current: string[] | undefined;
47
- for (const line of lines) {
48
- const match = SECTION_LINE.exec(line);
61
+ const fenced = fencedFlags(lines);
62
+ for (const [at, line] of lines.entries()) {
63
+ const match = fenced[at] === true ? null : SECTION_LINE.exec(line);
49
64
  const name = match?.[1]?.trim();
50
65
  if (match !== null && name !== undefined && isSection(name)) {
51
66
  if (found.has(name)) duplicates.push(name);
@@ -59,12 +74,13 @@ function splitSections(lines: readonly string[]): Sections {
59
74
 
60
75
  type Located = { readonly id: string; readonly title: string; readonly lines: readonly string[] };
61
76
 
62
- /** Every `## <id> — <title>` block; any other `## ` heading ends the block before it. */
77
+ /** Every `## <id> — <title>` block; any other heading (outside code fences) ends the block before it. */
63
78
  function recordsIn(lines: readonly string[]): Located[] {
64
79
  const records: { id: string; title: string; lines: string[] }[] = [];
65
80
  let open: { id: string; title: string; lines: string[] } | undefined;
66
- for (const line of lines) {
67
- if (line.startsWith("## ")) {
81
+ const fenced = fencedFlags(lines);
82
+ for (const [at, line] of lines.entries()) {
83
+ if (fenced[at] !== true && HEADING.test(line)) {
68
84
  const header = HEADER.exec(line);
69
85
  open =
70
86
  header === null
@@ -76,13 +92,25 @@ function recordsIn(lines: readonly string[]): Located[] {
76
92
  return records;
77
93
  }
78
94
 
79
- const stripTicks = (text: string): string => text.replace(/^`+|`+$/g, "").trim();
95
+ /** A command or result without its backticks, or the body of a fenced block (its info string dropped). */
96
+ const stripTicks = (text: string): string => {
97
+ const lines = text.split("\n");
98
+ if (FENCE.test(lines[0] ?? "")) {
99
+ const body = lines.slice(1);
100
+ if (FENCE.test(body.at(-1) ?? "")) body.pop();
101
+ return body.join("\n").trim();
102
+ }
103
+ return text.replace(/^`+|`+$/g, "").trim();
104
+ };
80
105
 
81
- const stepsOf = (text: string): string[] =>
82
- text.split("\n").flatMap((line) => {
83
- const item = STEP.exec(line)?.[1]?.trim();
106
+ const stepsOf = (text: string): string[] => {
107
+ const lines = text.split("\n");
108
+ const fenced = fencedFlags(lines);
109
+ return lines.flatMap((line, at) => {
110
+ const item = fenced[at] === true ? undefined : STEP.exec(line)?.[1]?.trim();
84
111
  return item === undefined || item === "" ? [] : [item];
85
112
  });
113
+ };
86
114
 
87
115
  function concrete(name: "Run" | "Expected", text: string): string | undefined {
88
116
  const bare = stripTicks(text);
@@ -1,5 +1,13 @@
1
1
  import type { Exec } from "../core/exec.ts";
2
2
  import { ok } from "../core/result.ts";
3
+ import { unwritable } from "./item-format.ts";
4
+
5
+ /** Our status marker is a label; a user label with that name would change the status as a side effect. */
6
+ const unusable = (item: { title: string; labels: readonly string[] }): string | undefined =>
7
+ item.labels.includes("in-progress")
8
+ ? '"in-progress" is reserved for the in-progress status; set status instead'
9
+ : unwritable(item);
10
+
3
11
  import {
4
12
  type ItemFilter,
5
13
  type NewWorkItem,
@@ -94,27 +102,55 @@ const bindGh =
94
102
  return trackerError(`gh ${args.slice(0, 2).join(" ")} failed: ${why}`);
95
103
  };
96
104
 
97
- const view = async (gh: Gh, id: string): Promise<TrackerResult<WorkItem>> => {
105
+ const viewRaw = async (gh: Gh, id: string): Promise<TrackerResult<GhIssue>> => {
98
106
  if (!ISSUE_NUMBER.test(id)) return notNumber(id);
99
107
  const out = await gh(["issue", "view", id, "--json", FIELDS]);
100
108
  if (!out.ok) return out;
101
109
  const parsed = parseIssue(out.value);
102
- return parsed === undefined
103
- ? trackerError(`gh issue view ${id}: unexpected output`)
104
- : ok(toItem(parsed));
110
+ return parsed === undefined ? trackerError(`gh issue view ${id}: unexpected output`) : ok(parsed);
111
+ };
112
+
113
+ const view = async (gh: Gh, id: string): Promise<TrackerResult<WorkItem>> => {
114
+ const raw = await viewRaw(gh, id);
115
+ return raw.ok ? ok(toItem(raw.value)) : raw;
116
+ };
117
+
118
+ const LIST_LIMIT = 500;
119
+
120
+ /** The `gh issue list` arguments that select exactly the wanted status where gh can, so the limit bites last. */
121
+ const listArgs = (status: WorkStatus | undefined): string[] => {
122
+ const state = status === undefined ? "all" : STATE_ARG[status];
123
+ const marker = status === "in-progress" ? ["--label", IN_PROGRESS] : [];
124
+ return [
125
+ "issue",
126
+ "list",
127
+ "--state",
128
+ state,
129
+ ...marker,
130
+ "--limit",
131
+ String(LIST_LIMIT),
132
+ "--json",
133
+ FIELDS,
134
+ ];
105
135
  };
106
136
 
107
137
  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]);
138
+ const out = await gh(listArgs(filter.status));
110
139
  if (!out.ok) return out;
111
140
  const parsed = parseIssues(out.value);
112
141
  if (parsed === undefined) return trackerError("gh issue list: unexpected output");
142
+ if (parsed.length >= LIST_LIMIT) {
143
+ return trackerError(
144
+ `gh issue list returned ${LIST_LIMIT} issues, so the list may be cut short; ask for one status (open, in-progress or done) to narrow it`,
145
+ );
146
+ }
113
147
  const items = parsed.map(toItem);
114
148
  return ok(filter.status === undefined ? items : items.filter((i) => i.status === filter.status));
115
149
  };
116
150
 
117
151
  const createIssue = async (gh: Gh, input: NewWorkItem): Promise<TrackerResult<WorkItem>> => {
152
+ const problem = unusable({ title: input.title, labels: input.labels ?? [] });
153
+ if (problem !== undefined) return trackerError(problem);
118
154
  const labels = (input.labels ?? []).flatMap((l) => ["--label", l]);
119
155
  const out = await gh([
120
156
  "issue",
@@ -132,10 +168,9 @@ const createIssue = async (gh: Gh, input: NewWorkItem): Promise<TrackerResult<Wo
132
168
  };
133
169
 
134
170
  /** 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[] => {
171
+ const labelFlags = (current: WorkItem, hadMarker: boolean, patch: WorkItemPatch): string[] => {
136
172
  const wanted = patch.labels ?? current.labels;
137
173
  const marked = (patch.status ?? current.status) === "in-progress";
138
- const hadMarker = current.status === "in-progress";
139
174
  const add = [
140
175
  ...wanted.filter((l) => !current.labels.includes(l)),
141
176
  ...(marked && !hadMarker ? [IN_PROGRESS] : []),
@@ -150,10 +185,12 @@ const labelFlags = (current: WorkItem, patch: WorkItemPatch): string[] => {
150
185
  ];
151
186
  };
152
187
 
153
- const editFlags = (current: WorkItem, patch: WorkItemPatch): string[] => [
188
+ const editFlags = (current: WorkItem, hadMarker: boolean, patch: WorkItemPatch): string[] => [
154
189
  ...(patch.title === undefined ? [] : ["--title", patch.title]),
155
190
  ...(patch.body === undefined ? [] : ["--body", patch.body]),
156
- ...(patch.labels === undefined && patch.status === undefined ? [] : labelFlags(current, patch)),
191
+ ...(patch.labels === undefined && patch.status === undefined
192
+ ? []
193
+ : labelFlags(current, hadMarker, patch)),
157
194
  ];
158
195
 
159
196
  /** Closing or reopening only when the issue is not already in the wanted state. */
@@ -173,19 +210,23 @@ const updateIssue = async (
173
210
  id: string,
174
211
  patch: WorkItemPatch,
175
212
  ): Promise<TrackerResult<WorkItem>> => {
176
- const current = await view(gh, id);
177
- if (!current.ok) return current;
213
+ const problem = unusable({ title: patch.title ?? "x", labels: patch.labels ?? [] });
214
+ if (problem !== undefined) return trackerError(problem);
215
+ const raw = await viewRaw(gh, id);
216
+ if (!raw.ok) return raw;
217
+ const current = toItem(raw.value);
218
+ const hadMarker = (raw.value.labels ?? []).some((l) => l.name === IN_PROGRESS);
178
219
  if (patch.status === "in-progress") {
179
220
  // No --force: that would recolour the team's existing label. "Already exists" is success.
180
221
  const made = await gh(["label", "create", IN_PROGRESS]);
181
222
  if (!(made.ok || /already exists/i.test(made.error.message))) return made;
182
223
  }
183
- const flags = editFlags(current.value, patch);
224
+ const flags = editFlags(current, hadMarker, patch);
184
225
  if (flags.length > 0) {
185
226
  const edited = await gh(["issue", "edit", id, ...flags]);
186
227
  if (!edited.ok) return edited;
187
228
  }
188
- const command = stateCommand(current.value, patch.status);
229
+ const command = stateCommand(current, patch.status);
189
230
  if (command !== undefined) {
190
231
  const changed = await gh(["issue", command, id]);
191
232
  if (!changed.ok) return changed;
@@ -32,17 +32,13 @@ export const unwritable = (item: Pick<WorkItem, "title" | "labels">): string | u
32
32
  : `label ${JSON.stringify(bad)} is blank or holds a comma or line break`;
33
33
  };
34
34
 
35
+ /** Written to pass our own markdownlint: no trailing space, no repeated heading text, no stacked blank lines. */
35
36
  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");
37
+ const labels = item.labels.length === 0 ? "" : ` ${item.labels.join(", ")}`;
38
+ const top = `# ${item.title}\n\nStatus: ${item.status}\nLabels:${labels}`;
39
+ const body = item.body === "" ? [] : [item.body];
40
+ const comments = item.comments.map((c, n) => `\n### Comment ${n + 1}\n\n${quote(c)}\n`);
41
+ return `${[top, ...body, "## Comments"].join("\n\n")}\n${comments.join("")}`;
46
42
  };
47
43
 
48
44
  const isStatus = (value: string): value is WorkStatus =>
@@ -50,15 +46,16 @@ const isStatus = (value: string): value is WorkStatus =>
50
46
 
51
47
  const commentsOf = (section: string): string[] =>
52
48
  section
53
- .split(/^### Comment\n\n/m)
49
+ .split(/^### Comment(?: \d+)?\n\n/m)
54
50
  .slice(1)
55
51
  .map((c) => unquote(c.replace(/\n+$/, "")));
56
52
 
57
- export const parseItem = (id: string, text: string): TrackerResult<WorkItem> => {
53
+ export const parseItem = (id: string, raw: string): TrackerResult<WorkItem> => {
54
+ const text = raw.replace(/\r\n?/g, "\n");
58
55
  const marker = text.lastIndexOf(COMMENTS);
59
56
  const main = marker < 0 ? text : text.slice(0, marker);
60
57
  const comments = marker < 0 ? [] : commentsOf(text.slice(marker + COMMENTS.length));
61
- const head = /^# (.+)\n\nStatus: (.*)\nLabels: (.*)\n\n?/.exec(main);
58
+ const head = /^# (.+)\n\nStatus: (.*)\nLabels:[ ]?(.*)(?:\n\n|\n$|$)/.exec(main);
62
59
  if (head === null)
63
60
  return trackerError(`work item ${id}: expected "# title", Status and Labels lines`);
64
61
  const [whole, title = "", status = "", labels = ""] = head;