@jwilger/pi-development-system 0.57.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.57.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,11 +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
- const PLACEHOLDER = /^(?:n\/?a|none|todo|tbc|-|…|\.\.\.|works?|it works|passes|ok)\.?$/i;
36
+ const PLACEHOLDER =
37
+ /^(?:(?:tbd|tbc)\b.*|todo\s*(?::.*)?|n\/?a|none|-|…|\.\.\.|works?|it works|passes|ok)\.?$/i;
36
38
 
37
39
  const isSection = (name: string): name is Section => SECTIONS.some((s) => s === name);
38
40
 
@@ -85,7 +87,7 @@ const stepsOf = (text: string): string[] =>
85
87
 
86
88
  function concrete(name: "Run" | "Expected", text: string): string | undefined {
87
89
  const bare = stripTicks(text);
88
- if (PLACEHOLDER.test(bare))
90
+ if (bare === "" || PLACEHOLDER.test(bare))
89
91
  return `${name} must be a concrete ${name === "Run" ? "command" : "observable result"}, not "${bare}"`;
90
92
  if (name === "Expected" && bare.split(/\s+/).length < 2) {
91
93
  return "Expected must say what is observed (at least two words, e.g. a count, output or exit status)";
@@ -142,7 +144,7 @@ function locate(markdown: string, id: string | undefined): Located | ParseError
142
144
 
143
145
  /** Parses one task record (the only one in the file, or the one named by `id`), reporting every problem together. */
144
146
  export function parseTaskRecord(markdown: string, id?: string): TaskRecord | ParseError {
145
- const found = locate(markdown, id);
147
+ const found = locate(markdown.replace(/\r\n?/g, "\n"), id);
146
148
  if (isParseError(found)) return found;
147
149
  const split = splitSections(found.lines);
148
150
  const problems = problemsOf(found.lines.join("\n"), split);
@@ -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,16 +161,16 @@ 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
167
  const add = [
139
168
  ...wanted.filter((l) => !current.labels.includes(l)),
140
- ...(marked ? [IN_PROGRESS] : []),
169
+ ...(marked && !hadMarker ? [IN_PROGRESS] : []),
141
170
  ];
142
171
  const remove = [
143
172
  ...current.labels.filter((l) => !wanted.includes(l)),
144
- ...(marked ? [] : [IN_PROGRESS]),
173
+ ...(!marked && hadMarker ? [IN_PROGRESS] : []),
145
174
  ];
146
175
  return [
147
176
  ...add.flatMap((l) => ["--add-label", l]),
@@ -149,10 +178,12 @@ const labelFlags = (current: WorkItem, patch: WorkItemPatch): string[] => {
149
178
  ];
150
179
  };
151
180
 
152
- const editFlags = (current: WorkItem, patch: WorkItemPatch): string[] => [
181
+ const editFlags = (current: WorkItem, hadMarker: boolean, patch: WorkItemPatch): string[] => [
153
182
  ...(patch.title === undefined ? [] : ["--title", patch.title]),
154
183
  ...(patch.body === undefined ? [] : ["--body", patch.body]),
155
- ...(patch.labels === undefined && patch.status === undefined ? [] : labelFlags(current, patch)),
184
+ ...(patch.labels === undefined && patch.status === undefined
185
+ ? []
186
+ : labelFlags(current, hadMarker, patch)),
156
187
  ];
157
188
 
158
189
  /** Closing or reopening only when the issue is not already in the wanted state. */
@@ -172,18 +203,23 @@ const updateIssue = async (
172
203
  id: string,
173
204
  patch: WorkItemPatch,
174
205
  ): Promise<TrackerResult<WorkItem>> => {
175
- const current = await view(gh, id);
176
- 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);
177
212
  if (patch.status === "in-progress") {
178
- const made = await gh(["label", "create", IN_PROGRESS, "--force"]);
179
- if (!made.ok) return made;
213
+ // No --force: that would recolour the team's existing label. "Already exists" is success.
214
+ const made = await gh(["label", "create", IN_PROGRESS]);
215
+ if (!(made.ok || /already exists/i.test(made.error.message))) return made;
180
216
  }
181
- const flags = editFlags(current.value, patch);
217
+ const flags = editFlags(current, hadMarker, patch);
182
218
  if (flags.length > 0) {
183
219
  const edited = await gh(["issue", "edit", id, ...flags]);
184
220
  if (!edited.ok) return edited;
185
221
  }
186
- const command = stateCommand(current.value, patch.status);
222
+ const command = stateCommand(current, patch.status);
187
223
  if (command !== undefined) {
188
224
  const changed = await gh(["issue", command, id]);
189
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));
@@ -46,7 +46,9 @@ const readItem = async (root: string, id: string): Promise<TrackerResult<WorkIte
46
46
  const itemIds = async (root: string): Promise<string[]> => {
47
47
  try {
48
48
  const files = await readdir(itemsDir(root));
49
- return files.flatMap((f) => (f.endsWith(".md") ? [f.slice(0, -3)] : []));
49
+ return files.flatMap((f) =>
50
+ f.endsWith(".md") && ID.test(f.slice(0, -3)) ? [f.slice(0, -3)] : [],
51
+ );
50
52
  } catch (cause) {
51
53
  if (isMissing(cause)) return [];
52
54
  throw cause;
@@ -66,11 +68,14 @@ const readAll = async (root: string): Promise<TrackerResult<WorkItem[]>> => {
66
68
  const save = async (root: string, item: WorkItem): Promise<TrackerResult<WorkItem>> => {
67
69
  const problem = unwritable(item);
68
70
  if (problem !== undefined) return trackerError(problem);
71
+ const others = await readAll(root);
72
+ if (!others.ok) return others;
69
73
  await mkdir(itemsDir(root), { recursive: true });
70
74
  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));
75
+ const items = [...others.value.filter((i) => i.id !== item.id), item].toSorted((a, b) =>
76
+ byCodeUnit(a.id, b.id),
77
+ );
78
+ await writeFile(join(root, "work", "backlog.md"), renderBacklog(items));
74
79
  return ok(item);
75
80
  };
76
81