@erclx/aitk 0.103.0 → 0.104.1
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-teach/SKILL.md +21 -3
- package/claude/skills/claude-teach/references/lesson-craft.md +8 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/tasks.md +28 -1
- package/docs/agents/teach.md +29 -2
- package/docs/ai-workflow.md +2 -2
- package/package.json +1 -1
- package/src/commands/tasks.ts +108 -1
- package/src/commands/teach.ts +136 -0
- package/src/records/validate.ts +5 -3
- package/src/sandbox/expect.ts +29 -12
- package/src/tasks/archive.ts +158 -9
- package/src/tasks/trunk.ts +89 -0
- package/src/tasks/validate.ts +100 -29
- package/src/teach/lesson.ts +180 -0
- package/src/teach/workspace.ts +48 -3
- package/standards/tasks.md +15 -3
package/src/sandbox/expect.ts
CHANGED
|
@@ -209,23 +209,35 @@ export function countMechanicalAssertions(expectation: Expectation): number {
|
|
|
209
209
|
)
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* An entry carrying `*` is matched as a glob, so an arm can assert a file whose
|
|
214
|
+
* name a run derives. Reports the matched path rather than the pattern, since a
|
|
215
|
+
* pass on `lessons/0001-*.html` says nothing until the name it found is named.
|
|
216
|
+
*/
|
|
212
217
|
function checkPaths(
|
|
213
218
|
expectation: Expectation,
|
|
214
219
|
sandboxDir: string,
|
|
215
220
|
): AssertionResult[] {
|
|
216
|
-
return expectation.paths.map((path) =>
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
+
return expectation.paths.map((path) => {
|
|
222
|
+
const written = writtenUnder(path, sandboxDir)
|
|
223
|
+
|
|
224
|
+
return written
|
|
225
|
+
? { ok: true, message: `exists: ${written}` }
|
|
226
|
+
: { ok: false, message: `missing: ${path}` }
|
|
227
|
+
})
|
|
221
228
|
}
|
|
222
229
|
|
|
223
230
|
/**
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
231
|
+
* The first file an entry matches, or undefined when it matches none. An entry
|
|
232
|
+
* carrying `*` is matched as a glob, which is what lets an arm name a file whose
|
|
233
|
+
* name a run derives rather than fixes. Pinning one spelling of a derived name
|
|
234
|
+
* passes vacuously against every other spelling, which reads as coverage the arm
|
|
235
|
+
* does not have.
|
|
236
|
+
*
|
|
237
|
+
* Returning the match rather than a boolean is what lets a result name the file
|
|
238
|
+
* the run wrote instead of the pattern that found it. A glob matching several
|
|
239
|
+
* files answers with one of them in no fixed order, so an arm asserting content
|
|
240
|
+
* through a glob seeds a folder holding one.
|
|
229
241
|
*/
|
|
230
242
|
function writtenUnder(pattern: string, sandboxDir: string): string | undefined {
|
|
231
243
|
if (!pattern.includes('*')) {
|
|
@@ -260,14 +272,18 @@ function checkAbsent(
|
|
|
260
272
|
* throwing, since an arm may assert content without also listing the path. A
|
|
261
273
|
* pattern that does not compile is a defect in the declaration, so it fails the
|
|
262
274
|
* assertion it belongs to rather than aborting the whole verdict.
|
|
275
|
+
*
|
|
276
|
+
* A path carrying `*` resolves to the file it matched, and falls back to itself
|
|
277
|
+
* when it matched none so the miss is reported against the entry as written.
|
|
263
278
|
*/
|
|
264
279
|
function checkContent(
|
|
265
280
|
expectation: Expectation,
|
|
266
281
|
sandboxDir: string,
|
|
267
282
|
): AssertionResult[] {
|
|
268
283
|
return expectation.content.map(({ path, pattern }) => {
|
|
269
|
-
const
|
|
270
|
-
const
|
|
284
|
+
const matched = writtenUnder(path, sandboxDir) ?? path
|
|
285
|
+
const full = join(sandboxDir, matched)
|
|
286
|
+
const label = `${matched} =~ ${pattern}`
|
|
271
287
|
|
|
272
288
|
let matcher: RegExp
|
|
273
289
|
try {
|
|
@@ -279,6 +295,7 @@ function checkContent(
|
|
|
279
295
|
if (!existsSync(full) || !statSync(full).isFile()) {
|
|
280
296
|
return { ok: false, message: `no file to match: ${path}` }
|
|
281
297
|
}
|
|
298
|
+
|
|
282
299
|
if (matcher.test(readFileSync(full, 'utf8'))) {
|
|
283
300
|
return { ok: true, message: `matches: ${label}` }
|
|
284
301
|
}
|
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
|
+
}
|
package/src/tasks/validate.ts
CHANGED
|
@@ -5,8 +5,10 @@ import {
|
|
|
5
5
|
archiveDir,
|
|
6
6
|
isReservedStem,
|
|
7
7
|
readOutcomes,
|
|
8
|
+
readPullRequest,
|
|
8
9
|
tasksDir,
|
|
9
10
|
} from '@/tasks/archive'
|
|
11
|
+
import { gitTrunkReader, type TrunkReader } from '@/tasks/trunk'
|
|
10
12
|
|
|
11
13
|
const ORDERING_FILE = 'priority.md'
|
|
12
14
|
const BACKLOG_FILE = 'backlog.md'
|
|
@@ -486,17 +488,34 @@ function citedStem(cell: string): string | undefined {
|
|
|
486
488
|
return stemOf(target)
|
|
487
489
|
}
|
|
488
490
|
|
|
491
|
+
/** What one blocker citation produced, since a row can be neither settled nor open. */
|
|
492
|
+
interface CitedResult {
|
|
493
|
+
readonly findings: readonly Finding[]
|
|
494
|
+
readonly untested: readonly Untested[]
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function nothing(): CitedResult {
|
|
498
|
+
return { findings: [], untested: [] }
|
|
499
|
+
}
|
|
500
|
+
|
|
489
501
|
/**
|
|
490
|
-
* Reports what a cited task does to the row waiting on it. A
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
* check could not parse is not evidence of a finished one.
|
|
502
|
+
* Reports what a cited task does to the row waiting on it. A file sitting in
|
|
503
|
+
* the archive settles the row, and a live file settles it only once the work it
|
|
504
|
+
* carries is on the trunk. A file carrying no outcome box settles nothing,
|
|
505
|
+
* since a file the check could not parse is not evidence of a finished one.
|
|
494
506
|
*
|
|
495
507
|
* A citation resolving in neither folder is a broken pointer rather than a
|
|
496
508
|
* closed task, and the two take different findings. Reading an absent file as
|
|
497
509
|
* archived states a specific fact about a file nobody ever wrote, which is what
|
|
498
510
|
* a renamed task or a typo produces.
|
|
499
511
|
*
|
|
512
|
+
* A closed outcome is not the same fact as landed work. The ship chain marks
|
|
513
|
+
* outcomes as its first step and opens the pull request several steps later, so
|
|
514
|
+
* a check reading the checkbox reports the row settled while the branch is
|
|
515
|
+
* still in review. The pull request the task names is what the trunk is asked
|
|
516
|
+
* about, and a task naming none leaves the row untested rather than settled,
|
|
517
|
+
* because the only local signal left is the checkbox that produced the defect.
|
|
518
|
+
*
|
|
500
519
|
* The outcome list comes off `readOutcomes` rather than a pattern of its own,
|
|
501
520
|
* so this check cannot disagree with the archive and outcome verbs about which
|
|
502
521
|
* checkboxes are outcomes and which sit inside a block a task displays.
|
|
@@ -506,42 +525,76 @@ async function checkCitedTask(
|
|
|
506
525
|
subject: string,
|
|
507
526
|
cited: string,
|
|
508
527
|
root: string,
|
|
509
|
-
|
|
528
|
+
trunk: TrunkReader,
|
|
529
|
+
): Promise<CitedResult> {
|
|
510
530
|
const live = join(tasksDir(root), `${cited}.md`)
|
|
511
531
|
|
|
512
532
|
if (!existsSync(live)) {
|
|
513
533
|
if (existsSync(join(archiveDir(root), `${cited}.md`))) {
|
|
514
|
-
return
|
|
534
|
+
return settled(group, subject, `waits on ${cited}, which is archived.`)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return {
|
|
538
|
+
findings: [
|
|
515
539
|
{
|
|
516
|
-
kind: 'blocker-
|
|
540
|
+
kind: 'blocker-unresolved',
|
|
517
541
|
group,
|
|
518
542
|
subject,
|
|
519
|
-
message: `waits on ${cited}, which is archived.`,
|
|
543
|
+
message: `waits on ${cited}, which is neither on the board nor archived.`,
|
|
520
544
|
},
|
|
521
|
-
]
|
|
545
|
+
],
|
|
546
|
+
untested: [],
|
|
522
547
|
}
|
|
523
|
-
|
|
524
|
-
return [
|
|
525
|
-
{
|
|
526
|
-
kind: 'blocker-unresolved',
|
|
527
|
-
group,
|
|
528
|
-
subject,
|
|
529
|
-
message: `waits on ${cited}, which is neither on the board nor archived.`,
|
|
530
|
-
},
|
|
531
|
-
]
|
|
532
548
|
}
|
|
533
549
|
|
|
534
|
-
const
|
|
535
|
-
|
|
550
|
+
const text = await readFile(live, 'utf8')
|
|
551
|
+
const { open, closed } = readOutcomes(text)
|
|
552
|
+
if (open.length > 0 || closed.length === 0) return nothing()
|
|
536
553
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
554
|
+
const pullRequest = readPullRequest(text)
|
|
555
|
+
if (pullRequest === undefined) {
|
|
556
|
+
return untestedRow(
|
|
540
557
|
group,
|
|
541
558
|
subject,
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
559
|
+
`waits on ${cited}, which closed every outcome but names no pull request, so nothing tests whether the work reached the trunk.`,
|
|
560
|
+
)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const landed = await trunk(pullRequest)
|
|
564
|
+
if (landed === undefined) {
|
|
565
|
+
return untestedRow(
|
|
566
|
+
group,
|
|
567
|
+
subject,
|
|
568
|
+
`waits on ${cited}, whose pull request #${pullRequest} could not be read against the trunk.`,
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (!landed) return nothing()
|
|
573
|
+
|
|
574
|
+
return settled(
|
|
575
|
+
group,
|
|
576
|
+
subject,
|
|
577
|
+
`waits on ${cited}, whose pull request #${pullRequest} reached the trunk.`,
|
|
578
|
+
)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function settled(
|
|
582
|
+
group: BoardGroup,
|
|
583
|
+
subject: string,
|
|
584
|
+
message: string,
|
|
585
|
+
): CitedResult {
|
|
586
|
+
return {
|
|
587
|
+
findings: [{ kind: 'blocker-settled', group, subject, message }],
|
|
588
|
+
untested: [],
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function untestedRow(
|
|
593
|
+
group: BoardGroup,
|
|
594
|
+
subject: string,
|
|
595
|
+
message: string,
|
|
596
|
+
): CitedResult {
|
|
597
|
+
return { findings: [], untested: [{ group, subject, message }] }
|
|
545
598
|
}
|
|
546
599
|
|
|
547
600
|
/**
|
|
@@ -564,6 +617,7 @@ async function checkCitedTask(
|
|
|
564
617
|
async function checkParked(
|
|
565
618
|
rows: readonly BoardRow[],
|
|
566
619
|
root: string,
|
|
620
|
+
trunk: TrunkReader,
|
|
567
621
|
): Promise<{ findings: Finding[]; untested: Untested[] }> {
|
|
568
622
|
const findings: Finding[] = []
|
|
569
623
|
const untested: Untested[] = []
|
|
@@ -578,7 +632,15 @@ async function checkParked(
|
|
|
578
632
|
const contested = readPaths(cell)
|
|
579
633
|
|
|
580
634
|
if (cited) {
|
|
581
|
-
|
|
635
|
+
const result = await checkCitedTask(
|
|
636
|
+
row.group,
|
|
637
|
+
subject,
|
|
638
|
+
cited,
|
|
639
|
+
root,
|
|
640
|
+
trunk,
|
|
641
|
+
)
|
|
642
|
+
findings.push(...result.findings)
|
|
643
|
+
untested.push(...result.untested)
|
|
582
644
|
}
|
|
583
645
|
|
|
584
646
|
const held = contested.filter((path) =>
|
|
@@ -613,12 +675,21 @@ function refuse(reason: ValidateRefusal, message: string): ValidateRefused {
|
|
|
613
675
|
return { ok: false, reason, message }
|
|
614
676
|
}
|
|
615
677
|
|
|
678
|
+
export interface ValidateOptions {
|
|
679
|
+
/** Overridden by tests, which supply the trunk rather than reaching for git. */
|
|
680
|
+
readonly trunk?: TrunkReader
|
|
681
|
+
}
|
|
682
|
+
|
|
616
683
|
/**
|
|
617
684
|
* Reports what every board row claims against what the tree holds. It writes
|
|
618
685
|
* nothing: a row is a session's claim about readiness, and a validator that
|
|
619
686
|
* repaired one would be asserting the claim it exists to test.
|
|
620
687
|
*/
|
|
621
|
-
export async function validateBoard(
|
|
688
|
+
export async function validateBoard(
|
|
689
|
+
root: string,
|
|
690
|
+
options: ValidateOptions = {},
|
|
691
|
+
): Promise<ValidateOutcome> {
|
|
692
|
+
const trunk = options.trunk ?? gitTrunkReader(root)
|
|
622
693
|
const dir = tasksDir(root)
|
|
623
694
|
if (!existsSync(dir)) {
|
|
624
695
|
return refuse('no-board', `No task board at ${dir}.`)
|
|
@@ -647,7 +718,7 @@ export async function validateBoard(root: string): Promise<ValidateOutcome> {
|
|
|
647
718
|
: []
|
|
648
719
|
|
|
649
720
|
const stems = await listTaskStems(dir)
|
|
650
|
-
const parked = await checkParked(rows, root)
|
|
721
|
+
const parked = await checkParked(rows, root, trunk)
|
|
651
722
|
|
|
652
723
|
const findings = [
|
|
653
724
|
...checkMapping(rows, backlog, stems, dir),
|