@pmelab/gtd 18.1.0 → 20.0.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/.claude-plugin/plugin.json +1 -1
- package/README.md +69 -69
- package/claude/hooks/doors.ts +34 -0
- package/claude/hooks/drive.ts +1 -0
- package/claude/hooks/register.tsx +12 -13
- package/claude/hooks/ship.ts +3 -2
- package/dist/gtd.bundle.mjs +847 -721
- package/package.json +3 -2
- package/skills/authoring/SKILL.md +43 -32
- package/src/flows/helpers.ts +4 -2
- package/src/flows/runtime.ts +22 -17
- package/src/flows/scripts.test.ts +28 -1
- package/src/flows/scripts.ts +3 -0
- package/src/workflows/access.test.ts +3 -4
- package/src/workflows/access.ts +0 -1
- package/src/workflows/{unified.ts → bundled.ts} +38 -31
- package/src/workflows/health.test.ts +21 -1
- package/src/workflows/health.ts +47 -8
- package/src/workflows/index.ts +1 -1
- package/src/workflows/packages.test.ts +91 -0
- package/src/workflows/packages.ts +96 -76
- package/src/workflows/planning.ts +5 -49
- package/src/workflows/prose.ts +1 -9
- package/src/workflows/review.test.ts +91 -4
- package/src/workflows/review.ts +42 -17
- package/src/workflows/scenarios.test.ts +256 -0
- package/src/workflows/scenarios.ts +137 -0
- package/src/workflows/skills.test.ts +2 -6
- package/src/workflows/skills.ts +0 -2
- package/src/workflows/steps.test.ts +2 -12
- package/src/workflows/steps.ts +2 -20
- package/src/workflows/text.test.ts +60 -0
- package/src/workflows/text.ts +118 -96
- package/src/workflows/vars.ts +1 -2
- package/claude/hooks/entry.ts +0 -34
- package/src/workflows/diff.test.ts +0 -115
- package/src/workflows/diff.ts +0 -306
package/src/workflows/diff.ts
DELETED
|
@@ -1,306 +0,0 @@
|
|
|
1
|
-
import type { Change } from "../flows/index.js"
|
|
2
|
-
|
|
3
|
-
// Pure evidence rendering for `packages.item.spec.pre`: no step, no tree
|
|
4
|
-
// access. Everything here works from the `Change` list a flow already holds.
|
|
5
|
-
|
|
6
|
-
const CONTEXT = 3
|
|
7
|
-
const TOO_LARGE_LINES = 1500
|
|
8
|
-
|
|
9
|
-
// ── The fixed exclusion list — no configuration key ─────────────────────────
|
|
10
|
-
|
|
11
|
-
const LOCKFILES = new Set([
|
|
12
|
-
"package-lock.json",
|
|
13
|
-
"npm-shrinkwrap.json",
|
|
14
|
-
"yarn.lock",
|
|
15
|
-
"pnpm-lock.yaml",
|
|
16
|
-
"bun.lock",
|
|
17
|
-
"bun.lockb",
|
|
18
|
-
"Cargo.lock",
|
|
19
|
-
"composer.lock",
|
|
20
|
-
"Gemfile.lock",
|
|
21
|
-
"poetry.lock",
|
|
22
|
-
"uv.lock",
|
|
23
|
-
"Pipfile.lock",
|
|
24
|
-
"go.sum",
|
|
25
|
-
"flake.lock",
|
|
26
|
-
"mix.lock",
|
|
27
|
-
"pubspec.lock",
|
|
28
|
-
"Podfile.lock",
|
|
29
|
-
"gradle.lockfile",
|
|
30
|
-
])
|
|
31
|
-
|
|
32
|
-
const GENERATED_GLOBS = [
|
|
33
|
-
"dist/**",
|
|
34
|
-
"build/**",
|
|
35
|
-
"out/**",
|
|
36
|
-
"coverage/**",
|
|
37
|
-
"node_modules/**",
|
|
38
|
-
"vendor/**",
|
|
39
|
-
".turbo/**",
|
|
40
|
-
"**/__snapshots__/**",
|
|
41
|
-
"**/*.snap",
|
|
42
|
-
"**/*.min.js",
|
|
43
|
-
"**/*.min.css",
|
|
44
|
-
"**/*.map",
|
|
45
|
-
]
|
|
46
|
-
|
|
47
|
-
const BINARY_EXTENSIONS = new Set([
|
|
48
|
-
"png",
|
|
49
|
-
"jpg",
|
|
50
|
-
"jpeg",
|
|
51
|
-
"gif",
|
|
52
|
-
"webp",
|
|
53
|
-
"ico",
|
|
54
|
-
"pdf",
|
|
55
|
-
"zip",
|
|
56
|
-
"gz",
|
|
57
|
-
"tar",
|
|
58
|
-
"woff",
|
|
59
|
-
"woff2",
|
|
60
|
-
"ttf",
|
|
61
|
-
"otf",
|
|
62
|
-
"mp4",
|
|
63
|
-
"wasm",
|
|
64
|
-
"bin",
|
|
65
|
-
"exe",
|
|
66
|
-
"so",
|
|
67
|
-
"dylib",
|
|
68
|
-
])
|
|
69
|
-
|
|
70
|
-
// A small, self-contained glob match — `*` stays within a segment, `**`
|
|
71
|
-
// crosses them — kept local rather than reached through another boundary for
|
|
72
|
-
// eighteen fixed patterns.
|
|
73
|
-
const globCache = new Map<string, RegExp>()
|
|
74
|
-
const compileGlob = (glob: string): RegExp => {
|
|
75
|
-
let pattern = "^"
|
|
76
|
-
let i = 0
|
|
77
|
-
while (i < glob.length) {
|
|
78
|
-
const char = glob[i]!
|
|
79
|
-
if (char !== "*") {
|
|
80
|
-
pattern += char.replace(/[.+^${}()|[\]\\?]/g, "\\$&")
|
|
81
|
-
i += 1
|
|
82
|
-
} else if (glob[i + 1] !== "*") {
|
|
83
|
-
pattern += "[^/]*"
|
|
84
|
-
i += 1
|
|
85
|
-
} else if (glob[i + 2] === "/") {
|
|
86
|
-
pattern += "(?:.*/)?"
|
|
87
|
-
i += 3
|
|
88
|
-
} else {
|
|
89
|
-
pattern += ".*"
|
|
90
|
-
i += 2
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return new RegExp(`${pattern}$`)
|
|
94
|
-
}
|
|
95
|
-
const matchesGlob = (path: string, glob: string): boolean => {
|
|
96
|
-
let regex = globCache.get(glob)
|
|
97
|
-
if (regex === undefined) {
|
|
98
|
-
regex = compileGlob(glob)
|
|
99
|
-
globCache.set(glob, regex)
|
|
100
|
-
}
|
|
101
|
-
return regex.test(path)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const extensionOf = (path: string): string => {
|
|
105
|
-
const base = path.split("/").pop() ?? path
|
|
106
|
-
const dot = base.lastIndexOf(".")
|
|
107
|
-
return dot === -1 ? "" : base.slice(dot + 1).toLowerCase()
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const isBinary = (change: Change): boolean =>
|
|
111
|
-
BINARY_EXTENSIONS.has(extensionOf(change.path)) ||
|
|
112
|
-
(change.before?.includes("\0") ?? false) ||
|
|
113
|
-
(change.after?.includes("\0") ?? false)
|
|
114
|
-
|
|
115
|
-
const isExcluded = (change: Change): boolean =>
|
|
116
|
-
change.path === ".gtd" ||
|
|
117
|
-
change.path.startsWith(".gtd/") ||
|
|
118
|
-
LOCKFILES.has(change.path.split("/").pop() ?? change.path) ||
|
|
119
|
-
GENERATED_GLOBS.some((glob) => matchesGlob(change.path, glob)) ||
|
|
120
|
-
isBinary(change)
|
|
121
|
-
|
|
122
|
-
/** `changes` with lockfiles, generated trees and binaries dropped. */
|
|
123
|
-
export const filterChanges = (changes: readonly Change[]): readonly Change[] =>
|
|
124
|
-
changes.filter((change) => !isExcluded(change))
|
|
125
|
-
|
|
126
|
-
// ── Line diff ────────────────────────────────────────────────────────────
|
|
127
|
-
|
|
128
|
-
interface Op {
|
|
129
|
-
readonly type: "eq" | "add" | "del"
|
|
130
|
-
readonly line: string
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const linesOf = (content: string | undefined): readonly string[] => {
|
|
134
|
-
if (content === undefined || content === "") return []
|
|
135
|
-
const lines = content.split("\n")
|
|
136
|
-
if (lines[lines.length - 1] === "") lines.pop()
|
|
137
|
-
return lines
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** `dp[i][j]` = the LCS length of `a[i:]` and `b[j:]`. */
|
|
141
|
-
const lcsTable = (a: readonly string[], b: readonly string[]): number[][] => {
|
|
142
|
-
const n = a.length
|
|
143
|
-
const m = b.length
|
|
144
|
-
const dp: number[][] = Array.from({ length: n + 1 }, () =>
|
|
145
|
-
Array.from<number>({ length: m + 1 }).fill(0),
|
|
146
|
-
)
|
|
147
|
-
for (let i = n - 1; i >= 0; i--) {
|
|
148
|
-
for (let j = m - 1; j >= 0; j--) {
|
|
149
|
-
dp[i]![j] = a[i] === b[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!)
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
return dp
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** Walk an LCS table into an edit script, favoring a deletion on a tie. */
|
|
156
|
-
const backtrack = (a: readonly string[], b: readonly string[], dp: number[][]): Op[] => {
|
|
157
|
-
const ops: Op[] = []
|
|
158
|
-
let i = 0
|
|
159
|
-
let j = 0
|
|
160
|
-
while (i < a.length && j < b.length) {
|
|
161
|
-
if (a[i] === b[j]) {
|
|
162
|
-
ops.push({ type: "eq", line: a[i]! })
|
|
163
|
-
i++
|
|
164
|
-
j++
|
|
165
|
-
} else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
|
|
166
|
-
ops.push({ type: "del", line: a[i]! })
|
|
167
|
-
i++
|
|
168
|
-
} else {
|
|
169
|
-
ops.push({ type: "add", line: b[j]! })
|
|
170
|
-
j++
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
while (i < a.length) ops.push({ type: "del", line: a[i++]! })
|
|
174
|
-
while (j < b.length) ops.push({ type: "add", line: b[j++]! })
|
|
175
|
-
return ops
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** Longest-common-subsequence edit script between two (already trimmed) line arrays. */
|
|
179
|
-
const lcsOps = (a: readonly string[], b: readonly string[]): Op[] => backtrack(a, b, lcsTable(a, b))
|
|
180
|
-
|
|
181
|
-
const commonAffixes = (
|
|
182
|
-
before: readonly string[],
|
|
183
|
-
after: readonly string[],
|
|
184
|
-
): { readonly prefix: number; readonly suffix: number } => {
|
|
185
|
-
const maxPrefix = Math.min(before.length, after.length)
|
|
186
|
-
let prefix = 0
|
|
187
|
-
while (prefix < maxPrefix && before[prefix] === after[prefix]) prefix++
|
|
188
|
-
const maxSuffix = maxPrefix - prefix
|
|
189
|
-
let suffix = 0
|
|
190
|
-
while (
|
|
191
|
-
suffix < maxSuffix &&
|
|
192
|
-
before[before.length - 1 - suffix] === after[after.length - 1 - suffix]
|
|
193
|
-
)
|
|
194
|
-
suffix++
|
|
195
|
-
return { prefix, suffix }
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
interface Hunk {
|
|
199
|
-
readonly beforeStart: number
|
|
200
|
-
readonly beforeCount: number
|
|
201
|
-
readonly afterStart: number
|
|
202
|
-
readonly afterCount: number
|
|
203
|
-
readonly lines: readonly string[]
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const buildHunks = (ops: readonly Op[]): readonly Hunk[] => {
|
|
207
|
-
const beforeLineAt: number[] = []
|
|
208
|
-
const afterLineAt: number[] = []
|
|
209
|
-
let bl = 1
|
|
210
|
-
let al = 1
|
|
211
|
-
for (const op of ops) {
|
|
212
|
-
beforeLineAt.push(bl)
|
|
213
|
-
afterLineAt.push(al)
|
|
214
|
-
if (op.type !== "add") bl++
|
|
215
|
-
if (op.type !== "del") al++
|
|
216
|
-
}
|
|
217
|
-
const ranges: [number, number][] = []
|
|
218
|
-
ops.forEach((op, idx) => {
|
|
219
|
-
if (op.type === "eq") return
|
|
220
|
-
const start = Math.max(0, idx - CONTEXT)
|
|
221
|
-
const end = Math.min(ops.length - 1, idx + CONTEXT)
|
|
222
|
-
const last = ranges[ranges.length - 1]
|
|
223
|
-
if (last !== undefined && start <= last[1] + 1) {
|
|
224
|
-
last[1] = Math.max(last[1], end)
|
|
225
|
-
} else {
|
|
226
|
-
ranges.push([start, end])
|
|
227
|
-
}
|
|
228
|
-
})
|
|
229
|
-
return ranges.map(([start, end]) => {
|
|
230
|
-
const slice = ops.slice(start, end + 1)
|
|
231
|
-
return {
|
|
232
|
-
beforeStart: beforeLineAt[start]!,
|
|
233
|
-
beforeCount: slice.filter((op) => op.type !== "add").length,
|
|
234
|
-
afterStart: afterLineAt[start]!,
|
|
235
|
-
afterCount: slice.filter((op) => op.type !== "del").length,
|
|
236
|
-
lines: slice.map(
|
|
237
|
-
(op) => `${op.type === "add" ? "+" : op.type === "del" ? "-" : " "}${op.line}`,
|
|
238
|
-
),
|
|
239
|
-
}
|
|
240
|
-
})
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
const renderHunk = (hunk: Hunk): readonly string[] => [
|
|
244
|
-
`@@ -${hunk.beforeStart},${hunk.beforeCount} +${hunk.afterStart},${hunk.afterCount} @@`,
|
|
245
|
-
...hunk.lines,
|
|
246
|
-
]
|
|
247
|
-
|
|
248
|
-
/** One file's unified diff, or a summary line when its changed region is too large to inline. */
|
|
249
|
-
const renderFileDiff = (change: Change): string => {
|
|
250
|
-
const before = linesOf(change.before)
|
|
251
|
-
const after = linesOf(change.after)
|
|
252
|
-
const { prefix, suffix } = commonAffixes(before, after)
|
|
253
|
-
const beforeMiddle = before.slice(prefix, before.length - suffix)
|
|
254
|
-
const afterMiddle = after.slice(prefix, after.length - suffix)
|
|
255
|
-
if (beforeMiddle.length > TOO_LARGE_LINES || afterMiddle.length > TOO_LARGE_LINES) {
|
|
256
|
-
return `${change.path}: +${afterMiddle.length}/-${beforeMiddle.length} lines, too large to inline`
|
|
257
|
-
}
|
|
258
|
-
const prefixOps: Op[] = before.slice(0, prefix).map((line) => ({ type: "eq", line }))
|
|
259
|
-
const suffixOps: Op[] = before.slice(before.length - suffix).map((line) => ({ type: "eq", line }))
|
|
260
|
-
const ops = [...prefixOps, ...lcsOps(beforeMiddle, afterMiddle), ...suffixOps]
|
|
261
|
-
const beforePath = change.status === "added" ? "/dev/null" : `a/${change.path}`
|
|
262
|
-
const afterPath = change.status === "deleted" ? "/dev/null" : `b/${change.path}`
|
|
263
|
-
return [`--- ${beforePath}`, `+++ ${afterPath}`, ...buildHunks(ops).flatMap(renderHunk)].join(
|
|
264
|
-
"\n",
|
|
265
|
-
)
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const omissionTrailer = (paths: readonly string[]): string =>
|
|
269
|
-
`${paths.length} file(s) omitted for the judge's byte budget: ${paths.join(", ")}`
|
|
270
|
-
|
|
271
|
-
interface RenderedFile {
|
|
272
|
-
readonly path: string
|
|
273
|
-
readonly text: string
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/** The byte size of keeping `files`' first `keepCount` entries plus the trailer the rest would need — the trailer counts against the cap too, not just the kept text. */
|
|
277
|
-
const sizeWithTrailer = (files: readonly RenderedFile[], keepCount: number): number => {
|
|
278
|
-
let used = 0
|
|
279
|
-
for (let i = 0; i < keepCount; i++) {
|
|
280
|
-
used += Buffer.byteLength(files[i]!.text, "utf8") + (i > 0 ? 1 : 0)
|
|
281
|
-
}
|
|
282
|
-
const droppedCount = files.length - keepCount
|
|
283
|
-
if (droppedCount === 0) return used
|
|
284
|
-
const trailer = omissionTrailer(files.slice(keepCount).map((f) => f.path))
|
|
285
|
-
return used + (keepCount > 0 ? 1 : 0) + Buffer.byteLength(trailer, "utf8")
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* The filtered `changes`, rendered as one unified diff (files in path order)
|
|
290
|
-
* and bounded to `capBytes`: over the cap, whole files are dropped from the
|
|
291
|
-
* end — never bytes off a hunk — and named in a trailer line, so the
|
|
292
|
-
* judge is told what it did not see rather than handed a silently short diff.
|
|
293
|
-
* The trailer itself is sized against the cap along with the files it keeps,
|
|
294
|
-
* so a package with many touched files can never push the total past it.
|
|
295
|
-
*/
|
|
296
|
-
export const packageDiff = (changes: readonly Change[], capBytes: number): string => {
|
|
297
|
-
const files = filterChanges(changes)
|
|
298
|
-
.slice()
|
|
299
|
-
.sort((a, b) => a.path.localeCompare(b.path))
|
|
300
|
-
.map((change) => ({ path: change.path, text: renderFileDiff(change) }))
|
|
301
|
-
let keepCount = files.length
|
|
302
|
-
while (keepCount > 0 && sizeWithTrailer(files, keepCount) > capBytes) keepCount--
|
|
303
|
-
const dropped = files.slice(keepCount).map((f) => f.path)
|
|
304
|
-
const kept = files.slice(0, keepCount).map((f) => f.text)
|
|
305
|
-
return [...kept, ...(dropped.length > 0 ? [omissionTrailer(dropped)] : [])].join("\n")
|
|
306
|
-
}
|