@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.
@@ -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.103.0",
4
+ "version": "0.104.1",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -227,6 +227,10 @@ A board carrying one task written `../plans/x.md` and another written `.claude/p
227
227
 
228
228
  Exclude the closing task explicitly. It sits on the board and cites the plan itself, so a scan that counts it never reaches zero and no plan is ever archived.
229
229
 
230
+ `aitk tasks plan-citations <stem> --json` answers this same question, and the archive gate already reads it. This body states the rule anyway rather than calling the verb, because a plugin skill reaches a target the moment it merges while the CLI reaches one only when a release publishes, so a target whose installed `aitk` predates the verb gets no record back and routes on nothing. Measured against the `claude:docs` `board-sweep` arm, where calling the verb archived neither plan and created no `.claude/plans-archive/`.
231
+
232
+ Nothing in the exit code reports that. Branch on the record's `ok` and `reason` fields and never on the exit, which is the rule every task verb already carries: an operator's shell profile may wrap `aitk` in a function that runs the binary and then another command, taking its status from the second, and one measured here masks every non-zero exit rather than only an absent verb. The binary itself exits 1 for an unknown subcommand and 1 for an ordinary refusal alike. Switching this body to the verb needs a release that carries it and a read of the record rather than the exit, which together retire the duplication.
233
+
230
234
  A plan can serve more than one task, and archiving on the first task to close strands every other task's pointer at a path that has moved. `.claude/plans/` is gitignored, so that retarget would be the only record and there is nothing to recover it from.
231
235
 
232
236
  - Target resolves inside `.claude/plans/`, the file exists, and no other task file cites it: create `.claude/plans-archive/`, move the file there under its original name, overwriting any file already sitting at that name. Then rewrite the task file's `Plan:` line to the archive path, so a completed task still leads to the reasoning behind it.
@@ -40,7 +40,7 @@ From a linked worktree the file-editing tools refuse every path under the main r
40
40
 
41
41
  A topic the listing already carries is a resume, and anything else is a new workspace. An invocation asking to promote is neither: read the named workspace through the listing and go to Step 6, which teaches nothing and writes no lesson.
42
42
 
43
- On a resume, run `aitk teach list <topic> --json` for the files behind each count, then read `MISSION.md`, the highest-numbered learning record, and `GLOSSARY.md`. Those three carry where the learner stopped and what they got wrong. Report the mission's success lines with what is already met before teaching anything.
43
+ On a resume, run `aitk teach list <topic> --json` for the files behind each count, then read the highest-numbered learning record and `GLOSSARY.md`. Those carry where the learner stopped and what they got wrong. The listing record carries `success`, the mission's success lines, which are the exit criteria this workspace is finished against. Report them with what is already met before teaching anything.
44
44
 
45
45
  On a new workspace, settle the starting point first, by asking rather than by assuming. Difficulty with no floor under it teaches nobody, and the mission cannot be written without it.
46
46
 
@@ -80,9 +80,27 @@ Open with retrieval on what the last session got wrong, before anything new. A l
80
80
 
81
81
  Two outputs with two lifetimes, and the split decides the format.
82
82
 
83
- - A lesson goes to `lessons/<nnnn>-<slug>.html`, self-contained, carrying its own quiz and the feedback for each answer. It links the shared stylesheet under `assets/` rather than restating styles, and the first lesson in a workspace writes that stylesheet before linking it. A lesson is disposable and is never promoted.
83
+ - A lesson is a self-contained page carrying its own quiz and the feedback for each answer. It links one shared stylesheet rather than restating styles, and it is disposable and never promoted.
84
84
  - A reference page goes to `reference/<slug>.md`, written for a reader with no learner in it. This is the half that survives the workspace, so it is written in markdown to pass the authoring gates a promotion would put it through.
85
85
 
86
+ Resolve the lesson before writing it, rather than composing its name or its quiz order by hand:
87
+
88
+ ```bash
89
+ aitk teach lesson <topic> --json \
90
+ --slug <kebab slug for what this lesson covers> \
91
+ --questions <how many questions the quiz carries> \
92
+ --options <how many options each question carries>
93
+ ```
94
+
95
+ It writes nothing and reports four things:
96
+
97
+ - `lesson`, the numbered path the lesson takes. Write it there.
98
+ - `stylesheet` with `stylesheetExists` and `stylesheetHref`. Write the stylesheet only when it reports absent, which is the first lesson in the workspace. Every lesson after that links the file already on disk and adds to it rather than replacing it, since overwriting discards what the last lesson put there.
99
+ - `success`, the mission's success lines, carried here so Step 5 needs no second read.
100
+ - `quiz`, one entry per question, carrying `order` and `answer`.
101
+
102
+ Write the correct option first, then present the options in the order `order` reports, reading it as authored indices where `0` is the correct one. Take the order as given. Position drawn here rather than chosen is the whole reason the verb exists, and a lesson that reorders on its own judgment puts the answer back in the first slot.
103
+
86
104
  Add every term the lesson defines to `GLOSSARY.md` through the verb, which places the entries alphabetically in the shape the standard fixes:
87
105
 
88
106
  ```bash
@@ -101,7 +119,7 @@ Write `learning-records/<nnnn>-<slug>.md` before the session ends, carrying the
101
119
 
102
120
  Record the wrong answer rather than the count. The next session places the learner from this file, and a tally carries no misconception to work against.
103
121
 
104
- Then restate the mission's success lines with what is now met. A mission whose lines are all met is finished, and saying so is what closes a workspace.
122
+ Then restate the mission's success lines with what is now met, reading them from the `success` the lesson verb already reported rather than from `MISSION.md` by eye. Report each line as met or not met, and name what the learner did that meets it. A mission whose lines are all met is finished, and saying so is what closes a workspace.
105
123
 
106
124
  ## Step 6: propose where the durable half belongs
107
125
 
@@ -13,6 +13,7 @@ A workspace accumulates lessons over weeks. The learner reads them as one body o
13
13
 
14
14
  - Write the shared stylesheet into the workspace assets on the first lesson, and link it from every lesson after
15
15
  - Promote anything used a second time into that stylesheet. A second use makes it a component of the course.
16
+ - Add to that stylesheet rather than replacing it. The lesson writing it is rarely the lesson that needs it changed, and a rewrite drops what every earlier lesson links it for.
16
17
  - Keep the structural furniture identical across lessons: where the title sits, where the quiz sits, what a correct answer looks like
17
18
  - Do not restate styles inside a lesson. A local override is a decision the next lesson has to either copy or contradict.
18
19
 
@@ -39,11 +40,17 @@ Every element competes with the material for attention.
39
40
  The quiz is the retrieval, so a leak in its construction turns it into a reading test.
40
41
 
41
42
  - Write every option to the same length, in words and in characters. A longer option reads as the considered one.
42
- - Vary which position holds the correct answer, and never let the order follow the order the material was taught in
43
+ - Write the correct option first and let the ordering verb place it. Position is not the author's to pick: an author who varies it by hand still varies it by judgment, and the judgment settles on the first slot.
43
44
  - Make each wrong option a misconception someone actually holds. An obviously wrong option removes itself and shrinks the question.
44
45
  - Write feedback for every option, including the correct one, saying why rather than whether
45
46
  - Give feedback after the attempt, never alongside the question
46
47
 
48
+ ## Tokens travel with the course
49
+
50
+ A lesson carries its own values in the shared stylesheet rather than reading a host project's. A workspace runs in any project and most carry no token record at all, so a lesson inheriting one is a lesson that renders unstyled wherever the record is absent, with nothing reporting it.
51
+
52
+ Pick values the material needs rather than values the project happens to hold. Inheriting is worth offering as something a learner asks for once, and it is the wrong default in every project that cannot answer.
53
+
47
54
  ## What makes a lesson worth returning to
48
55
 
49
56
  - The worked example is complete. A learner returning for the example finds the whole thing rather than a fragment they have to reconstruct.
@@ -26,4 +26,4 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
26
26
  - [Sessions](sessions.md): Resolving live peer sessions to the worktree and branch each holds, the liveness confidence field, the unresolved reasons, and what the read depends on
27
27
  - [Skill audit](skills-audit.md): Measuring both skill corpora against standards/skill.md, the checks it reads, the requirement gate that is the only failing one, and the drift verb that names bodies rewritten since a ref
28
28
  - [Tasks](tasks.md): Selecting a shipped task by stem or pull request, recording a number and closing an outcome, the refusal reasons, the board and backlog checks validate runs, and why the board root defaults to the main worktree
29
- - [Teach](teach.md): Listing learning workspaces and the ordinal a new one takes, opening one with its required files, recording sources and glossary terms, the refusal reasons, and why every write here runs through a verb
29
+ - [Teach](teach.md): Listing learning workspaces and the ordinal a new one takes, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, the refusal reasons, and why every write here runs through a verb
@@ -24,6 +24,8 @@ aitk tasks archive --pull-request 673 --json
24
24
 
25
25
  Exit codes: `0` archived, `1` refused. Every gate is a refusal rather than a warning, because `.husky/post-merge` calls this with nobody watching. The `reason` field carries which gate fired: `no-board`, `no-match`, `ambiguous`, `no-outcomes`, `open-outcomes`, `plan-unswept`, or `bad-input`.
26
26
 
27
+ `plan-unswept` fires on the last task pointing at a live plan, never on every task pointing at one. The gate counts the other live tasks whose `Plan:` line resolves onto the same file, so a plan several tasks share archives its tasks freely and only the final one is held until `claude-docs` sweeps the plan. Reading the folder alone refused all of them, which deadlocked the board against a sweep correctly declining to move a plan another live task cites.
28
+
27
29
  `bad-input` covers a malformed command line, which all three task verbs answer the same way. It is separate from `ambiguous` and `no-match` because those describe the board, and a caller that passed two selectors would otherwise be sent to repair a task citation that is fine.
28
30
 
29
31
  The board is shared scratch at the main worktree root, so `--root` defaults to the first entry of `git worktree list` rather than the working directory. A linked worktree archives against the same board every other session reads.
@@ -34,6 +36,27 @@ Skills branch on the reason rather than on the exit code:
34
36
  aitk tasks archive --pull-request 673 --json | jq -r 'if .ok then .task else .reason end'
35
37
  ```
36
38
 
39
+ ## Plan citations
40
+
41
+ `aitk tasks plan-citations <stem>` answers where a task's plan sits and which other live tasks hold it. It reports and never writes.
42
+
43
+ | Option | Effect |
44
+ | --------------- | ------------------------------------------- |
45
+ | `--json` | Emit a machine-readable record on stdout |
46
+ | `--root <path>` | Board root, defaulting to the main worktree |
47
+
48
+ The record carries `location`, one of `unstated`, `live`, `archived`, or `outside`, and `citedBy`, the other live tasks whose `Plan:` line lands on the same file. Exit codes: `0` read, `1` refused with `no-board` or `no-match`.
49
+
50
+ `aitk tasks archive` gates on this same answer, so a caller wanting the count reads it here rather than scanning the board. The `claude-docs` plans sweep is the exception and still states the rule in its own body, because a plugin skill reaches a target on merge while the CLI reaches one on release, so a sweep calling a verb the installed `aitk` predates gets no record back and archives nothing.
51
+
52
+ Branch on `reason` rather than on the exit code, which is the rule the archive section above already states and which this verb needs for a second reason. An operator's shell profile may wrap `aitk` in a function that runs the binary and then another command and takes the second status, which masks every non-zero exit rather than only an absent verb. The binary exits 1 for an unknown subcommand and 1 for an ordinary refusal alike, so the record is the only signal that survives the wrapper.
53
+
54
+ A `live` location with an empty `citedBy` is the sweep to run. One whose `citedBy` names a sibling is a plan several tasks share, which the sweep leaves alone and the archive gate lets through.
55
+
56
+ ```bash
57
+ aitk tasks plan-citations v28.1-trigger-escalation --json | jq -r '.location'
58
+ ```
59
+
37
60
  ## Pull request
38
61
 
39
62
  `aitk tasks pull-request` records the number a branch's pull request carries onto the task that branch closes. It adds `Pull request: #NNN` under the `Plan:`, `Groundwork:`, `Intake:`, or `Issue:` lines the task already holds, and corrects the number in place when the line exists.
@@ -106,7 +129,11 @@ A backlog line is a bullet carrying a link to a sibling task, since the backlog
106
129
 
107
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.
108
131
 
109
- 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 closing every outcome, and a collision is settled by nothing under `## Run now` still holding the file the cell cites.
132
+ 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
+
134
+ 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.
135
+
136
+ The trunk is read as the clone already holds it, `origin/main` first and local `main` behind it, and no run fetches. A validate runs several times a sweep and a fetch per run is a cost this command does not carry, so a clone behind its remote under-reports rather than claiming work landed.
110
137
 
111
138
  Both halves gate on a citation inside the `Waiting on` cell, never on the columns beside it. The board format gives a collision cell the file held by the running task, so a row whose cell names no file was parked by something else, and testing its Touches column instead reports a cleared collision on a row no collision ever parked while counting that row as re-tested. A cited task is a bare sibling link, the way the Task column spells one, so a pointer into another folder names a plan rather than a task and settles nothing. A cited task carrying no outcome box settles nothing either, since a file the check could not parse is not evidence of a finished one.
112
139
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Teach
3
- description: Listing learning workspaces and the ordinal a new one takes, opening one with its required files, recording sources and glossary terms, the refusal reasons, and why every write here runs through a verb
3
+ description: Listing learning workspaces and the ordinal a new one takes, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, the refusal reasons, and why every write here runs through a verb
4
4
  ---
5
5
 
6
6
  # Teach
@@ -102,6 +102,33 @@ A term already defined is refused rather than replaced. A definition the subject
102
102
 
103
103
  The entry lands as the standard's shape, leading with the term as a bolded span. A definition not ending in sentence punctuation is terminated before the citation is appended, so a bare phrase does not run into the sentence naming where the term first appears.
104
104
 
105
+ ## Lesson
106
+
107
+ `aitk teach lesson` resolves what the next lesson needs before it is written. It reads and never writes.
108
+
109
+ ```bash
110
+ aitk teach lesson regular-expressions \
111
+ --slug capture-groups \
112
+ --questions 3 \
113
+ --options 4
114
+ ```
115
+
116
+ | Option | Behavior |
117
+ | ----------------- | --------------------------------------------------------- |
118
+ | `--slug <kebab>` | The lesson's own topic, required |
119
+ | `--questions <n>` | How many questions the quiz carries, required |
120
+ | `--options <n>` | How many options each question carries, defaulting to `4` |
121
+ | `--json` | Emit a machine-readable record on stdout |
122
+ | `--root <path>` | Teach root, defaulting to the main worktree |
123
+
124
+ It reports four things. `lesson` is the numbered path the lesson takes, derived from the highest ordinal already in `lessons/` the way an open derives a workspace ordinal. `stylesheet` names the one file every lesson in the workspace links, with `stylesheetExists` saying whether it is on disk yet and `stylesheetHref` carrying what the lesson's own link element holds. `success` carries the mission's success lines, so a session reports progress against the exit criteria without a second read of `MISSION.md`. `quiz` carries one entry per question.
125
+
126
+ The stylesheet is reported rather than written. A verb that wrote it on every lesson would discard whatever the last lesson added, and the second lesson in a workspace is the one that needs the file the first one left.
127
+
128
+ Each `quiz` entry carries `order`, the authored option indices in presentation order where index `0` is the correct answer, and `answer`, the one-based position that answer lands in. Both travel together because a caller deriving the position itself is a caller that can derive it wrongly.
129
+
130
+ The order is drawn here rather than instructed, and that is the point of the verb. An author told to vary the position still varies it by judgment, and the judgment settles on the first slot, which is the defect this design departs from. The draw is uniform over the options, so the position carries no information about which answer is correct.
131
+
105
132
  ## Refusal reasons
106
133
 
107
134
  | Reason | Raised when |
@@ -116,4 +143,4 @@ The entry lands as the standard's shape, leading with the term as a bolded span.
116
143
  | `defined` | A term already carries a glossary entry |
117
144
  | `bad-input` | The command line is malformed, before any folder is read |
118
145
 
119
- A `bad-input` refusal reports the working directory as its root rather than the resolved one, since the command line is rejected before the root is worth resolving.
146
+ A `bad-input` refusal reports the working directory as its root rather than the resolved one, since the command line is rejected before the root is worth resolving. `lesson` raises it for a slug that is not kebab-case, for a quiz carrying no question, and for a question carrying fewer than two options, since a question with one option has nothing to confuse the right answer with.
@@ -105,11 +105,11 @@ The sweep reads the whole board rather than the tasks the session touched. It is
105
105
 
106
106
  `.claude/memory/` carries the same arrangement, its own hook regenerating `index.md` from each entry's `title`, `description`, and `category`. A hand-maintained `priority.md` sits beside it carrying execution order and what each task is waiting on, which the alphabetical index cannot express.
107
107
 
108
- `aitk tasks validate` checks what those rows claim against what the tree holds: every plan pointer resolves, every row and task file map one to one, no task sits in two groups, and no two rows marked ready touch the same file. That fourth check is the half a reader cannot run by eye, and it is what keeps two workers from being handed colliding work. A fifth re-takes what a parked row waits on, reporting one whose cited task has closed and one whose cited file nothing running still holds. Both halves read a citation out of the blocker cell, so a row citing neither is reported as untested rather than counted clean, which is where the three kinds resting on a person's judgment land. It reports and never writes, because a row is the orchestrator's claim and a validator repairing one would assert the claim it exists to test. Nothing fires it automatically, since the board is gitignored per-machine scratch with no shared moment to hang a hook on, so the orchestrator's sweep calls it at the point the readiness claim is made and follows it with the parked re-test.
108
+ `aitk tasks validate` checks what those rows claim against what the tree holds: every plan pointer resolves, every task file is named by a board row or a backlog line and never by both, no task sits in two groups, and no two rows marked ready touch the same file. One check across both surfaces is what lets a task move between the board and the backlog without the move reading as a dropped file. That fourth check is the half a reader cannot run by eye, and it is what keeps two workers from being handed colliding work. A fifth re-takes what a parked row waits on, reporting one whose cited task reached the trunk and one whose cited file nothing running still holds. A cited task settles the row by being archived, or by closing every outcome and naming a pull request the trunk carries, since the checkbox alone is marked while the branch is still in review. Both halves read a citation out of the blocker cell, so a row citing neither is reported as untested rather than counted clean, and so is a cited task the trunk could not answer for. It reports and never writes, because a row is the orchestrator's claim and a validator repairing one would assert the claim it exists to test. Nothing fires it automatically, since the board is gitignored per-machine scratch with no shared moment to hang a hook on, so the orchestrator's sweep calls it at the point the readiness claim is made and follows it with the parked re-test.
109
109
 
110
110
  `aitk:claude-tasks` owns the two operations that bracket a task's life. It creates the file, holding the filename convention and the frontmatter contract so a malformed write cannot break the index for every sibling, and it moves a shipped task to `.claude/task-archive/`. Creation is where the origin invariant is enforced: every task names a plan, a groundwork folder, an intake folder, or an issue, since a task with no origin is either lost context or work nobody decided to do.
111
111
 
112
- Archiving a task leaves its plan alone, because `aitk:claude-docs` owns the plans sweep and already holds the last-live-citation rule. That makes the order load-bearing, so the archive verb refuses to run while the `Plan:` line still points into `.claude/plans/`. The sweep only reaches tasks still in the live folder, and archiving the task first would strand the plan there with nothing citing it.
112
+ Archiving a task leaves its plan alone, because `aitk:claude-docs` owns the plans sweep and already holds the last-live-citation rule. That makes the order load-bearing, so the archive verb refuses the last task pointing at a live plan. The sweep only reaches tasks still in the live folder, and archiving that task first would strand the plan there with nothing citing it. A task whose plan a sibling still cites archives freely, because the sweep is correct to leave that plan where it is and a gate reading the folder instead would park every task sharing one plan behind a sweep that will never move it.
113
113
 
114
114
  Nothing chained that archive until the `post-merge` git hook landed. Every earlier step fires from `aitk:claude-autoship` or `aitk:git-ship`, both of which finish while the pull request is still open, so a task archived there would close for work that may be abandoned. The board is gitignored, which rules out reading it from anywhere but the machine that pulled. The hook names the board's archive candidates and stays silent otherwise, including on a project with no board.
115
115
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.103.0",
4
+ "version": "0.104.1",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -1,6 +1,12 @@
1
1
  import { relative } from 'node:path'
2
2
  import type { Command } from 'commander'
3
- import { type ArchiveOutcome, archiveTask } from '@/tasks/archive'
3
+ import {
4
+ type ArchiveOutcome,
5
+ archiveTask,
6
+ type CitationOutcome,
7
+ type PlanCitations,
8
+ planCitations,
9
+ } from '@/tasks/archive'
4
10
  import {
5
11
  type CloseOutcome,
6
12
  closeOutcomes,
@@ -42,6 +48,11 @@ interface ValidateCommandOptions {
42
48
  readonly root?: string
43
49
  }
44
50
 
51
+ interface CitationsCommandOptions {
52
+ readonly json?: boolean
53
+ readonly root?: string
54
+ }
55
+
45
56
  interface PullRequestCommandOptions {
46
57
  readonly json?: boolean
47
58
  readonly plan?: string
@@ -126,6 +137,41 @@ export function register(program: Command): void {
126
137
  process.exitCode = await runValidate(opts)
127
138
  })
128
139
 
140
+ tasks
141
+ .command('plan-citations')
142
+ .description('Report where a task plan sits and which live tasks hold it')
143
+ .argument('<task>', 'Task filename stem, as in v28.1-trigger-escalation')
144
+ .helpOption('-h, --help', 'Show this help message')
145
+ .option('--json', 'Emit a machine-readable record on stdout')
146
+ .option('--root <path>', 'Board root, defaulting to the main worktree')
147
+ .addHelpText(
148
+ 'after',
149
+ [
150
+ '',
151
+ 'Locations:',
152
+ ' unstated the task carries no Plan: line',
153
+ ' live the target resolves inside .claude/plans/',
154
+ ' archived the target resolves inside .claude/plans-archive/',
155
+ ' outside the target resolves somewhere else',
156
+ '',
157
+ 'Exit codes:',
158
+ ' 0 the citations were read',
159
+ ' 1 refused, with the reason on stderr or in the JSON record',
160
+ '',
161
+ 'It reports and never writes. A live plan whose citedBy list is empty',
162
+ 'is the sweep to run, and one a sibling still holds is left alone.',
163
+ 'The archive gate reads the same answer, so neither can drift.',
164
+ '',
165
+ 'Examples:',
166
+ ' aitk tasks plan-citations v28.1-trigger-escalation',
167
+ ' aitk tasks plan-citations v28.1-trigger-escalation --json',
168
+ '',
169
+ ].join('\n'),
170
+ )
171
+ .action(async (task: string, opts: CitationsCommandOptions) => {
172
+ process.exitCode = await runCitations(task, opts)
173
+ })
174
+
129
175
  tasks
130
176
  .command('pull-request')
131
177
  .description('Record a pull request number on the task a branch closes')
@@ -426,6 +472,67 @@ async function runValidate(opts: ValidateCommandOptions): Promise<number> {
426
472
  return reportValidation(outcome, opts.json ?? false, root)
427
473
  }
428
474
 
475
+ async function runCitations(
476
+ task: string,
477
+ opts: CitationsCommandOptions,
478
+ ): Promise<number> {
479
+ const root = opts.root ?? (await mainWorktreeRoot())
480
+ const outcome = await planCitations(root, task)
481
+
482
+ return reportCitations(outcome, opts.json ?? false, root)
483
+ }
484
+
485
+ function reportCitations(
486
+ outcome: CitationOutcome,
487
+ emitJson: boolean,
488
+ root: string,
489
+ ): number {
490
+ if (!outcome.ok) {
491
+ if (emitJson) {
492
+ process.stdout.write(
493
+ `${JSON.stringify({ ok: false, reason: outcome.reason, message: outcome.message })}\n`,
494
+ )
495
+ return 1
496
+ }
497
+
498
+ intro('aitk tasks plan-citations')
499
+ logStep('Refused')
500
+ logError(outcome.message)
501
+ outro()
502
+ return 1
503
+ }
504
+
505
+ if (emitJson) {
506
+ process.stdout.write(`${JSON.stringify({ ...outcome, root })}\n`)
507
+ return 0
508
+ }
509
+
510
+ intro('aitk tasks plan-citations')
511
+ logStep(outcome.stem)
512
+ logInfo(describeCitations(outcome))
513
+ outro()
514
+
515
+ return 0
516
+ }
517
+
518
+ function describeCitations(outcome: PlanCitations): string {
519
+ if (outcome.location === 'unstated') return 'carries no Plan: line.'
520
+
521
+ if (outcome.location === 'archived') {
522
+ return `points at ${outcome.target}, which an earlier sweep already archived.`
523
+ }
524
+
525
+ if (outcome.location === 'outside') {
526
+ return `points at ${outcome.target}, which resolves outside both plans folders.`
527
+ }
528
+
529
+ if (outcome.citedBy.length === 0) {
530
+ return `is the last live task citing ${outcome.target}, so the sweep may archive it.`
531
+ }
532
+
533
+ return `shares ${outcome.target} with ${outcome.citedBy.join(', ')}, so the sweep leaves it.`
534
+ }
535
+
429
536
  function reportValidation(
430
537
  outcome: ValidateOutcome,
431
538
  emitJson: boolean,
@@ -1,5 +1,6 @@
1
1
  import { relative } from 'node:path'
2
2
  import type { Command } from 'commander'
3
+ import { type LessonOutcome, planLesson } from '@/teach/lesson'
3
4
  import {
4
5
  defineTerms,
5
6
  type ListOutcome,
@@ -57,6 +58,14 @@ interface ResourceCommandOptions {
57
58
  readonly root?: string
58
59
  }
59
60
 
61
+ interface LessonCommandOptions {
62
+ readonly json?: boolean
63
+ readonly options?: string
64
+ readonly questions?: string
65
+ readonly root?: string
66
+ readonly slug?: string
67
+ }
68
+
60
69
  interface GlossaryCommandOptions {
61
70
  readonly firstSeen?: string
62
71
  readonly json?: boolean
@@ -231,6 +240,42 @@ export function register(program: Command): void {
231
240
  .action(async (topic: string, opts: GlossaryCommandOptions) => {
232
241
  process.exitCode = await runGlossary(topic, opts)
233
242
  })
243
+
244
+ teach
245
+ .command('lesson')
246
+ .description('Resolve what the next lesson needs before it is written')
247
+ .argument('<topic>', 'Workspace folder or topic, as in regular-expressions')
248
+ .helpOption('-h, --help', 'Show this help message')
249
+ .option('--slug <kebab>', "The lesson's own topic, as in capture-groups")
250
+ .option('--questions <n>', 'How many questions the quiz carries')
251
+ .option('--options <n>', 'How many options each question carries', '4')
252
+ .option('--json', 'Emit a machine-readable record on stdout')
253
+ .option('--root <path>', 'Teach root, defaulting to the main worktree')
254
+ .addHelpText(
255
+ 'after',
256
+ [
257
+ '',
258
+ 'Exit codes:',
259
+ ' 0 the lesson was planned',
260
+ ' 1 refused, with the reason on stderr or in the JSON record',
261
+ '',
262
+ 'It writes nothing. It reports the numbered path the lesson takes, the',
263
+ 'shared stylesheet with whether that file exists yet, the mission',
264
+ 'success lines to report progress against, and one option order per',
265
+ 'question.',
266
+ '',
267
+ 'Write the correct option first and present the options in the order',
268
+ 'reported. Position is drawn here rather than chosen, so an answer',
269
+ 'cannot settle into the first slot.',
270
+ '',
271
+ 'Examples:',
272
+ ' aitk teach lesson regular-expressions --slug capture-groups --questions 3 --json',
273
+ '',
274
+ ].join('\n'),
275
+ )
276
+ .action(async (topic: string, opts: LessonCommandOptions) => {
277
+ process.exitCode = await runLesson(topic, opts)
278
+ })
234
279
  }
235
280
 
236
281
  function collect(value: string, previous: string[]): string[] {
@@ -435,6 +480,43 @@ async function runGlossary(
435
480
  )
436
481
  }
437
482
 
483
+ async function runLesson(
484
+ topic: string,
485
+ opts: LessonCommandOptions,
486
+ ): Promise<number> {
487
+ const emitJson = opts.json ?? false
488
+
489
+ if (!opts.slug) {
490
+ return reportRefusal(
491
+ 'aitk teach lesson',
492
+ badInput('No lesson slug. Pass --slug <kebab>.'),
493
+ emitJson,
494
+ process.cwd(),
495
+ )
496
+ }
497
+
498
+ if (!opts.questions) {
499
+ return reportRefusal(
500
+ 'aitk teach lesson',
501
+ badInput('No question count. Pass --questions <n>.'),
502
+ emitJson,
503
+ process.cwd(),
504
+ )
505
+ }
506
+
507
+ const root = await rootFor(opts.root)
508
+
509
+ return reportLesson(
510
+ await planLesson(root, topic, {
511
+ slug: opts.slug,
512
+ questions: Number(opts.questions),
513
+ options: Number(opts.options ?? '4'),
514
+ }),
515
+ emitJson,
516
+ root,
517
+ )
518
+ }
519
+
438
520
  function reportRefusal(
439
521
  title: string,
440
522
  refused: TeachRefused,
@@ -545,6 +627,11 @@ function reportWorkspace(
545
627
  logStep(workspace.glossary.length > 0 ? 'Glossary' : 'Glossary (empty)')
546
628
  for (const entry of workspace.glossary) logInfo(entry)
547
629
 
630
+ logStep(
631
+ workspace.success.length > 0 ? 'Exit criteria' : 'Exit criteria (none)',
632
+ )
633
+ for (const line of workspace.success) logInfo(line)
634
+
548
635
  if (workspace.missing.length > 0) {
549
636
  logStep('Missing a required file')
550
637
  logWarn(`no ${workspace.missing.join(' and no ')}`)
@@ -648,3 +735,52 @@ function reportGlossary(
648
735
 
649
736
  return 0
650
737
  }
738
+
739
+ function reportLesson(
740
+ outcome: LessonOutcome,
741
+ emitJson: boolean,
742
+ root: string,
743
+ ): number {
744
+ if (!outcome.ok) {
745
+ return reportRefusal('aitk teach lesson', outcome, emitJson, root)
746
+ }
747
+
748
+ if (emitJson) {
749
+ process.stdout.write(
750
+ `${JSON.stringify({
751
+ ok: true,
752
+ root,
753
+ slug: outcome.slug,
754
+ path: outcome.path,
755
+ lesson: outcome.lesson,
756
+ stylesheet: outcome.stylesheet,
757
+ stylesheetHref: outcome.stylesheetHref,
758
+ stylesheetExists: outcome.stylesheetExists,
759
+ success: outcome.success,
760
+ quiz: outcome.quiz,
761
+ })}\n`,
762
+ )
763
+ return 0
764
+ }
765
+
766
+ intro('aitk teach lesson')
767
+ logStep('Lesson')
768
+ logInfo(outcome.lesson)
769
+
770
+ logStep(outcome.stylesheetExists ? 'Stylesheet' : 'Stylesheet (to write)')
771
+ logInfo(`${outcome.stylesheet} linked as ${outcome.stylesheetHref}`)
772
+
773
+ logStep(outcome.success.length > 0 ? 'Exit criteria' : 'Exit criteria (none)')
774
+ for (const line of outcome.success) logInfo(line)
775
+
776
+ logStep('Option order')
777
+ for (const question of outcome.quiz) {
778
+ logInfo(
779
+ `question ${question.question}: present ${question.order.join(', ')}, correct answer in position ${question.answer}`,
780
+ )
781
+ }
782
+
783
+ outro()
784
+
785
+ return 0
786
+ }
@@ -9,6 +9,7 @@ import {
9
9
  TEACH_RECORDS,
10
10
  TEACH_REFERENCE,
11
11
  TEACH_RESOURCES,
12
+ TEACH_SUCCESS_HEADING,
12
13
  WORKSPACE_NAME,
13
14
  } from '@/teach/workspace'
14
15
 
@@ -576,7 +577,6 @@ async function checkDump(dir: string, slug: string): Promise<Finding[]> {
576
577
  return [...findings, ...perCluster.flat()]
577
578
  }
578
579
 
579
- const TEACH_SUCCESS = /^##[ \t]+Success looks like[ \t]*$/
580
580
  const NUMBERED_RECORD = /^\d{4}-[a-z0-9]+(-[a-z0-9]+)*\.md$/
581
581
  /**
582
582
  * A kebab slug that does not open with an ordinal. The lookahead rejects a
@@ -705,13 +705,15 @@ async function checkWorkspace(dir: string, slug: string): Promise<Finding[]> {
705
705
  }
706
706
 
707
707
  if (
708
- !linesOutsideFences(text).some((line) => TEACH_SUCCESS.test(line.trim()))
708
+ !linesOutsideFences(text).some(
709
+ (line) => line.trim() === TEACH_SUCCESS_HEADING,
710
+ )
709
711
  ) {
710
712
  findings.push(
711
713
  finding(
712
714
  'section-missing',
713
715
  slug,
714
- '## Success looks like',
716
+ TEACH_SUCCESS_HEADING,
715
717
  'is absent, so the mission names no observable thing the learner will be able to do.',
716
718
  ),
717
719
  )