@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 +1 -1
- package/prompts/devsys-plan.md +2 -2
- package/skills/work-intake-and-slicing/SKILL.md +1 -1
- package/src/planning/intake-tool.ts +10 -3
- package/src/planning/intake.ts +22 -3
- package/src/planning/task-check-tool.ts +6 -1
- package/src/planning/task-record.ts +59 -17
- package/src/tracker/github.ts +42 -18
- package/src/tracker/item-format.ts +25 -2
- package/src/tracker/repo-files.ts +5 -1
package/package.json
CHANGED
package/prompts/devsys-plan.md
CHANGED
|
@@ -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
|
|
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
|
|
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),
|
package/src/planning/intake.ts
CHANGED
|
@@ -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
|
-
|
|
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 = /^\*\*([^:*]+)
|
|
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[]):
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
if (
|
|
103
|
-
return
|
|
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
|
|
106
|
-
|
|
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(
|
|
150
|
+
const text = (s: Section): string => get(split.text, s);
|
|
109
151
|
return {
|
|
110
|
-
id:
|
|
111
|
-
title:
|
|
152
|
+
id: found.id,
|
|
153
|
+
title: found.title,
|
|
112
154
|
goal: text("Goal"),
|
|
113
155
|
files: filesOf(text("Files")),
|
|
114
156
|
interfaces: text("Interfaces"),
|
package/src/tracker/github.ts
CHANGED
|
@@ -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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
status
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
if (
|
|
160
|
-
const
|
|
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
|
-
|
|
164
|
-
|
|
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
|
|
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, {
|