@jwilger/pi-development-system 0.71.0 → 0.73.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-start.md +1 -1
- package/src/tracker/repo-files.ts +42 -9
package/package.json
CHANGED
package/prompts/devsys-start.md
CHANGED
|
@@ -6,6 +6,6 @@ Start new work with the work-intake-and-slicing skill.
|
|
|
6
6
|
|
|
7
7
|
1. Call `devsys_intake` with `request` set to: $ARGUMENTS (if empty, ask the user what they want done first).
|
|
8
8
|
2. Show the user the proposed size and artifacts. The tool asks them to confirm or change the size.
|
|
9
|
-
3. Produce the planning artifacts first (brief, journeys, event model, architecture, lens review, in that order, whichever are recommended), then the task record. `review` and `adr-if-needed` are not written up front: review happens on the finished slice, and an ADR when a decision needs one. To skip one, call `devsys_record_departure` with gate `artifact.skipped:<artifact>` and the reason; do not skip silently. The event model and architecture are recommendations, never gates.
|
|
9
|
+
3. Produce the planning artifacts first (brief, decision register, journeys, event model, architecture, lens review, in that order, whichever are recommended), then the task record. `review` and `adr-if-needed` are not written up front: review happens on the finished slice, and an ADR when a decision needs one. To skip one, call `devsys_record_departure` with gate `artifact.skipped:<artifact>` and the reason; do not skip silently. The event model and architecture are recommendations, never gates.
|
|
10
10
|
4. For `capability` and `product` the phase stays `planning` while you produce the artifacts and the plan. The single point where it changes is the user approving the whole plan: then call `devsys_begin_work` so the review and red-first gates switch on (it does nothing if already implementing).
|
|
11
11
|
5. For a confirmed `fix` the user is also asked whether to skip fresh-context review (it is logged if they say yes; if no, the slice needs its review rounds). For `fix` and `change` the phase is already `implementing`: write the task record, then work it test-first.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { lstat, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join, relative, sep } from "node:path";
|
|
3
3
|
import { ok } from "../core/result.ts";
|
|
4
4
|
import { parseItem, renderBacklog, renderItem, unwritable } from "./item-format.ts";
|
|
5
5
|
import {
|
|
@@ -15,8 +15,11 @@ import {
|
|
|
15
15
|
const ID = /^[a-z0-9][a-z0-9-]*$/;
|
|
16
16
|
const ID_MAX = 60;
|
|
17
17
|
|
|
18
|
+
/** ASCII id from a title: accents are folded (Résumé → resume); a title with no ASCII letters or digits becomes `item`. */
|
|
18
19
|
const slugOf = (title: string): string =>
|
|
19
20
|
title
|
|
21
|
+
.normalize("NFD")
|
|
22
|
+
.replace(/\p{M}+/gu, "")
|
|
20
23
|
.toLowerCase()
|
|
21
24
|
.replace(/[^a-z0-9]+/g, "-")
|
|
22
25
|
.replace(/^-+/, "")
|
|
@@ -33,8 +36,35 @@ const byCodeUnit = (a: string, b: string): number => Number(a > b) - Number(a <
|
|
|
33
36
|
const describe = (cause: unknown): string =>
|
|
34
37
|
cause instanceof Error ? cause.message : String(cause);
|
|
35
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The first symlink on the way from the repo root to `target`, if any. A committed symlink
|
|
41
|
+
* under `work/` could otherwise make a tracker write land outside the repository.
|
|
42
|
+
*/
|
|
43
|
+
const symlinkOnPath = async (root: string, target: string): Promise<string | undefined> => {
|
|
44
|
+
let current = root;
|
|
45
|
+
for (const part of relative(root, target).split(sep)) {
|
|
46
|
+
current = join(current, part);
|
|
47
|
+
try {
|
|
48
|
+
if ((await lstat(current)).isSymbolicLink()) return current;
|
|
49
|
+
} catch (cause) {
|
|
50
|
+
if (isMissing(cause)) return undefined;
|
|
51
|
+
throw cause;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const refuseSymlink = async (root: string, target: string): Promise<string | undefined> => {
|
|
58
|
+
const link = await symlinkOnPath(root, target);
|
|
59
|
+
return link === undefined
|
|
60
|
+
? undefined
|
|
61
|
+
: `${relative(root, link)} is a symbolic link; the repo-files tracker will not follow it`;
|
|
62
|
+
};
|
|
63
|
+
|
|
36
64
|
const readItem = async (root: string, id: string): Promise<TrackerResult<WorkItem>> => {
|
|
37
65
|
if (!ID.test(id)) return trackerError(`work item "${id}": not a valid id`);
|
|
66
|
+
const link = await refuseSymlink(root, fileOf(root, id));
|
|
67
|
+
if (link !== undefined) return trackerError(link);
|
|
38
68
|
try {
|
|
39
69
|
return parseItem(id, await readFile(fileOf(root, id), "utf8"));
|
|
40
70
|
} catch (cause) {
|
|
@@ -44,6 +74,7 @@ const readItem = async (root: string, id: string): Promise<TrackerResult<WorkIte
|
|
|
44
74
|
};
|
|
45
75
|
|
|
46
76
|
const itemIds = async (root: string): Promise<string[]> => {
|
|
77
|
+
if ((await refuseSymlink(root, itemsDir(root))) !== undefined) return [];
|
|
47
78
|
try {
|
|
48
79
|
const files = await readdir(itemsDir(root));
|
|
49
80
|
return files.flatMap((f) =>
|
|
@@ -68,6 +99,11 @@ const readAll = async (root: string): Promise<TrackerResult<WorkItem[]>> => {
|
|
|
68
99
|
const save = async (root: string, item: WorkItem): Promise<TrackerResult<WorkItem>> => {
|
|
69
100
|
const problem = unwritable(item);
|
|
70
101
|
if (problem !== undefined) return trackerError(problem);
|
|
102
|
+
const backlog = join(root, "work", "backlog.md");
|
|
103
|
+
for (const target of [itemsDir(root), fileOf(root, item.id), backlog]) {
|
|
104
|
+
const link = await refuseSymlink(root, target);
|
|
105
|
+
if (link !== undefined) return trackerError(link);
|
|
106
|
+
}
|
|
71
107
|
const others = await readAll(root);
|
|
72
108
|
if (!others.ok) return others;
|
|
73
109
|
await mkdir(itemsDir(root), { recursive: true });
|
|
@@ -75,13 +111,12 @@ const save = async (root: string, item: WorkItem): Promise<TrackerResult<WorkIte
|
|
|
75
111
|
const items = [...others.value.filter((i) => i.id !== item.id), item].toSorted((a, b) =>
|
|
76
112
|
byCodeUnit(a.id, b.id),
|
|
77
113
|
);
|
|
78
|
-
await writeFile(
|
|
114
|
+
await writeFile(backlog, renderBacklog(items));
|
|
79
115
|
return ok(item);
|
|
80
116
|
};
|
|
81
117
|
|
|
82
|
-
const freshId = async (root: string, title: string): Promise<string
|
|
83
|
-
const base = slugOf(title);
|
|
84
|
-
if (base === "") return undefined;
|
|
118
|
+
const freshId = async (root: string, title: string): Promise<string> => {
|
|
119
|
+
const base = slugOf(title) || "item";
|
|
85
120
|
const taken = new Set(await itemIds(root));
|
|
86
121
|
let id = base;
|
|
87
122
|
for (let n = 2; taken.has(id); n++) id = `${base}-${n}`;
|
|
@@ -97,10 +132,8 @@ const listItems = async (root: string, filter: ItemFilter): Promise<TrackerResul
|
|
|
97
132
|
const createItem = async (root: string, input: NewWorkItem): Promise<TrackerResult<WorkItem>> => {
|
|
98
133
|
const problem = unwritable({ title: input.title, labels: input.labels ?? [] });
|
|
99
134
|
if (problem !== undefined) return trackerError(problem);
|
|
100
|
-
const id = await freshId(root, input.title);
|
|
101
|
-
if (id === undefined) return trackerError("a work item title needs letters or digits");
|
|
102
135
|
return save(root, {
|
|
103
|
-
id,
|
|
136
|
+
id: await freshId(root, input.title),
|
|
104
137
|
title: input.title,
|
|
105
138
|
body: input.body ?? "",
|
|
106
139
|
status: "open",
|