@erclx/aitk 0.103.0 → 0.104.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-docs/SKILL.md +4 -0
- package/claude/skills/claude-teach/SKILL.md +21 -3
- package/claude/skills/claude-teach/references/lesson-craft.md +8 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/tasks.md +28 -1
- package/docs/agents/teach.md +29 -2
- package/docs/ai-workflow.md +2 -2
- package/package.json +1 -1
- package/src/commands/tasks.ts +108 -1
- package/src/commands/teach.ts +136 -0
- package/src/records/validate.ts +5 -3
- package/src/sandbox/expect.ts +29 -12
- package/src/tasks/archive.ts +158 -9
- package/src/tasks/trunk.ts +89 -0
- package/src/tasks/validate.ts +100 -29
- package/src/teach/lesson.ts +180 -0
- package/src/teach/workspace.ts +48 -3
- package/standards/tasks.md +15 -3
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import {
|
|
4
|
+
readWorkspace,
|
|
5
|
+
refuse,
|
|
6
|
+
TEACH_ASSETS,
|
|
7
|
+
TEACH_LESSONS,
|
|
8
|
+
TEACH_STYLESHEET,
|
|
9
|
+
type TeachRefused,
|
|
10
|
+
} from '@/teach/workspace'
|
|
11
|
+
|
|
12
|
+
/** Four digits inside a workspace, per the standard, because one holds many. */
|
|
13
|
+
const LESSON_WIDTH = 4
|
|
14
|
+
|
|
15
|
+
/** The ordinal a lesson filename opens with, which fixes its read order. */
|
|
16
|
+
const LESSON_NUMBER = /^(\d{4})-/
|
|
17
|
+
|
|
18
|
+
const LESSON_SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
|
19
|
+
|
|
20
|
+
/** A quiz needs a right answer and at least one thing to confuse it with. */
|
|
21
|
+
const MIN_OPTIONS = 2
|
|
22
|
+
|
|
23
|
+
export interface QuizOrder {
|
|
24
|
+
/** One-based, so a report reads the same way the lesson numbers them. */
|
|
25
|
+
readonly question: number
|
|
26
|
+
/**
|
|
27
|
+
* Authored indices in the order the lesson presents them, where the authored
|
|
28
|
+
* index `0` is the correct answer. The author writes the correct option first
|
|
29
|
+
* and reads its position back off this list.
|
|
30
|
+
*/
|
|
31
|
+
readonly order: readonly number[]
|
|
32
|
+
/** Where the correct answer lands, one-based, so no caller derives it. */
|
|
33
|
+
readonly answer: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LessonPlanned {
|
|
37
|
+
readonly ok: true
|
|
38
|
+
readonly slug: string
|
|
39
|
+
/** Relative to the root, so a caller prints a path a reader can open. */
|
|
40
|
+
readonly path: string
|
|
41
|
+
readonly lesson: string
|
|
42
|
+
readonly stylesheet: string
|
|
43
|
+
/** What the lesson's own `link` element carries, resolved from `lessons/`. */
|
|
44
|
+
readonly stylesheetHref: string
|
|
45
|
+
/** False on the first lesson in a workspace, which writes the stylesheet. */
|
|
46
|
+
readonly stylesheetExists: boolean
|
|
47
|
+
/** The mission's success lines, reported as the exit criteria they are. */
|
|
48
|
+
readonly success: readonly string[]
|
|
49
|
+
readonly quiz: readonly QuizOrder[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type LessonOutcome = LessonPlanned | TeachRefused
|
|
53
|
+
|
|
54
|
+
export interface LessonRequest {
|
|
55
|
+
readonly slug: string
|
|
56
|
+
readonly questions: number
|
|
57
|
+
readonly options: number
|
|
58
|
+
/**
|
|
59
|
+
* Injected so a test can assert the shape of an order against a generator it
|
|
60
|
+
* controls. Every caller outside a test takes the default, which is what
|
|
61
|
+
* keeps the position of a correct answer off the author's judgment.
|
|
62
|
+
*/
|
|
63
|
+
readonly random?: () => number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A uniform permutation of `0 .. count - 1` by Fisher-Yates.
|
|
68
|
+
*
|
|
69
|
+
* The bias this exists against is the authored order surviving into the
|
|
70
|
+
* lesson, which puts the correct answer first whenever the author wrote it
|
|
71
|
+
* first. Drawing the permutation here rather than in a prompt is what makes the
|
|
72
|
+
* position unguessable from the outside.
|
|
73
|
+
*/
|
|
74
|
+
function shuffled(count: number, random: () => number): number[] {
|
|
75
|
+
const order = Array.from({ length: count }, (_, index) => index)
|
|
76
|
+
|
|
77
|
+
for (let index = count - 1; index > 0; index -= 1) {
|
|
78
|
+
const pick = Math.floor(random() * (index + 1))
|
|
79
|
+
const held = order[index]
|
|
80
|
+
order[index] = order[pick]
|
|
81
|
+
order[pick] = held
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return order
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* One presentation order per question, each carrying where the correct answer
|
|
89
|
+
* landed. Both halves travel together because a caller deriving the position
|
|
90
|
+
* itself is a caller that can derive it wrongly.
|
|
91
|
+
*/
|
|
92
|
+
export function orderQuiz(
|
|
93
|
+
questions: number,
|
|
94
|
+
options: number,
|
|
95
|
+
random: () => number = Math.random,
|
|
96
|
+
): QuizOrder[] {
|
|
97
|
+
return Array.from({ length: questions }, (_, index) => {
|
|
98
|
+
const order = shuffled(options, random)
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
question: index + 1,
|
|
102
|
+
order,
|
|
103
|
+
answer: order.indexOf(0) + 1,
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The number the next lesson takes, read off the filenames already there.
|
|
110
|
+
*
|
|
111
|
+
* A file whose name carries no ordinal moves nothing, the way a workspace
|
|
112
|
+
* folder with no ordinal moves no workspace number. Numbering past it would
|
|
113
|
+
* renumber nothing and skipping it would hide it.
|
|
114
|
+
*/
|
|
115
|
+
function nextLesson(files: readonly string[]): string {
|
|
116
|
+
const highest = files
|
|
117
|
+
.map((file) => LESSON_NUMBER.exec(file)?.[1])
|
|
118
|
+
.filter((ordinal): ordinal is string => ordinal !== undefined)
|
|
119
|
+
.reduce((carry, ordinal) => Math.max(carry, Number(ordinal)), 0)
|
|
120
|
+
|
|
121
|
+
return String(highest + 1).padStart(LESSON_WIDTH, '0')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Everything a lesson needs resolved before it is written: where it goes, which
|
|
126
|
+
* stylesheet it links and whether that file is already on disk, the mission's
|
|
127
|
+
* exit criteria, and the order each quiz presents its options in.
|
|
128
|
+
*
|
|
129
|
+
* The stylesheet is reported rather than written. Every lesson after the first
|
|
130
|
+
* reads the one the first wrote, so a verb that rewrote it on every lesson
|
|
131
|
+
* would discard whatever the last one added.
|
|
132
|
+
*/
|
|
133
|
+
export async function planLesson(
|
|
134
|
+
root: string,
|
|
135
|
+
selector: string,
|
|
136
|
+
request: LessonRequest,
|
|
137
|
+
): Promise<LessonOutcome> {
|
|
138
|
+
if (!LESSON_SLUG.test(request.slug)) {
|
|
139
|
+
return refuse('bad-input', `Not a kebab-case slug: ${request.slug}.`, [
|
|
140
|
+
request.slug,
|
|
141
|
+
])
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!Number.isInteger(request.questions) || request.questions < 1) {
|
|
145
|
+
return refuse(
|
|
146
|
+
'bad-input',
|
|
147
|
+
'A lesson carries at least one question. Pass --questions <n>.',
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!Number.isInteger(request.options) || request.options < MIN_OPTIONS) {
|
|
152
|
+
return refuse(
|
|
153
|
+
'bad-input',
|
|
154
|
+
`A question carries at least ${MIN_OPTIONS} options. Pass --options <n>.`,
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const found = await readWorkspace(root, selector)
|
|
159
|
+
if (!found.ok) return found
|
|
160
|
+
|
|
161
|
+
const workspace = found.workspace
|
|
162
|
+
const lesson = join(
|
|
163
|
+
workspace.path,
|
|
164
|
+
TEACH_LESSONS,
|
|
165
|
+
`${nextLesson(workspace.lessonFiles)}-${request.slug}.html`,
|
|
166
|
+
)
|
|
167
|
+
const stylesheet = join(workspace.path, TEACH_ASSETS, TEACH_STYLESHEET)
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
ok: true,
|
|
171
|
+
slug: workspace.slug,
|
|
172
|
+
path: workspace.path,
|
|
173
|
+
lesson,
|
|
174
|
+
stylesheet,
|
|
175
|
+
stylesheetHref: `../${TEACH_ASSETS}/${TEACH_STYLESHEET}`,
|
|
176
|
+
stylesheetExists: existsSync(join(root, stylesheet)),
|
|
177
|
+
success: workspace.success,
|
|
178
|
+
quiz: orderQuiz(request.questions, request.options, request.random),
|
|
179
|
+
}
|
|
180
|
+
}
|
package/src/teach/workspace.ts
CHANGED
|
@@ -32,6 +32,22 @@ export const TEACH_GLOSSARY = 'GLOSSARY.md'
|
|
|
32
32
|
export const TEACH_REFERENCE = 'reference'
|
|
33
33
|
export const TEACH_RECORDS = 'learning-records'
|
|
34
34
|
export const TEACH_LESSONS = 'lessons'
|
|
35
|
+
export const TEACH_ASSETS = 'assets'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The one stylesheet every lesson in a workspace links. The name is fixed here
|
|
39
|
+
* rather than chosen per lesson, because the second lesson has to reach the
|
|
40
|
+
* file the first one wrote and a name composed twice is a name that can differ.
|
|
41
|
+
*/
|
|
42
|
+
export const TEACH_STYLESHEET = 'course.css'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The mission heading whose list a session reads as exit criteria. The writer,
|
|
46
|
+
* the reader below, and the record validator all match this one spelling, so a
|
|
47
|
+
* heading none of them can find fails the validator rather than reading as an
|
|
48
|
+
* empty list.
|
|
49
|
+
*/
|
|
50
|
+
export const TEACH_SUCCESS_HEADING = '## Success looks like'
|
|
35
51
|
|
|
36
52
|
/**
|
|
37
53
|
* A workspace folder as the standard names it, capturing the ordinal and the
|
|
@@ -79,6 +95,8 @@ export interface WorkspaceDetail extends WorkspaceSummary {
|
|
|
79
95
|
readonly recordFiles: readonly string[]
|
|
80
96
|
readonly referenceFiles: readonly string[]
|
|
81
97
|
readonly glossary: readonly string[]
|
|
98
|
+
/** The mission's success lines, which a session reports progress against. */
|
|
99
|
+
readonly success: readonly string[]
|
|
82
100
|
}
|
|
83
101
|
|
|
84
102
|
export interface WorkspacesListed {
|
|
@@ -141,7 +159,7 @@ export interface OpenRequest {
|
|
|
141
159
|
readonly date?: string
|
|
142
160
|
}
|
|
143
161
|
|
|
144
|
-
function refuse(
|
|
162
|
+
export function refuse(
|
|
145
163
|
reason: TeachRefusal,
|
|
146
164
|
message: string,
|
|
147
165
|
detail: readonly string[] = [],
|
|
@@ -198,6 +216,30 @@ function glossaryTerms(text: string): string[] {
|
|
|
198
216
|
.map((line) => line.trim().slice('- '.length))
|
|
199
217
|
}
|
|
200
218
|
|
|
219
|
+
/**
|
|
220
|
+
* The mission's success lines, each one an observable thing the learner will be
|
|
221
|
+
* able to do. A session reads them as exit criteria, so a wrapped entry is
|
|
222
|
+
* joined back into one line rather than reported as two criteria.
|
|
223
|
+
*
|
|
224
|
+
* A mission carrying no such heading yields nothing rather than refusing. The
|
|
225
|
+
* record validator is what reports the absent section, and a listing that
|
|
226
|
+
* refused would take the whole workspace down with it.
|
|
227
|
+
*/
|
|
228
|
+
function successLines(text: string): string[] {
|
|
229
|
+
const lines = text.split('\n')
|
|
230
|
+
const section = sectionRange(
|
|
231
|
+
unfenced(text),
|
|
232
|
+
TEACH_SUCCESS_HEADING,
|
|
233
|
+
lines.length,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
if (!section) return []
|
|
237
|
+
|
|
238
|
+
return bulletBlocks(lines.slice(section.start, section.end)).map((block) =>
|
|
239
|
+
block.join(' ').trim().slice('- '.length).replace(/\s+/g, ' ').trim(),
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
201
243
|
/** The ordinal a folder name carries, or `NaN` when it carries none. */
|
|
202
244
|
function ordinalOf(slug: string): number {
|
|
203
245
|
const match = WORKSPACE_NAME.exec(slug)
|
|
@@ -211,9 +253,11 @@ async function summarize(
|
|
|
211
253
|
): Promise<WorkspaceDetail> {
|
|
212
254
|
const match = WORKSPACE_NAME.exec(slug)
|
|
213
255
|
const missionPath = join(dir, TEACH_MISSION)
|
|
214
|
-
const
|
|
215
|
-
?
|
|
256
|
+
const mission = existsSync(missionPath)
|
|
257
|
+
? await readFile(missionPath, 'utf8')
|
|
216
258
|
: undefined
|
|
259
|
+
const frontmatter =
|
|
260
|
+
mission === undefined ? undefined : parseFrontmatter(mission)
|
|
217
261
|
|
|
218
262
|
const glossaryPath = join(dir, TEACH_GLOSSARY)
|
|
219
263
|
const glossary = existsSync(glossaryPath)
|
|
@@ -244,6 +288,7 @@ async function summarize(
|
|
|
244
288
|
recordFiles,
|
|
245
289
|
referenceFiles,
|
|
246
290
|
glossary,
|
|
291
|
+
success: mission === undefined ? [] : successLines(mission),
|
|
247
292
|
}
|
|
248
293
|
}
|
|
249
294
|
|
package/standards/tasks.md
CHANGED
|
@@ -132,7 +132,11 @@ Add no fourth readiness group in place of this file. The three group names are t
|
|
|
132
132
|
|
|
133
133
|
## Validation
|
|
134
134
|
|
|
135
|
-
`aitk tasks validate` reads the columns above and reports where a row's claim and the tree disagree: a plan pointer resolving to no file, a task file reached by neither surface, a task on both surfaces or in two groups, and two `## Run now` rows touching a path in common. It also re-takes the two blocker kinds a command can settle, reporting a parked row whose cited task
|
|
135
|
+
`aitk tasks validate` reads the columns above and reports where a row's claim and the tree disagree: a plan pointer resolving to no file, a task file reached by neither surface, a task on both surfaces or in two groups, and two `## Run now` rows touching a path in common. It also re-takes the two blocker kinds a command can settle, reporting a parked row whose cited task reached the trunk and one whose cited file nothing under `## Run now` still holds. Both halves read a citation out of the cell rather than parsing it into fields, and a row citing neither is reported as untested, which is where the three kinds resting on a person's judgment land. Run it when the readiness claim is made rather than on a schedule, since the board is gitignored per-machine scratch and no shared moment exists to hang it on. It reports and never writes, so a session fixes the row it names.
|
|
136
|
+
|
|
137
|
+
A cited task is settled by being archived, or by closing every outcome and carrying a `Pull request:` line the trunk holds. The closed checkbox alone settles nothing, because the ship chain marks outcomes as its first step and opens the pull request several steps later, so a row read off the checkbox reports settled while the branch is still in review. A task that closed every outcome and names no pull request, and one whose pull request the run could not read against the trunk, are both reported as untested. Degrading either back to the checkbox would reproduce the defect under a name claiming it was fixed.
|
|
138
|
+
|
|
139
|
+
The trunk is read as the clone already holds it, `origin/main` first and local `main` behind it, and no run fetches. A validate happens several times a sweep and a fetch per run is a cost this check does not carry, so a clone behind the remote under-reports rather than claiming work landed.
|
|
136
140
|
|
|
137
141
|
A task file is accounted for when a row on `priority.md` or a line on `backlog.md` names it, and reported when neither does. One check across both surfaces is what lets a task move between them without the move looking like a dropped file, and a task named by both is reported for the same reason a task in two groups is: it claims two things about itself and only one of them can hold. A project carrying no `backlog.md` is read as an empty backlog rather than refused, which leaves the one-to-one mapping this check ran before the second surface existed.
|
|
138
142
|
|
|
@@ -252,8 +256,16 @@ Archiving a task does not archive its plan. `claude-docs` owns the plans sweep a
|
|
|
252
256
|
|
|
253
257
|
The row is matched by the link in its first cell rather than by a pattern against the whole line. A row names the task it is about in the first cell, so a link anywhere after that is a reference, such as a blocker pointing at what it waits on. Matching the line would delete the referring task's row too, on a board that is gitignored and has nothing to recover it from.
|
|
254
258
|
|
|
255
|
-
Sweep the plan before archiving the task. The sweep finds its work by scanning the live folder, so a task archived first is beyond its reach for good, and the plan is left with no live task citing it and an archived task pointing at a path nothing will retarget. The archive refuses
|
|
259
|
+
Sweep the plan before archiving the task. The sweep finds its work by scanning the live folder, so a task archived first is beyond its reach for good, and the plan is left with no live task citing it and an archived task pointing at a path nothing will retarget. The archive refuses the last task pointing at a live plan for that reason, which puts the ordering under a gate rather than under a convention the unattended caller cannot follow.
|
|
260
|
+
|
|
261
|
+
The gate counts the other live tasks citing the same plan rather than reading which folder the plan sits in. A plan several tasks share stays in the live folder by design, because the sweep is correct to leave a plan another live task still cites, so a gate reading the folder alone refuses every one of those tasks and the board and the sweep block each other with neither in the wrong. Counting the citations asks the question the gate means: a plan nothing else holds is one the sweep has yet to reach, and a plan a sibling still holds is one the sweep already decided about.
|
|
262
|
+
|
|
263
|
+
The count resolves the target against `.claude/tasks/` and against the project root both, so `../plans/x.md` and `.claude/plans/x.md` land on the same file and one plan two tasks spelled differently counts once. `aitk tasks plan-citations` exposes that count for a caller that wants it, and the gate reads it.
|
|
264
|
+
|
|
265
|
+
The `claude-docs` sweep states the rule rather than calling that verb, which is a duplication accepted with a reason rather than an oversight. A skill reaches a target the moment it merges and the CLI reaches one only when a release publishes, so a body calling a verb the installed `aitk` predates gets no record back and sweeps nothing. The two spellings therefore have to agree by hand until a release carries the verb, and the failure they guard against is a plan stranded by the form its citation was written in.
|
|
266
|
+
|
|
267
|
+
A caller reads the outcome off the record's `reason` field and never off the exit code. 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, which masks an ordinary refusal exactly as it masks an absent verb.
|
|
256
268
|
|
|
257
|
-
|
|
269
|
+
Surviving a shared plan is not the same as sanctioning one. `standards/plan.md` puts one concern in one plan file, so a plan serving several tasks is a shape to correct rather than to build on, and the gate only stops it from deadlocking the board.
|
|
258
270
|
|
|
259
271
|
A task with an open outcome stays on the board. Close it, or cut it from the task when the work is being abandoned, so what was dropped is recorded rather than inferred from an archived file. The sweep is gated on the same condition, so archiving around an open outcome also leaves the plan behind.
|