@erclx/aitk 0.104.0 → 0.105.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/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-docs/SKILL.md +4 -0
- package/claude/skills/claude-groundwork/SKILL.md +3 -2
- package/claude/skills/claude-worktree/SKILL.md +6 -1
- package/docs/agents/commands.md +2 -1
- package/docs/agents/index.md +1 -0
- package/docs/agents/tasks.md +28 -1
- package/docs/agents/test-order.md +56 -0
- package/docs/ai-workflow.md +4 -2
- package/governance/rules/core/070-planning.md +1 -0
- package/package.json +1 -1
- package/src/commands/gov.ts +135 -0
- package/src/commands/tasks.ts +108 -1
- package/src/gov/test-order.ts +383 -0
- package/src/tasks/archive.ts +158 -9
- package/src/tasks/trunk.ts +89 -0
- package/src/tasks/validate.ts +100 -29
- package/standards/groundwork.md +4 -0
- package/standards/tasks.md +15 -3
- package/tooling/astro/manifest.toml +5 -5
- package/tooling/vite-react/manifest.toml +4 -4
- package/tooling/web/configs/scripts/worktree-port.sh +44 -6
- package/tooling/web/manifest.toml +3 -3
- package/tooling/web/reference.md +4 -1
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { execaSync } from 'execa'
|
|
2
|
+
import { gitEnv } from '@/git-env'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Preferred first, matching `src/tasks/trunk.ts`. A clone with no remote still
|
|
6
|
+
* answers off its local trunk, and a local `main` trailing the remote widens
|
|
7
|
+
* the range rather than narrowing it, which over-reports instead of hiding.
|
|
8
|
+
*/
|
|
9
|
+
const TRUNK_REFS = ['origin/main', 'main'] as const
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The extensions this check can pair. A test sits beside its subject under one
|
|
13
|
+
* name throughout this corpus, so the weakest assumption available is also the
|
|
14
|
+
* one the corpus already honors. Every other extension is read past and named,
|
|
15
|
+
* since a bash script and its test are related by nothing a filename carries.
|
|
16
|
+
*/
|
|
17
|
+
export const SOURCE_EXTENSIONS = ['.ts', '.tsx'] as const
|
|
18
|
+
|
|
19
|
+
/** The suffix removed to derive the subject a test covers. */
|
|
20
|
+
export const TEST_SUFFIXES = ['.test.ts', '.test.tsx'] as const
|
|
21
|
+
|
|
22
|
+
/** A `%H` line, which is 40 hex characters under sha1 and 64 under sha256. */
|
|
23
|
+
const COMMIT = /^[0-9a-f]{40,64}$/
|
|
24
|
+
|
|
25
|
+
/** A `--name-status` line: a status letter, a tab, then one path. */
|
|
26
|
+
const NAME_STATUS = /^([A-Z])\d*\t(.+)$/
|
|
27
|
+
|
|
28
|
+
export type Verdict = 'satisfied' | 'implementation-first' | 'unclassified'
|
|
29
|
+
|
|
30
|
+
export interface PairRecord {
|
|
31
|
+
/** The implementation path, which is what a subject is named by. */
|
|
32
|
+
readonly subject: string
|
|
33
|
+
/** The test paired to it, or `null` when the pairing found no partner. */
|
|
34
|
+
readonly test: string | null
|
|
35
|
+
readonly verdict: Verdict
|
|
36
|
+
readonly implementationCommit: string | null
|
|
37
|
+
readonly testCommit: string | null
|
|
38
|
+
/** Why the verdict reads the way it does, in the report's own words. */
|
|
39
|
+
readonly reason: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Scope {
|
|
43
|
+
readonly extensions: readonly string[]
|
|
44
|
+
readonly testSuffixes: readonly string[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type TestOrderReport =
|
|
48
|
+
| {
|
|
49
|
+
readonly kind: 'measured'
|
|
50
|
+
readonly base: string
|
|
51
|
+
readonly head: string
|
|
52
|
+
readonly scope: Scope
|
|
53
|
+
readonly satisfied: readonly PairRecord[]
|
|
54
|
+
readonly findings: readonly PairRecord[]
|
|
55
|
+
readonly unclassified: readonly PairRecord[]
|
|
56
|
+
/** Changed paths outside the pairing's reach, named rather than counted. */
|
|
57
|
+
readonly ignored: readonly string[]
|
|
58
|
+
}
|
|
59
|
+
| { readonly kind: 'unreadable'; readonly reason: string }
|
|
60
|
+
|
|
61
|
+
export interface TestOrderOptions {
|
|
62
|
+
/** The far side of the range, defaulting to the merge base against the trunk. */
|
|
63
|
+
readonly base?: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One path as one commit in the range touched it. */
|
|
67
|
+
interface Change {
|
|
68
|
+
readonly path: string
|
|
69
|
+
readonly status: string
|
|
70
|
+
readonly commit: string
|
|
71
|
+
/** Position in the range, oldest first, which is what orders a pair. */
|
|
72
|
+
readonly order: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reads `--name-status` log output, oldest commit first, into one entry per
|
|
77
|
+
* path per commit.
|
|
78
|
+
*
|
|
79
|
+
* The order field rather than the commit is what a comparison reads, since two
|
|
80
|
+
* commits carry no ordering a caller can derive from their hashes alone.
|
|
81
|
+
*/
|
|
82
|
+
function parseChanges(output: string): Change[] {
|
|
83
|
+
const changes: Change[] = []
|
|
84
|
+
let commit = ''
|
|
85
|
+
let order = -1
|
|
86
|
+
|
|
87
|
+
for (const line of output.split('\n')) {
|
|
88
|
+
const trimmed = line.trimEnd()
|
|
89
|
+
if (trimmed === '') continue
|
|
90
|
+
|
|
91
|
+
if (COMMIT.test(trimmed)) {
|
|
92
|
+
commit = trimmed
|
|
93
|
+
order += 1
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const match = NAME_STATUS.exec(trimmed)
|
|
98
|
+
if (match === null || commit === '') continue
|
|
99
|
+
|
|
100
|
+
changes.push({ path: match[2], status: match[1], commit, order })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return changes
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether a path is a test this check can derive a subject from. */
|
|
107
|
+
function testSuffix(path: string): string | undefined {
|
|
108
|
+
return TEST_SUFFIXES.find((suffix) => path.endsWith(suffix))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The implementation a test covers, derived by removing the test suffix.
|
|
113
|
+
*
|
|
114
|
+
* A behavior split across two modules pairs wrongly or not at all under this,
|
|
115
|
+
* which is what the unclassified bucket exists to catch rather than hide.
|
|
116
|
+
*/
|
|
117
|
+
function subjectOf(path: string): string {
|
|
118
|
+
const suffix = testSuffix(path)
|
|
119
|
+
if (suffix === undefined) return path
|
|
120
|
+
return `${path.slice(0, -suffix.length)}${suffix.replace('.test', '')}`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The one test path that would cover `subject` under the beside convention. */
|
|
124
|
+
function testPathFor(subject: string): string {
|
|
125
|
+
return subject.replace(/\.(tsx?)$/, '.test.$1')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Whether a path is an implementation this check can pair. A declaration file
|
|
130
|
+
* carries no behavior to test, so it is read past rather than reported as a
|
|
131
|
+
* module nothing covers.
|
|
132
|
+
*/
|
|
133
|
+
function isImplementation(path: string): boolean {
|
|
134
|
+
if (path.endsWith('.d.ts')) return false
|
|
135
|
+
if (testSuffix(path) !== undefined) return false
|
|
136
|
+
return SOURCE_EXTENSIONS.some((extension) => path.endsWith(extension))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The first commit in the range that added `path`, or `undefined`. */
|
|
140
|
+
function introduction(
|
|
141
|
+
changes: readonly Change[],
|
|
142
|
+
path: string,
|
|
143
|
+
): Change | undefined {
|
|
144
|
+
return changes.find((change) => change.path === path && change.status === 'A')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function classify(
|
|
148
|
+
changes: readonly Change[],
|
|
149
|
+
atBase: ReadonlySet<string>,
|
|
150
|
+
): {
|
|
151
|
+
satisfied: PairRecord[]
|
|
152
|
+
findings: PairRecord[]
|
|
153
|
+
unclassified: PairRecord[]
|
|
154
|
+
} {
|
|
155
|
+
const satisfied: PairRecord[] = []
|
|
156
|
+
const findings: PairRecord[] = []
|
|
157
|
+
const unclassified: PairRecord[] = []
|
|
158
|
+
|
|
159
|
+
// Keyed by subject so a module whose implementation and test both moved is
|
|
160
|
+
// one record rather than two, and so a test with no partner still reports
|
|
161
|
+
// under the implementation path a reader would go looking for.
|
|
162
|
+
const subjects = new Set<string>()
|
|
163
|
+
for (const change of changes) {
|
|
164
|
+
if (isImplementation(change.path)) subjects.add(change.path)
|
|
165
|
+
else if (testSuffix(change.path) !== undefined) {
|
|
166
|
+
subjects.add(subjectOf(change.path))
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const subject of [...subjects].sort()) {
|
|
171
|
+
const candidate = testPathFor(subject)
|
|
172
|
+
const testChange = introduction(changes, candidate)
|
|
173
|
+
const testAtBase = atBase.has(candidate)
|
|
174
|
+
const test = testAtBase || testChange !== undefined ? candidate : undefined
|
|
175
|
+
|
|
176
|
+
const implementation = introduction(changes, subject)
|
|
177
|
+
|
|
178
|
+
if (implementation === undefined) {
|
|
179
|
+
unclassified.push({
|
|
180
|
+
subject,
|
|
181
|
+
test: test ?? null,
|
|
182
|
+
verdict: 'unclassified',
|
|
183
|
+
implementationCommit: null,
|
|
184
|
+
testCommit: null,
|
|
185
|
+
reason: atBase.has(subject)
|
|
186
|
+
? 'the implementation predates the range, so a change to it cannot be separated from a refactor'
|
|
187
|
+
: 'no implementation reached the range beside this test',
|
|
188
|
+
})
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (test === undefined) {
|
|
193
|
+
unclassified.push({
|
|
194
|
+
subject,
|
|
195
|
+
test: null,
|
|
196
|
+
verdict: 'unclassified',
|
|
197
|
+
implementationCommit: implementation.commit,
|
|
198
|
+
testCommit: null,
|
|
199
|
+
reason: 'no test names this module, so the ordering has no second side',
|
|
200
|
+
})
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// A paired test the range never added is one that already sat at the base
|
|
205
|
+
// commit, since those are the only two ways `test` gets a value at all.
|
|
206
|
+
if (testChange === undefined) {
|
|
207
|
+
satisfied.push({
|
|
208
|
+
subject,
|
|
209
|
+
test,
|
|
210
|
+
verdict: 'satisfied',
|
|
211
|
+
implementationCommit: implementation.commit,
|
|
212
|
+
testCommit: null,
|
|
213
|
+
reason: 'the test predates the range',
|
|
214
|
+
})
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// One commit carrying both sides counts as satisfied. The rule asks that
|
|
219
|
+
// the test not come after, and a single commit is the shape a small change
|
|
220
|
+
// takes here, so reporting it would flag most of the corpus.
|
|
221
|
+
const record: PairRecord = {
|
|
222
|
+
subject,
|
|
223
|
+
test,
|
|
224
|
+
verdict:
|
|
225
|
+
testChange.order <= implementation.order
|
|
226
|
+
? 'satisfied'
|
|
227
|
+
: 'implementation-first',
|
|
228
|
+
implementationCommit: implementation.commit,
|
|
229
|
+
testCommit: testChange.commit,
|
|
230
|
+
reason:
|
|
231
|
+
testChange.order <= implementation.order
|
|
232
|
+
? 'the test reached history no later than the implementation'
|
|
233
|
+
: 'the implementation reached history before the test covering it',
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (record.verdict === 'satisfied') satisfied.push(record)
|
|
237
|
+
else findings.push(record)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { satisfied, findings, unclassified }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Where an implementation reached a commit ahead of the test covering it,
|
|
245
|
+
* between `base` and the current `HEAD` of the tree at `root`.
|
|
246
|
+
*
|
|
247
|
+
* This reports and never gates. Pairing a test to an implementation is a
|
|
248
|
+
* judgment, so a change the pairing cannot read lands in `unclassified` with
|
|
249
|
+
* its reason stated rather than being counted as a pass. Coverage is narrower
|
|
250
|
+
* than the rule the check answers to, and `scope` and `ignored` are what say so
|
|
251
|
+
* on every run.
|
|
252
|
+
*/
|
|
253
|
+
export function readTestOrder(
|
|
254
|
+
root: string,
|
|
255
|
+
options: TestOrderOptions = {},
|
|
256
|
+
): TestOrderReport {
|
|
257
|
+
const head = revParse(root, 'HEAD')
|
|
258
|
+
if (head === undefined) {
|
|
259
|
+
return {
|
|
260
|
+
kind: 'unreadable',
|
|
261
|
+
reason: `No git history under ${root}. History is the only surface carrying the ordering, so there is nothing to read.`,
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const base = resolveBase(root, options.base, head)
|
|
266
|
+
if (typeof base !== 'string') return base
|
|
267
|
+
|
|
268
|
+
const log = git(root, [
|
|
269
|
+
'log',
|
|
270
|
+
'--reverse',
|
|
271
|
+
'--name-status',
|
|
272
|
+
'--no-renames',
|
|
273
|
+
'--format=%H',
|
|
274
|
+
`${base}..${head}`,
|
|
275
|
+
])
|
|
276
|
+
|
|
277
|
+
if (log === undefined) {
|
|
278
|
+
return {
|
|
279
|
+
kind: 'unreadable',
|
|
280
|
+
reason: `Reading history between ${base} and HEAD failed under ${root}.`,
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const tree = git(root, ['ls-tree', '-r', '--name-only', base])
|
|
285
|
+
if (tree === undefined) {
|
|
286
|
+
return {
|
|
287
|
+
kind: 'unreadable',
|
|
288
|
+
reason: `Reading the tree at ${base} failed under ${root}. Without it a test written before the range reads as absent.`,
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const changes = parseChanges(log)
|
|
293
|
+
const atBase = new Set(tree.split('\n').filter((line) => line !== ''))
|
|
294
|
+
|
|
295
|
+
const ignored = [
|
|
296
|
+
...new Set(
|
|
297
|
+
changes
|
|
298
|
+
.map((change) => change.path)
|
|
299
|
+
.filter(
|
|
300
|
+
(path) => !isImplementation(path) && testSuffix(path) === undefined,
|
|
301
|
+
),
|
|
302
|
+
),
|
|
303
|
+
].sort()
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
kind: 'measured',
|
|
307
|
+
base,
|
|
308
|
+
head,
|
|
309
|
+
scope: { extensions: SOURCE_EXTENSIONS, testSuffixes: TEST_SUFFIXES },
|
|
310
|
+
...classify(changes, atBase),
|
|
311
|
+
ignored,
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The far side of the range. A ref the caller named has to resolve, since
|
|
317
|
+
* falling back to the trunk there would measure a range nobody asked for. With
|
|
318
|
+
* no ref named, the merge base against the trunk scopes the run to the branch,
|
|
319
|
+
* and a repository carrying no trunk falls back to the root commit rather than
|
|
320
|
+
* refusing.
|
|
321
|
+
*/
|
|
322
|
+
function resolveBase(
|
|
323
|
+
root: string,
|
|
324
|
+
ref: string | undefined,
|
|
325
|
+
head: string,
|
|
326
|
+
): string | { kind: 'unreadable'; reason: string } {
|
|
327
|
+
if (ref !== undefined) {
|
|
328
|
+
const resolved = revParse(root, ref)
|
|
329
|
+
if (resolved === undefined) {
|
|
330
|
+
return {
|
|
331
|
+
kind: 'unreadable',
|
|
332
|
+
reason: `Ref ${ref} resolves to no commit in ${root}. Pass a commit this tree carries.`,
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return resolved
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
for (const trunk of TRUNK_REFS) {
|
|
339
|
+
if (revParse(root, trunk) === undefined) continue
|
|
340
|
+
const merged = git(root, ['merge-base', head, trunk])
|
|
341
|
+
if (merged !== undefined && merged !== '') return merged
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const rootCommits = git(root, ['rev-list', '--max-parents=0', head])
|
|
345
|
+
if (rootCommits === undefined || rootCommits === '') {
|
|
346
|
+
return {
|
|
347
|
+
kind: 'unreadable',
|
|
348
|
+
reason: `No base resolves against ${root}. Fetch origin or pass --base.`,
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return rootCommits.split('\n')[0]
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* `execaSync` with git's repository-resolution variables stripped, so `-C`
|
|
357
|
+
* resolves against `root` and not against whatever repository a hook exported.
|
|
358
|
+
* `undefined` is the refusal, which every caller turns into its own reason.
|
|
359
|
+
*/
|
|
360
|
+
function git(root: string, args: readonly string[]): string | undefined {
|
|
361
|
+
const result = execaSync('git', ['-C', root, ...args], {
|
|
362
|
+
reject: false,
|
|
363
|
+
env: gitEnv(),
|
|
364
|
+
extendEnv: false,
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
return result.exitCode === 0 ? result.stdout.trimEnd() : undefined
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The commit a ref names. `^{commit}` is what turns a tag or a tree into the
|
|
372
|
+
* commit behind it, so a caller never compares a ref against another type.
|
|
373
|
+
*/
|
|
374
|
+
function revParse(root: string, ref: string): string | undefined {
|
|
375
|
+
const resolved = git(root, [
|
|
376
|
+
'rev-parse',
|
|
377
|
+
'--verify',
|
|
378
|
+
'--quiet',
|
|
379
|
+
`${ref}^{commit}`,
|
|
380
|
+
])
|
|
381
|
+
|
|
382
|
+
return resolved === undefined || resolved === '' ? undefined : resolved.trim()
|
|
383
|
+
}
|
package/src/tasks/archive.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { regenOne } from '@/indexes/regen'
|
|
|
6
6
|
const TASKS_DIR = join('.claude', 'tasks')
|
|
7
7
|
const ARCHIVE_DIR = join('.claude', 'task-archive')
|
|
8
8
|
const PLANS_DIR = join('.claude', 'plans')
|
|
9
|
+
const PLANS_ARCHIVE_DIR = join('.claude', 'plans-archive')
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Siblings that sit on the board without being tasks: the generated index, the
|
|
@@ -192,13 +193,152 @@ function isUnder(path: string, dir: string): boolean {
|
|
|
192
193
|
* both, which is how `claude-docs` reads the same line. It accepts `../plans/x.md`
|
|
193
194
|
* and `.claude/plans/x.md` as one file, so a gate reading only the first form
|
|
194
195
|
* would pass the second and strand the plan this exists to protect.
|
|
196
|
+
*
|
|
197
|
+
* The resolved path is returned rather than a boolean, because the citation
|
|
198
|
+
* count below compares two tasks by where their targets land and not by the
|
|
199
|
+
* strings they wrote. A target outside the live plans folder yields nothing,
|
|
200
|
+
* which is an archived plan or a pointer into somewhere else entirely.
|
|
195
201
|
*/
|
|
196
|
-
function
|
|
202
|
+
export function resolveLivePlan(
|
|
203
|
+
target: string,
|
|
204
|
+
dir: string,
|
|
205
|
+
root: string,
|
|
206
|
+
): string | undefined {
|
|
197
207
|
const plans = join(root, PLANS_DIR)
|
|
208
|
+
const fromBoard = resolve(dir, target)
|
|
209
|
+
const fromRoot = resolve(root, target)
|
|
210
|
+
|
|
211
|
+
if (isUnder(fromBoard, plans)) return fromBoard
|
|
212
|
+
if (isUnder(fromRoot, plans)) return fromRoot
|
|
213
|
+
return undefined
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Names the other live tasks whose `Plan:` line lands on the same file. This is
|
|
218
|
+
* the rule `claude-docs` applies before it archives a plan, held here so one
|
|
219
|
+
* question has one implementation: a plan another live task still cites is a
|
|
220
|
+
* plan the sweep is correct to leave, and a guard that read the folder instead
|
|
221
|
+
* refused every task sharing one plan and deadlocked the board against the
|
|
222
|
+
* sweep that was behaving correctly.
|
|
223
|
+
*
|
|
224
|
+
* The closing task is excluded by name. It cites the plan itself, so counting
|
|
225
|
+
* it would never reach zero and the count would answer nothing.
|
|
226
|
+
*/
|
|
227
|
+
export async function otherTasksCitingPlan(
|
|
228
|
+
dir: string,
|
|
229
|
+
root: string,
|
|
230
|
+
plan: string,
|
|
231
|
+
closing: string,
|
|
232
|
+
): Promise<string[]> {
|
|
233
|
+
const stems = (await listTaskStems(dir)).filter((stem) => stem !== closing)
|
|
234
|
+
|
|
235
|
+
const read = await Promise.all(
|
|
236
|
+
stems.map(async (stem) => {
|
|
237
|
+
const target = readPlanTarget(
|
|
238
|
+
await readFile(join(dir, `${stem}.md`), 'utf8'),
|
|
239
|
+
)
|
|
240
|
+
const resolved = target && resolveLivePlan(target, dir, root)
|
|
241
|
+
return resolved === plan ? stem : undefined
|
|
242
|
+
}),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
return read.filter((stem): stem is string => stem !== undefined)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Where a task's `Plan:` target resolves, which is what decides whether the
|
|
250
|
+
* plan is the sweep's to move. `unstated` is a task carrying no line at all,
|
|
251
|
+
* and it is distinct from a line resolving somewhere unexpected.
|
|
252
|
+
*/
|
|
253
|
+
export const CITATION_LOCATIONS = [
|
|
254
|
+
'unstated',
|
|
255
|
+
'live',
|
|
256
|
+
'archived',
|
|
257
|
+
'outside',
|
|
258
|
+
] as const
|
|
259
|
+
|
|
260
|
+
export type CitationLocation = (typeof CITATION_LOCATIONS)[number]
|
|
261
|
+
|
|
262
|
+
export interface PlanCitations {
|
|
263
|
+
readonly ok: true
|
|
264
|
+
readonly stem: string
|
|
265
|
+
readonly target: string | undefined
|
|
266
|
+
readonly location: CitationLocation
|
|
267
|
+
/** Other live tasks landing on the same file. Empty unless `location` is `live`. */
|
|
268
|
+
readonly citedBy: readonly string[]
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export type CitationOutcome = PlanCitations | ArchiveRefused
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Answers where one task's plan sits and who else holds it, which is the whole
|
|
275
|
+
* of the last-live-citation rule. `claude-docs` reads this rather than scanning
|
|
276
|
+
* the board itself, so the sweep that moves a plan and the gate that refuses a
|
|
277
|
+
* task archive cannot drift into disagreeing about which plan is free.
|
|
278
|
+
*
|
|
279
|
+
* It reports and never writes. The move, the retarget, and the ordering the two
|
|
280
|
+
* happen in belong to the caller, and a verb that performed them would be
|
|
281
|
+
* deciding a question the sweep is there to decide.
|
|
282
|
+
*/
|
|
283
|
+
export async function planCitations(
|
|
284
|
+
root: string,
|
|
285
|
+
stem: string,
|
|
286
|
+
): Promise<CitationOutcome> {
|
|
287
|
+
const dir = tasksDir(root)
|
|
288
|
+
|
|
289
|
+
if (!existsSync(dir)) {
|
|
290
|
+
return refuse('no-board', `No task board at ${relative(root, dir)}.`)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const stems = await listTaskStems(dir)
|
|
294
|
+
if (!stems.includes(stem)) {
|
|
295
|
+
return refuse('no-match', `No task named ${stem} on the board.`, stems)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const target = readPlanTarget(await readFile(join(dir, `${stem}.md`), 'utf8'))
|
|
299
|
+
if (!target) {
|
|
300
|
+
return {
|
|
301
|
+
ok: true,
|
|
302
|
+
stem,
|
|
303
|
+
target: undefined,
|
|
304
|
+
location: 'unstated',
|
|
305
|
+
citedBy: [],
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const live = resolveLivePlan(target, dir, root)
|
|
310
|
+
if (!live) {
|
|
311
|
+
const location = resolvesUnder(target, dir, root, PLANS_ARCHIVE_DIR)
|
|
312
|
+
? 'archived'
|
|
313
|
+
: 'outside'
|
|
314
|
+
return { ok: true, stem, target, location, citedBy: [] }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
ok: true,
|
|
319
|
+
stem,
|
|
320
|
+
target,
|
|
321
|
+
location: 'live',
|
|
322
|
+
citedBy: await otherTasksCitingPlan(dir, root, live, stem),
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Runs the two-spelling resolution `resolveLivePlan` applies against a folder
|
|
328
|
+
* other than the live one, so an archived plan is read as archived whichever
|
|
329
|
+
* root the task wrote its path against.
|
|
330
|
+
*/
|
|
331
|
+
function resolvesUnder(
|
|
332
|
+
target: string,
|
|
333
|
+
dir: string,
|
|
334
|
+
root: string,
|
|
335
|
+
folder: string,
|
|
336
|
+
): boolean {
|
|
337
|
+
const resolved = join(root, folder)
|
|
198
338
|
|
|
199
339
|
return (
|
|
200
|
-
isUnder(resolve(dir, target),
|
|
201
|
-
isUnder(resolve(root, target),
|
|
340
|
+
isUnder(resolve(dir, target), resolved) ||
|
|
341
|
+
isUnder(resolve(root, target), resolved)
|
|
202
342
|
)
|
|
203
343
|
}
|
|
204
344
|
|
|
@@ -310,12 +450,21 @@ export async function archiveTask(
|
|
|
310
450
|
}
|
|
311
451
|
|
|
312
452
|
const planTarget = readPlanTarget(text)
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
453
|
+
const livePlan = planTarget && resolveLivePlan(planTarget, dir, root)
|
|
454
|
+
|
|
455
|
+
// A live plan is unswept only when nothing else on the board holds it. A plan
|
|
456
|
+
// several tasks share stays live by design, so refusing on the folder alone
|
|
457
|
+
// parked every one of those tasks behind a sweep that was right to decline.
|
|
458
|
+
if (livePlan) {
|
|
459
|
+
const shared = await otherTasksCitingPlan(dir, root, livePlan, stem)
|
|
460
|
+
|
|
461
|
+
if (shared.length === 0) {
|
|
462
|
+
return refuse(
|
|
463
|
+
'plan-unswept',
|
|
464
|
+
`${stem} is the last task pointing at a live plan. Run /claude-docs to sweep it first, then archive.`,
|
|
465
|
+
[planTarget],
|
|
466
|
+
)
|
|
467
|
+
}
|
|
319
468
|
}
|
|
320
469
|
|
|
321
470
|
const destination = archiveDir(root)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
import { gitEnv } from '@/git-env'
|
|
3
|
+
|
|
4
|
+
const GIT_TIMEOUT_MS = 10_000
|
|
5
|
+
|
|
6
|
+
/** Preferred first. A clone with no remote still answers off its local trunk. */
|
|
7
|
+
const TRUNK_REFS = ['origin/main', 'main'] as const
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Reports whether a pull request's work reached the trunk. `undefined` means
|
|
11
|
+
* the trunk could not be read, which is a different answer from `false` and has
|
|
12
|
+
* to stay one: a check that degraded to "not landed" on an unreachable trunk
|
|
13
|
+
* would report a board silently rather than say what it failed to test.
|
|
14
|
+
*/
|
|
15
|
+
export type TrunkReader = (pullRequest: number) => Promise<boolean | undefined>
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Matches the two subjects a landed pull request leaves on the trunk: the
|
|
19
|
+
* `(#NNN)` suffix a squash merge writes, and the subject GitHub writes for a
|
|
20
|
+
* merge commit. Both carry the number in a form no other commit spells, so a
|
|
21
|
+
* pull request numbered 12 cannot match one numbered 123.
|
|
22
|
+
*/
|
|
23
|
+
function grepArgs(pullRequest: number): string[] {
|
|
24
|
+
return [
|
|
25
|
+
'--extended-regexp',
|
|
26
|
+
'--grep',
|
|
27
|
+
`\\(#${pullRequest}\\)`,
|
|
28
|
+
'--grep',
|
|
29
|
+
`Merge pull request #${pullRequest} from`,
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Reads the trunk as this clone already holds it and never fetches. A validate
|
|
35
|
+
* run happens several times a sweep and a fetch per run is a cost the check
|
|
36
|
+
* does not carry today, so a clone behind the remote reports the row untested
|
|
37
|
+
* or leaves it parked rather than claiming work landed.
|
|
38
|
+
*/
|
|
39
|
+
export function gitTrunkReader(root: string): TrunkReader {
|
|
40
|
+
const answered = new Map<number, boolean | undefined>()
|
|
41
|
+
|
|
42
|
+
return async (pullRequest) => {
|
|
43
|
+
if (answered.has(pullRequest)) return answered.get(pullRequest)
|
|
44
|
+
|
|
45
|
+
const landed = await readTrunk(root, pullRequest)
|
|
46
|
+
answered.set(pullRequest, landed)
|
|
47
|
+
return landed
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function readTrunk(
|
|
52
|
+
root: string,
|
|
53
|
+
pullRequest: number,
|
|
54
|
+
): Promise<boolean | undefined> {
|
|
55
|
+
for (const ref of TRUNK_REFS) {
|
|
56
|
+
const result = await execa(
|
|
57
|
+
'git',
|
|
58
|
+
[
|
|
59
|
+
'-C',
|
|
60
|
+
root,
|
|
61
|
+
'log',
|
|
62
|
+
ref,
|
|
63
|
+
'-n',
|
|
64
|
+
'1',
|
|
65
|
+
'--format=%H',
|
|
66
|
+
...grepArgs(pullRequest),
|
|
67
|
+
// The ref is a revision, and a repository tracking a path under the
|
|
68
|
+
// same name would otherwise fail the whole read as ambiguous.
|
|
69
|
+
'--',
|
|
70
|
+
],
|
|
71
|
+
// `post-merge` drives the task verbs, and a hook exports the repository
|
|
72
|
+
// variables git reads ahead of `-C`, so the ambient environment would
|
|
73
|
+
// answer for whatever repository fired the hook.
|
|
74
|
+
{
|
|
75
|
+
reject: false,
|
|
76
|
+
timeout: GIT_TIMEOUT_MS,
|
|
77
|
+
env: gitEnv(),
|
|
78
|
+
extendEnv: false,
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
// A missing ref exits non-zero, which is the next ref's turn rather than an
|
|
83
|
+
// answer. An exit of zero with no commit is the ref saying the work is not
|
|
84
|
+
// on it, which is an answer and stops the walk.
|
|
85
|
+
if (result.exitCode === 0) return result.stdout.trim().length > 0
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return undefined
|
|
89
|
+
}
|