@erclx/aitk 3.34.1 → 3.35.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "3.34.1",
4
+ "version": "3.35.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -37,7 +37,9 @@ No count binds this. List the files the candidate's plan touches, from its `**Fi
37
37
 
38
38
  The board is not the whole set. A track a person launched by hand carries no row, so that column cannot see it, which is the ordinary shape whenever the operator is launching rather than dispatching. Read `aitk sessions list --json` for the branches in flight, and take the file set of any branch no row names from the plan that branch is building. A candidate cleared against the board alone is cleared against a partial reading.
39
39
 
40
- Take the comparison at the file path rather than at a folder above it. `aitk tasks validate` compares path segments, and on 2026-08-28 it reported two rows as colliding on `src` where one writes `src/github.ts` and the other `src/markdown/structure.ts`. Most of the CLI sits under `src/`, so a folder-level reading fires on nearly every parallel pair and buries the one real collision that same run caught, `.claude/ARCHITECTURE.md` held by two rows. Read that verb's output as a candidate list and settle each pair by file.
40
+ Take the comparison at the file path rather than at a folder above it. `aitk tasks validate` compares the paths each row wrote, so a collision it reports on a folder means a row's Touches cell claimed that folder rather than the verb widening anything. On 2026-08-28 it called two rows colliding on `src` because one cell named the bare folder while the other wrote `src/markdown/structure.ts`, which this paragraph once misread as the verb comparing path segments too coarsely.
41
+
42
+ The finding names which row contributed the containing path, and a bare-folder cell reports as a claim of its own beside the findings. Read that output as a candidate list, settle each pair by file, and narrow the cell that over-claimed rather than discounting the collision it caused.
41
43
 
42
44
  Disjointness is necessary and not sufficient, so hold a candidate whose sets do not touch when a stated reason serializes it, and write the reason on the hold. One row creating a skill and another auditing that catalog and counting it write nothing in common, measured 2026-08-27, and dispatching both still leaves the audit counting a denominator that moves underneath it. Nothing verifies that a reason was written, so the rule holds only while the dispatcher applies it.
43
45
 
@@ -129,6 +129,8 @@ A backlog line is a bullet carrying a link to a sibling task, since the backlog
129
129
 
130
130
  The collision check is the one a person cannot run by eye. Paths come from the backticked spans in the Touches column, a span naming no file is dropped, and a directory collides with any file beneath it. A `## Run now` row whose column parses to nothing is reported rather than skipped, since a row stating no file set makes a claim nothing can check.
131
131
 
132
+ Where a directory holds the other row's file, the finding names the row that claimed it, reading `both touch src/tasks, which v2.0-second claims as a folder.` The shared strings alone leave an over-broad cell and a genuine overlap identical, which is how a correct report was once read as the verb comparing folders rather than files.
133
+
132
134
  The blocker check re-takes a measurement the board records once and never repeats. Two of the five blocker kinds put a fact on disk: a dependency is settled by the cited task being archived or by its work reaching the trunk, and a collision is settled by nothing under `## Run now` still holding the file the cell cites.
133
135
 
134
136
  A closed outcome is not the fact the dependency half needs. The ship chain marks outcomes as its first step and opens the pull request several steps later, so a check reading the checkbox reports the row settled while the branch is still in review. A live task therefore settles the row only once it closed every outcome and carries a `Pull request:` line the trunk holds. One that names no pull request, and one whose number no trunk ref could answer for, land in the untested array below rather than being settled or left silent.
@@ -155,6 +157,24 @@ The other three kinds rest on a person's judgment, so a row neither half reached
155
157
 
156
158
  An untested row is not a finding and moves no exit code. Reading a clean findings list as a clean board is the failure the array exists to prevent, and `orchestrator-parked.md` is the pass that takes those rows by hand.
157
159
 
160
+ A `## Run now` row whose Touches column names a bare folder lands in a third array on the same reasoning:
161
+
162
+ ```json
163
+ {
164
+ "claims": [
165
+ {
166
+ "group": "Run now",
167
+ "subject": "v1.0-first",
168
+ "message": "claims the whole src/tasks folder, so it collides with every row written under it."
169
+ }
170
+ ]
171
+ }
172
+ ```
173
+
174
+ That claim collides with every row a later session writes under the folder, and it is legitimate whenever the row does rewrite the directory, so the array states the reach and moves no exit code. A measure failing on a cell that is right teaches a reader to skip it. Folder against file is decided by asking the tree for a path that resolves, and by the extension only for a path the row has yet to create, since the name alone reads a file carrying no extension as a folder.
175
+
176
+ The scan reaches `## Run now` and stops, where the collision check stops. A cell in another group describes work nobody has planned, written as a sentence and rewritten once a plan exists, so a claim read off one reports on prose rather than on a file set. A parked folder claim surfaces when its row is promoted, which is when the cell becomes something a dispatcher can act on.
177
+
158
178
  Exit codes: `0` every check passed, `1` refused, `2` at least one finding. The `reason` field carries which gate refused: `no-board`, `no-ordering`, or `no-groups`. A board grouping under headings of its own trips `no-groups` rather than being read against columns it never declared.
159
179
 
160
180
  Columns are read from each table's own header rather than by position, so a project whose board differs from this one is reported for what it lacks. The `index`, `priority`, and `backlog` siblings are skipped, along with every pre-compaction handoff, which takes one file per session under a `session-` prefix. None of them is a task, and a handoff counted as one would be reported as a task carrying no row on every session that wrote one.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.34.1",
4
+ "version": "3.35.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -17,6 +17,7 @@ import {
17
17
  } from '@/tasks/record'
18
18
  import {
19
19
  type Finding,
20
+ type FolderClaim,
20
21
  type Untested,
21
22
  type ValidateOutcome,
22
23
  validateBoard,
@@ -581,6 +582,16 @@ function reportValidation(
581
582
  )
582
583
  for (const row of outcome.untested) logWarn(describeUntested(row))
583
584
  }
585
+
586
+ // A folder claim is often the correct way to say a row rewrites a whole
587
+ // directory, so it reports beside the findings and moves no exit code. A
588
+ // measure that fails on a legitimate cell trains a reader to skip it.
589
+ logStep('Folder claims')
590
+ if (outcome.claims.length === 0) {
591
+ logInfo('every run now row names files rather than folders')
592
+ } else {
593
+ for (const claim of outcome.claims) logWarn(describeClaim(claim))
594
+ }
584
595
  outro()
585
596
  }
586
597
 
@@ -594,6 +605,7 @@ function reportValidation(
594
605
  tasks: outcome.tasks,
595
606
  findings: outcome.findings,
596
607
  untested: outcome.untested,
608
+ claims: outcome.claims,
597
609
  })}\n`,
598
610
  )
599
611
  }
@@ -610,6 +622,10 @@ function describeUntested(row: Untested): string {
610
622
  return `${row.group}: ${row.subject} ${row.message}`
611
623
  }
612
624
 
625
+ function describeClaim(claim: FolderClaim): string {
626
+ return `${claim.group}: ${claim.subject} ${claim.message}`
627
+ }
628
+
613
629
  async function runArchive(
614
630
  task: string | undefined,
615
631
  opts: ArchiveCommandOptions,
@@ -1,4 +1,4 @@
1
- import { existsSync } from 'node:fs'
1
+ import { existsSync, statSync } from 'node:fs'
2
2
  import { readdir, readFile } from 'node:fs/promises'
3
3
  import { join, resolve } from 'node:path'
4
4
  import {
@@ -63,6 +63,19 @@ export interface Untested {
63
63
  readonly message: string
64
64
  }
65
65
 
66
+ /**
67
+ * A `Touches` cell naming a bare folder, which claims every file under it and
68
+ * so collides with every row a later session writes there. The claim is often
69
+ * correct, since a row rewriting a whole directory has no other way to say so,
70
+ * which is why it reports beside the findings rather than inside them and moves
71
+ * no exit code.
72
+ */
73
+ export interface FolderClaim {
74
+ readonly group: BoardGroup
75
+ readonly subject: string
76
+ readonly message: string
77
+ }
78
+
66
79
  /**
67
80
  * A backlog line, which carries a pointer and nothing else. The backlog is
68
81
  * explicitly unordered, so a line has no position to read and no columns to
@@ -91,6 +104,7 @@ export interface ValidateReport {
91
104
  readonly tasks: number
92
105
  readonly findings: readonly Finding[]
93
106
  readonly untested: readonly Untested[]
107
+ readonly claims: readonly FolderClaim[]
94
108
  }
95
109
 
96
110
  export interface ValidateRefused {
@@ -164,6 +178,23 @@ function sharesPath(left: string, right: string): boolean {
164
178
  )
165
179
  }
166
180
 
181
+ /**
182
+ * Whether a `Touches` path names a directory. The tree answers for a path that
183
+ * resolves, which is the only reading that separates a folder from a file
184
+ * carrying no extension, such as a hook script.
185
+ *
186
+ * A path the row has yet to create resolves to nothing, so the name decides
187
+ * there. `readPaths` admits a span for one of two reasons, a slash or an
188
+ * extension, so a span surviving without an extension is one a slash let
189
+ * through and reads as a folder.
190
+ */
191
+ function isFolder(path: string, root: string): boolean {
192
+ const target = resolve(root, path)
193
+ if (existsSync(target)) return statSync(target).isDirectory()
194
+
195
+ return !/\.[A-Za-z][A-Za-z0-9]*$/.test(path)
196
+ }
197
+
167
198
  function splitCells(line: string): string[] {
168
199
  return line
169
200
  .trim()
@@ -457,17 +488,22 @@ function checkCollisions(rows: readonly BoardRow[]): Finding[] {
457
488
  for (let j = i + 1; j < ready.length; j += 1) {
458
489
  const left = ready[i]
459
490
  const right = ready[j]
460
- const shared = (left.touches ?? []).filter((path) =>
461
- (right.touches ?? []).some((other) => sharesPath(path, other)),
462
- )
491
+ const shared = (left.touches ?? []).flatMap((path) => {
492
+ const other = (right.touches ?? []).find((candidate) =>
493
+ sharesPath(path, candidate),
494
+ )
495
+ return other === undefined
496
+ ? []
497
+ : [describeShared(path, other, left, right)]
498
+ })
463
499
 
464
500
  if (shared.length === 0) continue
465
501
 
466
502
  findings.push({
467
503
  kind: 'touches-collided',
468
504
  group: 'Run now',
469
- subject: `${left.stem ?? left.label} and ${right.stem ?? right.label}`,
470
- message: `both touch ${shared.join(', ')}.`,
505
+ subject: `${subjectOf(left)} and ${subjectOf(right)}`,
506
+ message: `both touch ${joinShared(shared)}.`,
471
507
  })
472
508
  }
473
509
  }
@@ -475,6 +511,75 @@ function checkCollisions(rows: readonly BoardRow[]): Finding[] {
475
511
  return findings
476
512
  }
477
513
 
514
+ function subjectOf(row: BoardRow): string {
515
+ return row.stem ?? row.label
516
+ }
517
+
518
+ /**
519
+ * Names one path two rows share, and the row that claimed it as a folder when
520
+ * one side named a directory holding the other's file. Which side contributed
521
+ * the containing path is the fact a reader acts on, and printing the shared
522
+ * strings alone leaves an over-broad cell and a genuine overlap identical.
523
+ *
524
+ * The shorter path is the container, because `sharesPath` holds for an unequal
525
+ * pair only when one is a prefix of the other up to a separator.
526
+ */
527
+ function describeShared(
528
+ path: string,
529
+ other: string,
530
+ left: BoardRow,
531
+ right: BoardRow,
532
+ ): string {
533
+ if (path === other) return path
534
+
535
+ const container = path.length < other.length ? path : other
536
+ const owner = container === path ? left : right
537
+ return `${container}, which ${subjectOf(owner)} claims as a folder`
538
+ }
539
+
540
+ /**
541
+ * A folder clause carries a comma of its own, so a comma between clauses would
542
+ * read as another path. The plain list keeps the comma it has always had.
543
+ */
544
+ function joinShared(clauses: readonly string[]): string {
545
+ const separator = clauses.some((clause) => clause.includes(',')) ? '; ' : ', '
546
+ return clauses.join(separator)
547
+ }
548
+
549
+ /**
550
+ * Reports a `Touches` cell naming a bare folder. The claim collides with every
551
+ * row a later session writes under that folder, and it is legitimate whenever
552
+ * the row does rewrite the directory, so this states the reach rather than
553
+ * calling it a defect.
554
+ *
555
+ * The scan takes `## Run now` alone, where `checkCollisions` takes it. A cell in
556
+ * another group describes work nobody has planned, so it is written as a
557
+ * sentence and rewritten at planning time, and a claim read off one reports on
558
+ * prose rather than on a file set. That is the shape that teaches a reader to
559
+ * skip the report. A parked folder claim surfaces when the row is promoted,
560
+ * which is also when its cell becomes a set anything can act on.
561
+ */
562
+ function checkFolderClaims(
563
+ rows: readonly BoardRow[],
564
+ root: string,
565
+ ): FolderClaim[] {
566
+ const claims: FolderClaim[] = []
567
+
568
+ for (const row of rows.filter((candidate) => candidate.group === 'Run now')) {
569
+ for (const path of row.touches ?? []) {
570
+ if (!isFolder(path, root)) continue
571
+
572
+ claims.push({
573
+ group: row.group,
574
+ subject: subjectOf(row),
575
+ message: `claims the whole ${path} folder, so it collides with every row written under it.`,
576
+ })
577
+ }
578
+ }
579
+
580
+ return claims
581
+ }
582
+
478
583
  /**
479
584
  * Reads the task a blocker cell cites. A task pointer is a bare sibling
480
585
  * filename, the way every `Task` column spells one, so a target carrying a
@@ -734,5 +839,6 @@ export async function validateBoard(
734
839
  tasks: stems.length,
735
840
  findings,
736
841
  untested: parked.untested,
842
+ claims: checkFolderClaims(rows, root),
737
843
  }
738
844
  }