@jwilger/pi-development-system 0.58.0 → 0.59.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.59.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,12 +28,13 @@ 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
 
@@ -1,5 +1,6 @@
1
1
  import type { Exec } from "../core/exec.ts";
2
2
  import { ok } from "../core/result.ts";
3
+ import { unwritable } from "./item-format.ts";
3
4
  import {
4
5
  type ItemFilter,
5
6
  type NewWorkItem,
@@ -94,27 +95,55 @@ const bindGh =
94
95
  return trackerError(`gh ${args.slice(0, 2).join(" ")} failed: ${why}`);
95
96
  };
96
97
 
97
- const view = async (gh: Gh, id: string): Promise<TrackerResult<WorkItem>> => {
98
+ const viewRaw = async (gh: Gh, id: string): Promise<TrackerResult<GhIssue>> => {
98
99
  if (!ISSUE_NUMBER.test(id)) return notNumber(id);
99
100
  const out = await gh(["issue", "view", id, "--json", FIELDS]);
100
101
  if (!out.ok) return out;
101
102
  const parsed = parseIssue(out.value);
102
- return parsed === undefined
103
- ? trackerError(`gh issue view ${id}: unexpected output`)
104
- : ok(toItem(parsed));
103
+ return parsed === undefined ? trackerError(`gh issue view ${id}: unexpected output`) : ok(parsed);
104
+ };
105
+
106
+ const view = async (gh: Gh, id: string): Promise<TrackerResult<WorkItem>> => {
107
+ const raw = await viewRaw(gh, id);
108
+ return raw.ok ? ok(toItem(raw.value)) : raw;
109
+ };
110
+
111
+ const LIST_LIMIT = 500;
112
+
113
+ /** The `gh issue list` arguments that select exactly the wanted status where gh can, so the limit bites last. */
114
+ const listArgs = (status: WorkStatus | undefined): string[] => {
115
+ const state = status === undefined ? "all" : STATE_ARG[status];
116
+ const marker = status === "in-progress" ? ["--label", IN_PROGRESS] : [];
117
+ return [
118
+ "issue",
119
+ "list",
120
+ "--state",
121
+ state,
122
+ ...marker,
123
+ "--limit",
124
+ String(LIST_LIMIT),
125
+ "--json",
126
+ FIELDS,
127
+ ];
105
128
  };
106
129
 
107
130
  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]);
131
+ const out = await gh(listArgs(filter.status));
110
132
  if (!out.ok) return out;
111
133
  const parsed = parseIssues(out.value);
112
134
  if (parsed === undefined) return trackerError("gh issue list: unexpected output");
135
+ if (parsed.length >= LIST_LIMIT) {
136
+ return trackerError(
137
+ `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`,
138
+ );
139
+ }
113
140
  const items = parsed.map(toItem);
114
141
  return ok(filter.status === undefined ? items : items.filter((i) => i.status === filter.status));
115
142
  };
116
143
 
117
144
  const createIssue = async (gh: Gh, input: NewWorkItem): Promise<TrackerResult<WorkItem>> => {
145
+ const problem = unwritable({ title: input.title, labels: input.labels ?? [] });
146
+ if (problem !== undefined) return trackerError(problem);
118
147
  const labels = (input.labels ?? []).flatMap((l) => ["--label", l]);
119
148
  const out = await gh([
120
149
  "issue",
@@ -132,10 +161,9 @@ const createIssue = async (gh: Gh, input: NewWorkItem): Promise<TrackerResult<Wo
132
161
  };
133
162
 
134
163
  /** 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[] => {
164
+ const labelFlags = (current: WorkItem, hadMarker: boolean, patch: WorkItemPatch): string[] => {
136
165
  const wanted = patch.labels ?? current.labels;
137
166
  const marked = (patch.status ?? current.status) === "in-progress";
138
- const hadMarker = current.status === "in-progress";
139
167
  const add = [
140
168
  ...wanted.filter((l) => !current.labels.includes(l)),
141
169
  ...(marked && !hadMarker ? [IN_PROGRESS] : []),
@@ -150,10 +178,12 @@ const labelFlags = (current: WorkItem, patch: WorkItemPatch): string[] => {
150
178
  ];
151
179
  };
152
180
 
153
- const editFlags = (current: WorkItem, patch: WorkItemPatch): string[] => [
181
+ const editFlags = (current: WorkItem, hadMarker: boolean, patch: WorkItemPatch): string[] => [
154
182
  ...(patch.title === undefined ? [] : ["--title", patch.title]),
155
183
  ...(patch.body === undefined ? [] : ["--body", patch.body]),
156
- ...(patch.labels === undefined && patch.status === undefined ? [] : labelFlags(current, patch)),
184
+ ...(patch.labels === undefined && patch.status === undefined
185
+ ? []
186
+ : labelFlags(current, hadMarker, patch)),
157
187
  ];
158
188
 
159
189
  /** Closing or reopening only when the issue is not already in the wanted state. */
@@ -173,19 +203,23 @@ const updateIssue = async (
173
203
  id: string,
174
204
  patch: WorkItemPatch,
175
205
  ): Promise<TrackerResult<WorkItem>> => {
176
- const current = await view(gh, id);
177
- if (!current.ok) return current;
206
+ const problem = unwritable({ title: patch.title ?? "x", labels: patch.labels ?? [] });
207
+ if (problem !== undefined) return trackerError(problem);
208
+ const raw = await viewRaw(gh, id);
209
+ if (!raw.ok) return raw;
210
+ const current = toItem(raw.value);
211
+ const hadMarker = (raw.value.labels ?? []).some((l) => l.name === IN_PROGRESS);
178
212
  if (patch.status === "in-progress") {
179
213
  // No --force: that would recolour the team's existing label. "Already exists" is success.
180
214
  const made = await gh(["label", "create", IN_PROGRESS]);
181
215
  if (!(made.ok || /already exists/i.test(made.error.message))) return made;
182
216
  }
183
- const flags = editFlags(current.value, patch);
217
+ const flags = editFlags(current, hadMarker, patch);
184
218
  if (flags.length > 0) {
185
219
  const edited = await gh(["issue", "edit", id, ...flags]);
186
220
  if (!edited.ok) return edited;
187
221
  }
188
- const command = stateCommand(current.value, patch.status);
222
+ const command = stateCommand(current, patch.status);
189
223
  if (command !== undefined) {
190
224
  const changed = await gh(["issue", command, id]);
191
225
  if (!changed.ok) return changed;
@@ -54,7 +54,8 @@ const commentsOf = (section: string): string[] =>
54
54
  .slice(1)
55
55
  .map((c) => unquote(c.replace(/\n+$/, "")));
56
56
 
57
- export const parseItem = (id: string, text: string): TrackerResult<WorkItem> => {
57
+ export const parseItem = (id: string, raw: string): TrackerResult<WorkItem> => {
58
+ const text = raw.replace(/\r\n?/g, "\n");
58
59
  const marker = text.lastIndexOf(COMMENTS);
59
60
  const main = marker < 0 ? text : text.slice(0, marker);
60
61
  const comments = marker < 0 ? [] : commentsOf(text.slice(marker + COMMENTS.length));