@erclx/aitk 0.76.0 → 0.77.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "0.76.0",
4
+ "version": "0.77.1",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -13,6 +13,10 @@ The queue fails in both directions. It empties and a free worker waits, or it fi
13
13
 
14
14
  Refilling on a merge and on a thinning ready list leaves the window between them unwatched. A wave mid-build has merged nothing and has moved no rows, so both conditions stay silent across the whole build and the wave finishes into an empty queue, which lands planning on the critical path directly after the stretch where it would have cost nothing.
15
15
 
16
+ Every one of those triggers looks forward, so a row already parked is measured once and re-tested never. A blocker cell is a claim about the tree that was true the day it was written, and a merge changes the tree under every parked row at once rather than under the rows naming it. A row therefore sits on a condition that stopped holding weeks earlier with nothing reporting the gap.
17
+
18
+ Re-measuring one goes wrong twice over, and both ways return a confident number rather than an error. A count read looser than its consumer defines the shape answers a question nobody asked, and a condition measured against the tree at hand reads as unreachable while it stays live in the corpus a shipped command runs against. Waiting is not evidence either, so a row untouched for weeks invites promotion on its age, and a task colliding with everything by construction gets re-measured every pass rather than split.
19
+
16
20
  Writing those plans against the tree alone is the second half, since several branches are already changing the shape a plan describes. A plan carrying a bare path list rather than a constraint per in-flight track leaves the worker guessing which act each path forbids, which is the dangling citation the rule against bare paths already exists to prevent.
17
21
 
18
22
  The session also writes surfaces it is told only how to read. Refilling the queue promotes and demotes rows in the priority file with no stated method, so a session invents one, and an inline rewrite exits clean when it matches nothing and leaves the board wrong with nothing reporting it. A row carrying prose where a plan pointer belongs costs a worker dispatch, because the ship chain refuses at its guard after the worktree is already open. A plan archived from a worker's own branch strands the pointer the board still carries, and the row reads as correct until someone follows it.
@@ -23,7 +27,7 @@ Output drifts everywhere the contract stops. The specified shape covers invocati
23
27
 
24
28
  Naming that reader without naming the way in fails on the next axis, and it fails circularly. The command that routes a resume request lives in this body, which is the surface a long session is likeliest to have dropped, and dropping it is the condition the handoff exists to survive, so the session holding a complete handoff cannot reach the runbook that reads it back. The review poll is lost in the same moment and recovered by neither, since it is session-scoped and the resume performs no restart, which leaves a reader who follows the whole handoff with a board and no trigger.
25
29
 
26
- The runbooks the session cannot run from memory fail on a third axis, which is where they are kept. A runbook reached by an installed path is a dependency on a channel this skill does not travel on, so a project holding the plugin and running no install follows the citation to nothing, and nothing reports the break because a missing file produces no error until someone opens the path. Every one of them therefore has to ship inside the skill, the three covering the moments the loop cannot detect and the fourth holding the review trigger. That fourth one fails on an axis of its own, since it names one client's command as the way to start the loop, so a session holding a different recurring-prompt scheduler reaches no path and a client without that command reaches none either, and the file ships to every target holding the plugin.
30
+ The runbooks the session cannot run from memory fail on a third axis, which is where they are kept. A runbook reached by an installed path is a dependency on a channel this skill does not travel on, so a project holding the plugin and running no install follows the citation to nothing, and nothing reports the break because a missing file produces no error until someone opens the path. Every one of them therefore has to ship inside the skill, the four covering the moments the loop cannot detect and the fifth holding the review trigger. That last one fails on an axis of its own, since it names one client's command as the way to start the loop, so a session holding a different recurring-prompt scheduler reaches no path and a client without that command reaches none either, and the file ships to every target holding the plugin.
27
31
 
28
32
  The session also records nothing of what it learns. Both other callers of memory capture are ship-chain skills and this one never ships, so the session taking every operator correction is the session with no moment that writes one down. Hanging that moment on the merge sweep answers it and bills the operator a capture pass per batch of merges while nothing is being built, which is a cost paid on the days shipping is fastest.
29
33
 
@@ -52,6 +56,11 @@ The session also records nothing of what it learns. Both other callers of memory
52
56
  - State which rule wins where the tracked-file ban collides with the root instruction on small edits, and where a correction no open task owns goes
53
57
  - State the review trigger as a recurring prompt rather than as one client's command, since the runbook ships to targets running whatever client they run
54
58
  - Read the handoff or the resume runbook when the human asks for that side of a compaction, since the request is the only signal available for a moment the loop cannot detect
59
+ - Re-test every parked row's blocker against the current tree on request, since every refill trigger looks forward and none of them re-reads a row already waiting
60
+ - Measure a condition the way the code consuming it defines the shape, and settle which tree the condition is about before counting, since both errors return a number rather than a failure
61
+ - Write each re-test into the row and its measurement into that task's Findings, since a result reported in chat is gone at the next compaction and the next pass measures the same thing
62
+ - Plan a row the re-test clears, since a cleared row carrying no plan is one the next pass looks at again
63
+ - Split a task whose file set collides with every other by construction, rather than re-measuring a scoping defect that reads as a blocker
55
64
 
56
65
  ## Must not
57
66
 
@@ -61,6 +70,8 @@ The session also records nothing of what it learns. Both other callers of memory
61
70
  - Hand a worker anything but a plan, because scope lives there
62
71
  - Run a second orchestrator against the same board
63
72
  - Promote a task to fill the queue when nothing qualifies. A thin queue is a real answer.
73
+ - Promote a parked row on how long it has waited, since age is not a measurement of the blocker
74
+ - Schedule the parked-row pass on a recurring loop, since the review poll owns the one recurring trigger this skill has and a second loop fires into a static board
64
75
  - Rewrite the board with a shell stream editor or an inline string replace, both of which exit clean when they match nothing
65
76
  - Restate the board, a task file, or a groundwork folder in the pre-compact handoff, which is the padding that makes a handoff stop being read
66
77
  - Specify a shape for a correction, since a format for admitting error invites ceremony where plainness is the whole value
@@ -71,6 +82,7 @@ The session also records nothing of what it learns. Both other callers of memory
71
82
  - Roadmap absent: omit the roadmap line rather than asserting a version the file does not state
72
83
  - Roadmap present but never committed: report the date as uncommitted, since a blank reads as a formatting slip
73
84
  - This body dropped from a long session approaching a compaction: name the re-invocation and the runbook paths, since the routing lives in the body and a user-invoked skill routes nothing once it is gone
85
+ - Blocker only an operator can clear: record the row as untestable this pass and name the action owed, rather than re-measuring what no session can move
74
86
 
75
87
  ## Out of scope
76
88
 
@@ -39,7 +39,9 @@ A compaction is a moment this skill cannot detect, so the human asks for each si
39
39
 
40
40
  The review trigger takes the same shape. `references/orchestrator-poll.md` holds the loop prompt and the condition under which the poll runs, and `scripts/poll.sh` is what the prompt invokes. A session holding a recurring-prompt scheduler starts and cancels that loop itself, and no hook or check does, so the condition holds only while whoever holds the loop applies it.
41
41
 
42
- That routing lives in this body and this skill is user-invoked, so a session that has dropped the body routes nothing and the request lands as ordinary conversation. Approaching a compaction is when a long session is likeliest to have dropped it, which is the same moment the handoff exists for. Re-invoke `/aitk:claude-orchestrate` first whenever the session has run long or the ask goes unanswered. The two runbooks sit at `references/orchestrator-handoff.md` and `references/orchestrator-resume.md` inside this skill's own folder, so a person who knows their plugin root opens either one directly and follows it without this skill loaded at all.
42
+ A board that is not moving is a third such moment. On a request to re-test the parked rows, read `${CLAUDE_SKILL_DIR}/references/orchestrator-parked.md` and follow it. It re-tests every `## Up next` and `## Needs a plan` blocker against the current tree, writes what each test showed into the row, and plans what it clears. Its trigger is the inverse of the refill sweep's below, which fires on a merge and asks what to promote next rather than whether a row already parked is still parked for a reason.
43
+
44
+ That routing lives in this body and this skill is user-invoked, so a session that has dropped the body routes nothing and the request lands as ordinary conversation. Approaching a compaction is when a long session is likeliest to have dropped it, which is the same moment the handoff exists for. Re-invoke `/aitk:claude-orchestrate` first whenever the session has run long or the ask goes unanswered. The three runbooks sit at `references/orchestrator-handoff.md`, `references/orchestrator-resume.md`, and `references/orchestrator-parked.md` inside this skill's own folder, so a person who knows their plugin root opens any one of them directly and follows it without this skill loaded at all.
43
45
 
44
46
  ## Output
45
47
 
@@ -129,7 +131,7 @@ It counts unclaimed plans against workers rather than reading the reserve in ste
129
131
  3. Archive what closed. A task whose outcomes are all `[x]` runs `claude-docs` for the plan sweep, then `claude-tasks` to archive. A task whose outcomes describe standing policy rather than a deliverable never closes on its own, so hand it to a worker to encode the policy where it is enforced, then cut the outcomes with the reason recorded and archive once that branch merges. Encoding it from this session would write a tracked file, which Boundaries forbids.
130
132
  4. Read `.claude/tasks/priority.md` and count entries under its `## Run now` heading that carry a written plan. Keep one in reserve beyond what is running.
131
133
  5. Promote by whether a task establishes functionality rather than by age. Prefer a task that adds or proves a mechanism over one that trims, tidies, or audits an existing surface.
132
- 6. Before promoting a candidate, list the files it touches against every task already running, per Parallelism below. Name the overlap and serialize when the sets are not disjoint.
134
+ 6. Before promoting a candidate, list the files it touches against every task already running, per Parallelism below. Name the overlap and serialize when the sets are not disjoint. A candidate held by something outside the tree stays where it is whatever those sets show. A collision is one of the reasons a task cannot start, so disjointness clears that reason alone and leaves an external condition standing.
133
135
  7. Write a plan for each newly promoted task with `claude-feature`, carrying the in-flight constraint that The loop above states, then report:
134
136
 
135
137
  ```plaintext
@@ -0,0 +1,72 @@
1
+ ---
2
+ title: Orchestrator parked row runbook
3
+ description: Re-testing every parked blocker against the current tree, the trigger that separates this pass from the merge sweep, and the two ways a re-test goes wrong
4
+ ---
5
+
6
+ Re-test every parked row as orchestrator. A blocker cell is a measurement taken the day the row was parked and nothing re-takes it, so a row can wait on a condition that stopped holding weeks earlier with no surface reporting the gap.
7
+
8
+ Run this when nothing merged and the board is not moving. Workers are building, no pull request is waiting on a first pass, and the session is otherwise idle.
9
+
10
+ That trigger is the inverse of the sweep's. `orchestrator-sweep.md` runs after a batch of merges and asks which parked row to promote next, taking the blocker cell as read. This pass asks whether that cell is still true. Both walk the same rows and both can end in a promotion, so a session reading them as one pass runs whichever it remembers and re-tests nothing.
11
+
12
+ A merge is still worth following with this pass even though the sweep already ran. A merge changes the tree under every parked row at once rather than under the rows naming it, and the sweep re-reads none of them.
13
+
14
+ Nothing starts this pass, and it takes no scheduler of its own. `orchestrator-poll.md` owns the one recurring trigger this skill has, and a second loop firing into a static board is the always-on failure that file already warns about.
15
+
16
+ ## Scope
17
+
18
+ Every row under `## Up next` and `## Needs a plan` in `.claude/tasks/priority.md`. A `## Run now` row carries no blocker by definition, so the pass skips it. Resolve the board and each task file at the main worktree root, per Worktrees in `CLAUDE.md`.
19
+
20
+ Take the rows in board order and finish one before opening the next. Clearing a row changes what the next row collides with, so a pass that measures every row first and writes afterwards writes against a board it has already invalidated.
21
+
22
+ ## Re-testing a row
23
+
24
+ The blocker cell states what the row waits on, and each kind is tested differently.
25
+
26
+ - Collision with a track in flight: intersect the row's Touches column with that of every `## Run now` row. A track that merged since the row was parked is no longer in flight, whatever the sets still share. A `## Needs a plan` row carries no Touches column, so read its file set off the task file before claiming a collision either way.
27
+ - A dependency on another task: open that task. One whose outcomes are all `[x]`, or one already archived, holds nothing.
28
+ - A condition about the tree, such as a count of some shape or the presence of a construct: measure it again, per Two ways a re-test goes wrong below.
29
+ - Waiting on a plan: nothing external holds the row, so the pass writes the plan rather than testing anything. See The plan half below.
30
+ - Waiting on an operator action, such as a run that happens from a shell: record it as untestable this pass and name what the operator has to do. A session cannot clear it, and re-measuring it every pass is waste.
31
+
32
+ Write the result into the row. A re-test reported in chat is lost at the next compaction and the next pass measures the same thing again. Rewrite a blocker cell whose test no longer holds, move the row to the group its new state puts it in, and record the measurement in that task's `## Findings` with the date it was taken. `### Writing the board` in `claude-orchestrate` owns the method, and `aitk tasks validate` runs once the board is rewritten and before the report.
33
+
34
+ ## Two ways a re-test goes wrong
35
+
36
+ Both return a confident wrong answer rather than an error, which is why each gets a step of its own.
37
+
38
+ The first is reading the condition looser than the code defines it. A row parked on a count of some shape names a shape its consumer defines precisely, and a count taken by eye or by a pattern that approximates it lands somewhere else. The row that motivated this runbook counted 19 instances of a shape whose real count under the consumer's definition was zero.
39
+
40
+ Find the code that consumes the shape, read the definition off it, and measure with that. A count taken any other way is not evidence about the blocker.
41
+
42
+ The second is measuring against the wrong tree. A command that ships to targets is at risk in corpora this repository never formats, so a condition measured here reads as unreachable while it stays live where the command runs. Ask which tree the condition is about before asking what the count is. A local formatter removing the shape on contact says nothing about a target running no such formatter.
43
+
44
+ ## The plan half
45
+
46
+ Plan what the pass clears, and plan any `## Needs a plan` row whose only blocker is the missing plan. The window is the argument: this pass runs while workers build and the session is otherwise idle, which is when planning costs the critical path nothing, and a row cleared with no plan is a row the next pass looks at again.
47
+
48
+ Run `claude-feature` for each, carrying the constraint per in-flight track that `## The loop` in `claude-orchestrate` requires. Verify each plan against the tree the way that step does, since a plan written now is written against a tree several branches are already changing.
49
+
50
+ Stop where `## Parallelism` stops rather than planning every row the pass cleared. A plan whose file set collides with every track in flight is one nobody can dispatch.
51
+
52
+ Do not restate the refill procedure. `claude-orchestrate` owns it under `## Refilling the ready queue` and `orchestrator-sweep.md` wraps it for a batch of merges, so this pass promotes through that method rather than a second one.
53
+
54
+ ## Two results that are not a re-test
55
+
56
+ Age is not evidence. A row untouched for weeks invites promotion, and the waiting is not an argument for it. `claude-orchestrate` refuses to promote a task to fill the queue and this pass inherits that refusal, so a row whose blocker still holds stays where it is. Reporting it as still parked, against a measurement taken this pass, is a result.
57
+
58
+ A scoping defect can wear a blocker. A task whose file set collides with every other task by construction is oversized rather than blocked, and planning it again produces the same plan nobody can dispatch. Split it into tasks with disjoint file sets, so the board stops carrying a row that re-measures the same way every pass.
59
+
60
+ ## Output
61
+
62
+ ```plaintext
63
+ Re-tested: <row>, <blocker> → <what the measurement showed>
64
+ Cleared: <row>, now <group>, plan at <path>
65
+ Still parked: <row>, <blocker> re-confirmed against <what was measured>
66
+ Untestable: <row>, waits on <operator action>
67
+ Split: <task> into <tasks>, file set collided with everything by construction
68
+ ```
69
+
70
+ Omit any row with nothing in it. Name what was measured rather than the verdict alone, since a re-test the reader cannot check is the same claim the row already carried.
71
+
72
+ Lead the reply with the three slots under `### Every later turn` in `claude-orchestrate`, so the human reads what they own before the evidence for it.
@@ -19,7 +19,7 @@ It reports rather than gates by default, the closer call because a missing secti
19
19
 
20
20
  ## Length
21
21
 
22
- Length quotes its checkpoint from `.claude/standards/context.md`: roughly 150 rendered lines for an entry. It counts rendered lines rather than source lines, wrapping each line at 80 columns and summing the heights. A line is wrapped at the width it renders at, so a link counts as its anchor text and a backticked path counts whole. That moves 2 of the 48 entries measured, `extensions.md` by 2 lines and `governance.md` by 1, because an entry cites a path in backticks rather than as a link. Entries here are authored one line per bullet, so a block of fifteen paragraph-bullets occupies fifteen source lines and renders past sixty, which source counting cannot see. The measure counts fenced blocks and frontmatter, so a reference-heavy entry ranks by its examples, which the legend states on every run alongside the width, since a number in rendered lines cannot be reproduced without it.
22
+ Length quotes its checkpoint from `.claude/standards/context.md`: roughly 150 rendered lines for an entry, applied to each of the 60 entries the audit measures, 51 of them under `.claude/context/` and 9 diagram files beside them. It counts rendered lines rather than source lines, wrapping each line at 80 columns and summing the heights. A line is wrapped at the width it renders at, so a link counts as its anchor text and a backticked path counts whole, which parts the rendered count from a source count wherever an entry cites a path in backticks rather than as a link. Entries here are authored one line per bullet, so a block of fifteen paragraph-bullets occupies fifteen source lines and renders past sixty, which source counting cannot see. The measure counts fenced blocks and frontmatter, so a reference-heavy entry ranks by its examples, which the legend states on every run alongside the width, since a number in rendered lines cannot be reproduced without it.
23
23
 
24
24
  Depth and bullet weight are quoted from `.claude/standards/markdown.md`, which states both over every markdown file rather than over a context entry, so `aitk markdown audit` measures them and this command no longer does. They share `renderedHeight` with the length measure, since the two checkpoints sit in one section of that standard and a reader compares them. What the split costs is that a session wanting both numbers for one entry runs two commands, and what it buys is that either number can be had for a file in a folder this audit refuses to resolve.
25
25
 
@@ -45,7 +45,7 @@ A fence answers that test for itself, because every scan here skips a fenced lin
45
45
 
46
46
  The opening delimiter decides that for the whole block rather than each line deciding for itself. A blank line inside an indented fence carries no indentation to read, and a content line may sit at column zero inside one because CommonMark strips the fence's own indent and nothing further. Reading either as unindented ends a run that should have continued, which costs findings rather than inventing them, so no corpus count moves when it is wrong.
47
47
 
48
- Two fenced blocks with nothing between them read as one, so the second takes the first's answer. An unindented block written directly behind an indented one therefore leaves a run standing that it should have ended, which is a false positive rather than a missed finding. Separating the two needs the closing delimiter, which the line walker does not report, and no entry writes adjacent fences today.
48
+ Two fenced blocks with nothing between them are one contiguous run of fenced lines, so the mark alone cannot say where the first ends. The line walker reports which block each line sits in, and the boundary re-reads indentation whenever that number changes, so an unindented block written directly behind an indented one ends the run rather than inheriting the answer above it. No markdown file this repository tracks holds that shape, because a formatter inserts a blank line between two fences on contact, and a target running these commands over an unformatted tree still can.
49
49
 
50
50
  Precision is the whole value, so recall is the accepted exposure, and two shapes are knowingly out of reach. A narration written as one bullet carrying its own before and after slips through, and nothing else sees it either. So does the perfect passive, since `has been superseded` narrates a supersession and the copula guard rejects it with the passives it exists for. A rejected alternative is a back-reference in the past tense by construction, and the standard keeps what was tried and why it lost, so a legitimate hit exists and no measure separates it from a violation. The report states that on every run, which is why the finding names a line to read rather than a line to delete. The JSON record carries the findings per entry as `entries[].narration` and the sets as `checkpoints.narration`, which is absent under `--citations-only` where the run never loads them and null where no rule publishes both.
51
51
 
@@ -7,7 +7,7 @@ description: How aitk docs resolves the toolkit's own reference surface from an
7
7
 
8
8
  `aitk docs` emits the toolkit's own reference docs so an agent in a target project can orient without the toolkit source checked out. The CLI resolves `docs/` and `.claude/context/` from its install root, and which of the two it finds depends on how the CLI was installed. A registry install carries `docs/` alone, since `.claude/` is not published. A clone or a linked worktree carries both.
9
9
 
10
- - `aitk docs list [--json]` lists the downstream catalog: the consumer-facing `docs/` surface plus per-domain narrative from `.claude/context/` when that root is present. Toolkit-internal context entries (`ci`, `development`, `extensions`, `sandbox`) are dropped. From a registry install the context section is absent rather than empty.
10
+ - `aitk docs list [--json]` lists the downstream catalog: the consumer-facing `docs/` surface plus per-domain narrative from `.claude/context/` when that root is present. Toolkit-internal context entries (`ci`, `development`, `sandbox`) are dropped. From a registry install the context section is absent rather than empty.
11
11
  - `aitk docs <topic>` prints one doc to stdout, resolved by exact name from `docs/` first, then `.claude/context/`. Any doc the install carries is reachable by name, including the toolkit-internal topics the list omits.
12
12
 
13
13
  A domain too large for one file splits into `<domain>/` with a generated `index.md`, and both verbs name it by the folder. `aitk docs <domain>` prints that index, which is the catalog routing to the sub-area files, and the listing describes it from the index's `subtitle` where a sibling file supplies `description`. A sibling file wins over a folder of the same name. A folder carrying no `index.md` is absent from both, since a catalog is what makes the sub-areas reachable.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.76.0",
4
+ "version": "0.77.1",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -11,7 +11,7 @@ source "$PROJECT_ROOT/scripts/lib/frontmatter.sh"
11
11
  DOCS_DIR="$PROJECT_ROOT/docs"
12
12
  CONTEXT_DIR="$PROJECT_ROOT/.claude/context"
13
13
 
14
- INTERNAL_TOPICS=" ci development extensions sandbox "
14
+ INTERNAL_TOPICS=" ci development sandbox "
15
15
 
16
16
  show_help() {
17
17
  echo -e "${GREY}┌${NC}"
@@ -306,7 +306,8 @@ function narration(
306
306
 
307
307
  const findings: NarrationFinding[] = []
308
308
  let following = false
309
- let fenceInsideList: boolean | undefined
309
+ let fenceBlock: number | undefined
310
+ let fenceInsideList = false
310
311
 
311
312
  for (const line of lines) {
312
313
  // A fenced line is never a bullet, but an unindented block still ends the
@@ -314,29 +315,25 @@ function narration(
314
315
  // the bullets around it are two lists and the second has no antecedent
315
316
  // above it. A block indented under its bullet stays inside the item.
316
317
  //
317
- // The first delimiter of a contiguous fenced run decides for every line of
318
- // it. Reading each line instead ends the run on a blank line inside an
319
- // indented fence, which has no indentation to read, and on a content line
320
- // at column zero, which CommonMark permits since only the fence's own
321
- // indent is stripped.
318
+ // The opening delimiter decides for every line of its own block. Reading
319
+ // each line instead ends the run on a blank line inside an indented fence,
320
+ // which has no indentation to read, and on a content line at column zero,
321
+ // which CommonMark permits since only the fence's own indent is stripped.
322
322
  //
323
- // Two blocks with nothing between them are one such run, so the second
324
- // inherits the first's answer and an unindented block behind an indented
325
- // one leaves a run standing that should have ended. Telling them apart
326
- // needs the closing delimiter, which `BodyLine` does not carry, and parsing
327
- // one here would be the second fence walker this repository consolidated
328
- // away after its first pair disagreed. That fix belongs in
329
- // `src/markdown/scan.ts` rather than in a copy of it.
323
+ // The block index is what re-opens that decision, so two blocks written
324
+ // with nothing between them are answered separately rather than the second
325
+ // inheriting the first. Indentation stays the test here because what
326
+ // interrupts a list is this check's own judgment, and the walker only says
327
+ // which lines share a block.
330
328
  if (line.fenced) {
331
- if (fenceInsideList === undefined) {
329
+ if (line.fenceBlock !== fenceBlock) {
330
+ fenceBlock = line.fenceBlock
332
331
  fenceInsideList = INSIDE_LIST.test(line.text)
333
332
  }
334
333
  if (!fenceInsideList) following = false
335
334
  continue
336
335
  }
337
336
 
338
- fenceInsideList = undefined
339
-
340
337
  if (line.text.trim() === '') continue
341
338
 
342
339
  const bullet = line.text.match(TOP_BULLET)
@@ -39,6 +39,17 @@ export interface BodyLine {
39
39
  readonly text: string
40
40
  /** True on a fence delimiter and on every line between one pair. */
41
41
  readonly fenced: boolean
42
+ /**
43
+ * Which fenced block the line sits in, counted from one, and undefined
44
+ * outside one. Two blocks written with nothing between them are one
45
+ * contiguous run of fenced lines, so this is the only field telling them
46
+ * apart. Counting from one keeps the outside answer from reading as a block.
47
+ *
48
+ * The name carries the fence because `block` alone already names a run of
49
+ * `BodyLine` values in `src/markdown/structure.ts`, and one word meaning both
50
+ * an aggregate of lines and an index on each line reads as a bug.
51
+ */
52
+ readonly fenceBlock: number | undefined
42
53
  }
43
54
 
44
55
  export type BanKind = 'character' | 'word' | 'spelling'
@@ -58,17 +69,25 @@ export interface BanSets {
58
69
  }
59
70
 
60
71
  /**
61
- * Marks which lines sit inside a fence without dropping them.
72
+ * Marks which lines sit inside a fence without dropping them, and which block
73
+ * each of those lines belongs to.
62
74
  *
63
75
  * Each measure excludes a fence for its own reason and needs a different
64
76
  * response. The depth measure skips a fenced line so an example cannot break
65
77
  * the run around it, bullet folding treats one as a break so a bullet does not
66
78
  * absorb the block below it, and the ban scan ignores it outright. Returning
67
79
  * the mark rather than a filtered list is what lets one walk serve all three.
80
+ *
81
+ * The block index answers the consumer that has to decide per block rather
82
+ * than per contiguous run, since two adjacent blocks are indistinguishable
83
+ * through the mark alone. It is counted here because the walk already holds
84
+ * the opening delimiter, and a consumer parsing its own would be the second
85
+ * fence walker this repository consolidated away.
68
86
  */
69
87
  function markFences(texts: readonly string[], offset: number): BodyLine[] {
70
88
  const lines: BodyLine[] = []
71
89
  let fence: string | undefined
90
+ let fenceBlock = 0
72
91
 
73
92
  for (const [index, text] of texts.entries()) {
74
93
  const match = FENCE.exec(text.trim())
@@ -82,9 +101,15 @@ function markFences(texts: readonly string[], offset: number): BodyLine[] {
82
101
  } else if (match) {
83
102
  fenced = true
84
103
  fence = match[1]
104
+ fenceBlock += 1
85
105
  }
86
106
 
87
- lines.push({ number: offset + index + 1, text, fenced })
107
+ lines.push({
108
+ number: offset + index + 1,
109
+ text,
110
+ fenced,
111
+ fenceBlock: fenced ? fenceBlock : undefined,
112
+ })
88
113
  }
89
114
 
90
115
  return lines