@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.
@@ -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
- existsSync(join(sandboxDir, path))
218
- ? { ok: true, message: `exists: ${path}` }
219
- : { ok: false, message: `missing: ${path}` },
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
- * An entry carrying `*` is matched as a glob, which is what lets an arm forbid
225
- * a file whose name a run derives rather than fixes. Pinning one spelling of a
226
- * derived name passes vacuously against every other spelling, which reads as
227
- * coverage the arm does not have. Returns the offending path so a failure names
228
- * the file the run wrote rather than the pattern that caught it.
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 full = join(sandboxDir, path)
270
- const label = `${path} =~ ${pattern}`
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
  }
@@ -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 isLivePlan(target: string, dir: string, root: string): boolean {
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), plans) ||
201
- isUnder(resolve(root, target), plans)
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
- if (planTarget && isLivePlan(planTarget, dir, root)) {
314
- return refuse(
315
- 'plan-unswept',
316
- `${stem} still points at a live plan. Run /claude-docs to sweep it first, then archive.`,
317
- [planTarget],
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
+ }
@@ -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 live file whose
491
- * outcomes are all closed settles the row, and so does one sitting in the
492
- * archive. A file carrying no outcome box settles nothing, since a file the
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
- ): Promise<Finding[]> {
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-settled',
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 { open, closed } = readOutcomes(await readFile(live, 'utf8'))
535
- if (open.length > 0 || closed.length === 0) return []
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
- return [
538
- {
539
- kind: 'blocker-settled',
554
+ const pullRequest = readPullRequest(text)
555
+ if (pullRequest === undefined) {
556
+ return untestedRow(
540
557
  group,
541
558
  subject,
542
- message: `waits on ${cited}, which carries no open outcome.`,
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
- findings.push(...(await checkCitedTask(row.group, subject, cited, root)))
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(root: string): Promise<ValidateOutcome> {
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),