@erclx/aitk 0.57.0 → 0.59.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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-autoship/SKILL.md +2 -0
- package/claude/skills/claude-docs/SKILL.md +1 -1
- package/claude/skills/claude-feature/SKILL.md +8 -55
- package/docs/agents/commands.md +1 -0
- package/docs/agents/index.md +1 -0
- package/docs/agents/records.md +54 -0
- package/docs/agents/scripting.md +21 -9
- package/docs/ai-workflow.md +4 -0
- package/governance/rules/claude/558-plan.md +22 -0
- package/governance/stacks/base.toml +3 -1
- package/package.json +1 -1
- package/scripts/core/verify.sh +55 -0
- package/src/cli.ts +4 -0
- package/src/commands/gov.ts +79 -4
- package/src/commands/records.ts +159 -0
- package/src/gov/install.ts +32 -15
- package/src/gov/list.ts +106 -0
- package/src/gov/stacks.ts +55 -6
- package/src/records/validate.ts +599 -0
- package/standards/groundwork.md +1 -0
- package/standards/index.md +1 -0
- package/standards/intake.md +1 -0
- package/standards/plan.md +145 -0
- package/standards/rule.md +9 -0
- package/standards/tasks.md +1 -0
- package/scripts/gov/list.sh +0 -234
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
5
|
+
|
|
6
|
+
export const RECORD_KINDS = ['plans', 'groundwork', 'intake'] as const
|
|
7
|
+
|
|
8
|
+
export type RecordKind = (typeof RECORD_KINDS)[number]
|
|
9
|
+
|
|
10
|
+
const FOLDER_BY_KIND: Readonly<Record<RecordKind, string>> = {
|
|
11
|
+
plans: join('.claude', 'plans'),
|
|
12
|
+
groundwork: join('.claude', 'groundwork'),
|
|
13
|
+
intake: join('.claude', 'intake'),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `unknown-kind` is raised at the argument boundary rather than by the walk, and
|
|
18
|
+
* it sits here because both reach a caller through the same `reason` field. A
|
|
19
|
+
* union covering only what the walk returns would type a record the command can
|
|
20
|
+
* emit as impossible.
|
|
21
|
+
*/
|
|
22
|
+
export const VALIDATE_REFUSALS = ['no-folder', 'unknown-kind'] as const
|
|
23
|
+
|
|
24
|
+
export type ValidateRefusal = (typeof VALIDATE_REFUSALS)[number]
|
|
25
|
+
|
|
26
|
+
export const FINDING_KINDS = [
|
|
27
|
+
'name-malformed',
|
|
28
|
+
'title-missing',
|
|
29
|
+
'section-missing',
|
|
30
|
+
'entry-unreasoned',
|
|
31
|
+
'suggestion-missing',
|
|
32
|
+
'question-unanswerable',
|
|
33
|
+
'frontmatter-incomplete',
|
|
34
|
+
'date-malformed',
|
|
35
|
+
'index-missing',
|
|
36
|
+
'state-missing',
|
|
37
|
+
'closing-partial',
|
|
38
|
+
'item-incomplete',
|
|
39
|
+
] as const
|
|
40
|
+
|
|
41
|
+
export type FindingKind = (typeof FINDING_KINDS)[number]
|
|
42
|
+
|
|
43
|
+
export interface Finding {
|
|
44
|
+
readonly kind: FindingKind
|
|
45
|
+
/** The record the finding sits in, relative to the validated folder. */
|
|
46
|
+
readonly record: string
|
|
47
|
+
readonly subject: string
|
|
48
|
+
readonly message: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ValidateReport {
|
|
52
|
+
readonly ok: true
|
|
53
|
+
readonly kind: RecordKind
|
|
54
|
+
readonly records: number
|
|
55
|
+
readonly findings: readonly Finding[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ValidateRefused {
|
|
59
|
+
readonly ok: false
|
|
60
|
+
readonly reason: ValidateRefusal
|
|
61
|
+
readonly message: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type ValidateOutcome = ValidateReport | ValidateRefused
|
|
65
|
+
|
|
66
|
+
export function recordsDir(root: string, kind: RecordKind): string {
|
|
67
|
+
return join(root, FOLDER_BY_KIND[kind])
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isRecordKind(value: string): value is RecordKind {
|
|
71
|
+
return (RECORD_KINDS as readonly string[]).includes(value)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const NONE_IDENTIFIED = 'None identified.'
|
|
75
|
+
const NUMBERED_FILE = /^\d{2}-[a-z0-9]+(-[a-z0-9]+)*\.md$/
|
|
76
|
+
|
|
77
|
+
function finding(
|
|
78
|
+
kind: FindingKind,
|
|
79
|
+
record: string,
|
|
80
|
+
subject: string,
|
|
81
|
+
message: string,
|
|
82
|
+
): Finding {
|
|
83
|
+
return { kind, record, subject, message }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function listMarkdown(dir: string): Promise<string[]> {
|
|
87
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
88
|
+
|
|
89
|
+
return entries
|
|
90
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
|
91
|
+
.map((entry) => entry.name)
|
|
92
|
+
.sort()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function listFolders(dir: string): Promise<string[]> {
|
|
96
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
97
|
+
|
|
98
|
+
return entries
|
|
99
|
+
.filter((entry) => entry.isDirectory())
|
|
100
|
+
.map((entry) => entry.name)
|
|
101
|
+
.sort()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const PLAN_NAME = /^feature-[a-z0-9]+(-[a-z0-9]+)*\.md$/
|
|
105
|
+
const PLAN_TITLE = /^#[ \t]+Feature:[ \t]+\S/
|
|
106
|
+
/**
|
|
107
|
+
* An entry names a file and says something about it. Both halves are tested as
|
|
108
|
+
* facts rather than as a syntax: a backticked span anywhere, and prose left over
|
|
109
|
+
* once the spans are removed.
|
|
110
|
+
*
|
|
111
|
+
* Requiring the path to lead and the reason to follow a colon was the first
|
|
112
|
+
* shape and it reported 80 of 178 archived plans. The corpus writes
|
|
113
|
+
* `- Label: prose naming a path` as often as `- path: reason`, and both name the
|
|
114
|
+
* file and say why, so the stricter rule measured a house style rather than a
|
|
115
|
+
* defect.
|
|
116
|
+
*/
|
|
117
|
+
function statesReason(entry: string): boolean {
|
|
118
|
+
if (!/`[^`]+`/.test(entry)) return false
|
|
119
|
+
|
|
120
|
+
const prose = entry.replace(/`[^`]*`/g, '').replace(/^-[ \t]*/, '')
|
|
121
|
+
return /[A-Za-z0-9]/.test(prose)
|
|
122
|
+
}
|
|
123
|
+
const QUESTION_ITEM = /^\d+[a-z]?\.[ \t]+\S/
|
|
124
|
+
|
|
125
|
+
const PLAN_SECTIONS = [
|
|
126
|
+
'Summary',
|
|
127
|
+
'Constraints',
|
|
128
|
+
'Files to touch',
|
|
129
|
+
'Risks',
|
|
130
|
+
'Questions',
|
|
131
|
+
] as const
|
|
132
|
+
|
|
133
|
+
type PlanSection = (typeof PLAN_SECTIONS)[number]
|
|
134
|
+
|
|
135
|
+
const PLAN_REQUIRED: readonly PlanSection[] = [
|
|
136
|
+
'Summary',
|
|
137
|
+
'Files to touch',
|
|
138
|
+
'Risks',
|
|
139
|
+
'Questions',
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
const FENCE = /^(`{3,}|~{3,})/
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Drops every fenced block, so a quoted template is not read as content. A plan
|
|
146
|
+
* showing the shape it writes puts real-looking bullets and headings inside a
|
|
147
|
+
* fence, and scanning them reports the example rather than the plan.
|
|
148
|
+
*
|
|
149
|
+
* A closing fence has to match the opening character and be at least as long,
|
|
150
|
+
* which is what keeps a ```` block holding a ``` example from closing early. An
|
|
151
|
+
* unterminated fence swallows the rest of the document, which under-reports a
|
|
152
|
+
* malformed file rather than reporting its remainder as content.
|
|
153
|
+
*/
|
|
154
|
+
export function linesOutsideFences(text: string): string[] {
|
|
155
|
+
const kept: string[] = []
|
|
156
|
+
let fence: string | undefined
|
|
157
|
+
|
|
158
|
+
for (const line of text.split('\n')) {
|
|
159
|
+
const match = FENCE.exec(line.trim())
|
|
160
|
+
|
|
161
|
+
if (fence) {
|
|
162
|
+
const closes =
|
|
163
|
+
match && match[1][0] === fence[0] && match[1].length >= fence.length
|
|
164
|
+
if (closes) fence = undefined
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (match) {
|
|
169
|
+
fence = match[1]
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
kept.push(line)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return kept
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A line standing alone as a bold label or an H2, whatever it names. A plan is
|
|
181
|
+
* free to carry a section of its own, so the split has to see one to close the
|
|
182
|
+
* section above it.
|
|
183
|
+
*/
|
|
184
|
+
const MARKER_LINE = /^(?:##[ \t]+(.+?)|\*\*(.+?):\*\*)[ \t]*$/
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* A section opens as a bold label or as an H2 and both count. The corpus writes
|
|
188
|
+
* `Summary` as a heading and the other four as bold labels, and roughly a fifth
|
|
189
|
+
* of it swaps one for the other. Reporting the variant would fail nearly every
|
|
190
|
+
* plan present on the rule a reader is least served by, which is what teaches
|
|
191
|
+
* them to skip the output.
|
|
192
|
+
*/
|
|
193
|
+
export function sectionMarker(line: string): PlanSection | undefined {
|
|
194
|
+
const match = MARKER_LINE.exec(line.trim())
|
|
195
|
+
if (!match) return undefined
|
|
196
|
+
|
|
197
|
+
const name = match[1] ?? match[2]
|
|
198
|
+
return PLAN_SECTIONS.find((entry) => entry === name)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The spelling a finding names, which is the one the standard's template ships. */
|
|
202
|
+
export function preferredMarker(section: PlanSection): string {
|
|
203
|
+
return section === 'Summary' ? '## Summary' : `**${section}:**`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function splitPlanSections(text: string): Map<string, string[]> {
|
|
207
|
+
const sections = new Map<string, string[]>()
|
|
208
|
+
let current: string | undefined
|
|
209
|
+
|
|
210
|
+
for (const line of linesOutsideFences(text)) {
|
|
211
|
+
// Any marker-shaped line closes the section above it, and only a recognized
|
|
212
|
+
// one opens a section. A plan carrying a label of its own would otherwise
|
|
213
|
+
// collect its bullets into whichever section came before.
|
|
214
|
+
if (MARKER_LINE.test(line.trim())) {
|
|
215
|
+
current = sectionMarker(line)
|
|
216
|
+
if (current) sections.set(current, [])
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (current) sections.get(current)?.push(line)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return sections
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
interface Question {
|
|
227
|
+
readonly label: string
|
|
228
|
+
readonly body: readonly string[]
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function readQuestions(lines: readonly string[]): Question[] {
|
|
232
|
+
const questions: { label: string; body: string[] }[] = []
|
|
233
|
+
|
|
234
|
+
for (const line of lines) {
|
|
235
|
+
const trimmed = line.trim()
|
|
236
|
+
if (QUESTION_ITEM.test(trimmed)) {
|
|
237
|
+
questions.push({ label: trimmed, body: [] })
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
questions.at(-1)?.body.push(trimmed)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return questions
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function shorten(label: string): string {
|
|
248
|
+
return label.length > 60 ? `${label.slice(0, 57)}...` : label
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function checkQuestionContract(name: string, lines: string[]): Finding[] {
|
|
252
|
+
if (lines.some((line) => line.trim() === NONE_IDENTIFIED)) return []
|
|
253
|
+
|
|
254
|
+
const findings: Finding[] = []
|
|
255
|
+
|
|
256
|
+
for (const question of readQuestions(lines)) {
|
|
257
|
+
const subject = shorten(question.label)
|
|
258
|
+
|
|
259
|
+
if (!question.body.some((line) => line.startsWith('- Suggested:'))) {
|
|
260
|
+
findings.push(
|
|
261
|
+
finding(
|
|
262
|
+
'suggestion-missing',
|
|
263
|
+
name,
|
|
264
|
+
subject,
|
|
265
|
+
'carries no Suggested line, so it arrives at execution as a stop.',
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!question.body.some((line) => line.startsWith('- Answer:'))) {
|
|
271
|
+
findings.push(
|
|
272
|
+
finding(
|
|
273
|
+
'question-unanswerable',
|
|
274
|
+
name,
|
|
275
|
+
subject,
|
|
276
|
+
'carries no Answer slot, so the blank-answer default has nowhere to sit.',
|
|
277
|
+
),
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return findings
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function checkPlan(name: string, text: string): Finding[] {
|
|
286
|
+
const findings: Finding[] = []
|
|
287
|
+
|
|
288
|
+
if (!PLAN_NAME.test(name)) {
|
|
289
|
+
findings.push(
|
|
290
|
+
finding(
|
|
291
|
+
'name-malformed',
|
|
292
|
+
name,
|
|
293
|
+
name,
|
|
294
|
+
'is not named feature-<slug>.md with a kebab-case slug.',
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const lines = linesOutsideFences(text)
|
|
300
|
+
|
|
301
|
+
if (!lines.some((line) => PLAN_TITLE.test(line))) {
|
|
302
|
+
findings.push(
|
|
303
|
+
finding('title-missing', name, name, 'opens with no # Feature: heading.'),
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const sections = splitPlanSections(text)
|
|
308
|
+
|
|
309
|
+
for (const marker of PLAN_REQUIRED) {
|
|
310
|
+
if (!sections.has(marker)) {
|
|
311
|
+
findings.push(
|
|
312
|
+
finding(
|
|
313
|
+
'section-missing',
|
|
314
|
+
name,
|
|
315
|
+
preferredMarker(marker),
|
|
316
|
+
'is required and the plan carries no such section.',
|
|
317
|
+
),
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
for (const line of sections.get('Files to touch') ?? []) {
|
|
323
|
+
const trimmed = line.trim()
|
|
324
|
+
if (!trimmed.startsWith('- ') || trimmed === `- ${NONE_IDENTIFIED}`)
|
|
325
|
+
continue
|
|
326
|
+
|
|
327
|
+
if (!statesReason(trimmed)) {
|
|
328
|
+
findings.push(
|
|
329
|
+
finding(
|
|
330
|
+
'entry-unreasoned',
|
|
331
|
+
name,
|
|
332
|
+
shorten(trimmed),
|
|
333
|
+
'names no file, or names one and says nothing about it.',
|
|
334
|
+
),
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
findings.push(...checkQuestionContract(name, sections.get('Questions') ?? []))
|
|
340
|
+
|
|
341
|
+
return findings
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const DATE_FIELD = /^date:[ \t]*'?"?(\d{4}-\d{2}-\d{2})'?"?[ \t]*$/m
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Reads the opening date off the raw block rather than the parsed fields. A YAML
|
|
348
|
+
* parser resolves an unquoted `YYYY-MM-DD` to a date value on the core schema
|
|
349
|
+
* and to a string elsewhere, and a check keyed on the parsed type would report a
|
|
350
|
+
* conforming file on one runtime and not the other.
|
|
351
|
+
*/
|
|
352
|
+
function hasOpeningDate(raw: string): boolean {
|
|
353
|
+
return DATE_FIELD.test(raw)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function checkFolderFrontmatter(
|
|
357
|
+
dir: string,
|
|
358
|
+
slug: string,
|
|
359
|
+
files: readonly string[],
|
|
360
|
+
indexFile: string,
|
|
361
|
+
): Promise<Finding[]> {
|
|
362
|
+
const perFile = await Promise.all(
|
|
363
|
+
files.map(async (file) => {
|
|
364
|
+
const found: Finding[] = []
|
|
365
|
+
const frontmatter = parseFrontmatter(
|
|
366
|
+
await readFile(join(dir, file), 'utf8'),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
const missing = ['title', 'description'].filter(
|
|
370
|
+
(field) => !readField(frontmatter, field),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
if (missing.length > 0) {
|
|
374
|
+
found.push(
|
|
375
|
+
finding(
|
|
376
|
+
'frontmatter-incomplete',
|
|
377
|
+
slug,
|
|
378
|
+
file,
|
|
379
|
+
`carries no ${missing.join(' and no ')}.`,
|
|
380
|
+
),
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (file === indexFile && !hasOpeningDate(frontmatter?.raw ?? '')) {
|
|
385
|
+
found.push(
|
|
386
|
+
finding(
|
|
387
|
+
'date-malformed',
|
|
388
|
+
slug,
|
|
389
|
+
file,
|
|
390
|
+
'carries no date field as YYYY-MM-DD, so the folder states no opening day.',
|
|
391
|
+
),
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (file !== indexFile && !NUMBERED_FILE.test(file)) {
|
|
396
|
+
found.push(
|
|
397
|
+
finding(
|
|
398
|
+
'name-malformed',
|
|
399
|
+
slug,
|
|
400
|
+
file,
|
|
401
|
+
'is not numbered NN-<name>.md, so the folder has no read order.',
|
|
402
|
+
),
|
|
403
|
+
)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return found
|
|
407
|
+
}),
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
return perFile.flat()
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const GROUNDWORK_INDEX = 'README.md'
|
|
414
|
+
const GROUNDWORK_STATE = '01-current-state.md'
|
|
415
|
+
const GROUNDWORK_DECISION = '06-'
|
|
416
|
+
const GROUNDWORK_HANDOFF = '07-'
|
|
417
|
+
|
|
418
|
+
async function checkTrack(dir: string, slug: string): Promise<Finding[]> {
|
|
419
|
+
const files = await listMarkdown(dir)
|
|
420
|
+
const findings: Finding[] = []
|
|
421
|
+
|
|
422
|
+
if (!files.includes(GROUNDWORK_INDEX)) {
|
|
423
|
+
findings.push(
|
|
424
|
+
finding(
|
|
425
|
+
'index-missing',
|
|
426
|
+
slug,
|
|
427
|
+
GROUNDWORK_INDEX,
|
|
428
|
+
'is absent, so the track carries no file map and no reason it is running.',
|
|
429
|
+
),
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (!files.includes(GROUNDWORK_STATE)) {
|
|
434
|
+
findings.push(
|
|
435
|
+
finding(
|
|
436
|
+
'state-missing',
|
|
437
|
+
slug,
|
|
438
|
+
GROUNDWORK_STATE,
|
|
439
|
+
'is absent, so the track states no measured current state.',
|
|
440
|
+
),
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// A track closes on the decision and the handoff together. One without the
|
|
445
|
+
// other reads as closed to anyone scanning filenames and strands the half a
|
|
446
|
+
// returning session actually opens.
|
|
447
|
+
const decided = files.some((file) => file.startsWith(GROUNDWORK_DECISION))
|
|
448
|
+
const handed = files.some((file) => file.startsWith(GROUNDWORK_HANDOFF))
|
|
449
|
+
|
|
450
|
+
if (decided !== handed) {
|
|
451
|
+
findings.push(
|
|
452
|
+
finding(
|
|
453
|
+
'closing-partial',
|
|
454
|
+
slug,
|
|
455
|
+
decided ? GROUNDWORK_HANDOFF : GROUNDWORK_DECISION,
|
|
456
|
+
`is absent while ${decided ? '06' : '07'} is present, so the track is neither live nor closed.`,
|
|
457
|
+
),
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
findings.push(
|
|
462
|
+
...(await checkFolderFrontmatter(dir, slug, files, GROUNDWORK_INDEX)),
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
return findings
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const INTAKE_INDEX = '00-overview.md'
|
|
469
|
+
const INTAKE_HANDOFF = '99-next-session.md'
|
|
470
|
+
|
|
471
|
+
const ITEM_HEADING = /^###[ \t]+\S/
|
|
472
|
+
const ITEM_REQUIRED = ['Problem', 'Fix', 'Worth it', 'You'] as const
|
|
473
|
+
|
|
474
|
+
function bulletLabel(line: string): string | undefined {
|
|
475
|
+
const match = /^-[ \t]+\*\*([^:*]+):\*\*/.exec(line.trim())
|
|
476
|
+
return match ? match[1].trim() : undefined
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function checkItems(
|
|
480
|
+
slug: string,
|
|
481
|
+
file: string,
|
|
482
|
+
text: string,
|
|
483
|
+
): Finding[] {
|
|
484
|
+
const findings: Finding[] = []
|
|
485
|
+
const items: { heading: string; labels: string[] }[] = []
|
|
486
|
+
|
|
487
|
+
for (const line of linesOutsideFences(text)) {
|
|
488
|
+
if (ITEM_HEADING.test(line)) {
|
|
489
|
+
items.push({ heading: line.trim().replace(/^###[ \t]+/, ''), labels: [] })
|
|
490
|
+
continue
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const label = bulletLabel(line)
|
|
494
|
+
if (label) items.at(-1)?.labels.push(label)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
for (const item of items) {
|
|
498
|
+
const missing = ITEM_REQUIRED.filter(
|
|
499
|
+
(label) => !item.labels.includes(label),
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
if (missing.length > 0) {
|
|
503
|
+
findings.push(
|
|
504
|
+
finding(
|
|
505
|
+
'item-incomplete',
|
|
506
|
+
slug,
|
|
507
|
+
`${file}: ${shorten(item.heading)}`,
|
|
508
|
+
`states no ${missing.join(', no ')}.`,
|
|
509
|
+
),
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (item.labels.includes('Open') && !item.labels.includes('Suggested')) {
|
|
514
|
+
findings.push(
|
|
515
|
+
finding(
|
|
516
|
+
'suggestion-missing',
|
|
517
|
+
slug,
|
|
518
|
+
`${file}: ${shorten(item.heading)}`,
|
|
519
|
+
'asks an open question and suggests nothing, so a bare answer decides it.',
|
|
520
|
+
),
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return findings
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function checkDump(dir: string, slug: string): Promise<Finding[]> {
|
|
529
|
+
const files = await listMarkdown(dir)
|
|
530
|
+
const findings: Finding[] = []
|
|
531
|
+
|
|
532
|
+
if (!files.includes(INTAKE_INDEX)) {
|
|
533
|
+
findings.push(
|
|
534
|
+
finding(
|
|
535
|
+
'index-missing',
|
|
536
|
+
slug,
|
|
537
|
+
INTAKE_INDEX,
|
|
538
|
+
'is absent, so the dump carries no cluster table and no verdict counts.',
|
|
539
|
+
),
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
findings.push(
|
|
544
|
+
...(await checkFolderFrontmatter(dir, slug, files, INTAKE_INDEX)),
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
// The two reserved files hold no items. Running the item check over the
|
|
548
|
+
// handoff would report every heading it carries as a malformed item.
|
|
549
|
+
const clusters = files.filter(
|
|
550
|
+
(file) => file !== INTAKE_INDEX && file !== INTAKE_HANDOFF,
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
const perCluster = await Promise.all(
|
|
554
|
+
clusters.map(async (file) =>
|
|
555
|
+
checkItems(slug, file, await readFile(join(dir, file), 'utf8')),
|
|
556
|
+
),
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
return [...findings, ...perCluster.flat()]
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function refuse(reason: ValidateRefusal, message: string): ValidateRefused {
|
|
563
|
+
return { ok: false, reason, message }
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Reports what every record in one gitignored folder claims against the shape
|
|
568
|
+
* its standard fixes. It writes nothing: the folder is per-machine scratch with
|
|
569
|
+
* no history behind it, so a repair that guessed wrong could not be undone.
|
|
570
|
+
*/
|
|
571
|
+
export async function validateRecords(
|
|
572
|
+
root: string,
|
|
573
|
+
kind: RecordKind,
|
|
574
|
+
): Promise<ValidateOutcome> {
|
|
575
|
+
const dir = recordsDir(root, kind)
|
|
576
|
+
|
|
577
|
+
if (!existsSync(dir)) {
|
|
578
|
+
return refuse('no-folder', `No ${kind} folder at ${dir}.`)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (kind === 'plans') {
|
|
582
|
+
const files = await listMarkdown(dir)
|
|
583
|
+
const perFile = await Promise.all(
|
|
584
|
+
files.map(async (file) =>
|
|
585
|
+
checkPlan(file, await readFile(join(dir, file), 'utf8')),
|
|
586
|
+
),
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
return { ok: true, kind, records: files.length, findings: perFile.flat() }
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const folders = await listFolders(dir)
|
|
593
|
+
const check = kind === 'groundwork' ? checkTrack : checkDump
|
|
594
|
+
const perFolder = await Promise.all(
|
|
595
|
+
folders.map((slug) => check(join(dir, slug), slug)),
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
return { ok: true, kind, records: folders.length, findings: perFolder.flat() }
|
|
599
|
+
}
|
package/standards/groundwork.md
CHANGED
|
@@ -16,6 +16,7 @@ Governs a groundwork track under `.claude/groundwork/<slug>/`: folder layout, re
|
|
|
16
16
|
Does not govern:
|
|
17
17
|
|
|
18
18
|
- A dump of many findings filed by domain, each carrying its own verdict: `intake.md`
|
|
19
|
+
- The feature plan a closed track feeds, and the contract its answer slots keep: `plan.md`
|
|
19
20
|
- The task file a closing track writes, and the origin line pointing back at the folder: `tasks.md`
|
|
20
21
|
- Voice and word choice: `prose.md`
|
|
21
22
|
- Headings, punctuation, and file references: `markdown.md`
|
package/standards/index.md
CHANGED
|
@@ -14,6 +14,7 @@ Reference docs for consistent authoring across the toolkit and target projects.
|
|
|
14
14
|
- [Groundwork reference](groundwork.md): Folder layout, reserved numbering, frontmatter and dating, required file contents, and conventions for a measurement track
|
|
15
15
|
- [Intake reference](intake.md): Folder layout, reserved index number, frontmatter and dating, the item template, the answer contract, and retrieval
|
|
16
16
|
- [Markdown reference](markdown.md): Headings, paragraph and list structure, code spans, punctuation, emphasis, and file references
|
|
17
|
+
- [Plan reference](plan.md): Filename and slug, required sections, the suggested-and-answer contract, and the lifecycle from the live folder to the archive
|
|
17
18
|
- [Prose reference](prose.md): Voice, language, and frontmatter wording for reference markdown
|
|
18
19
|
- [Publish reference](publish.md): Scan run against finished text leaving through a channel no automated check covers
|
|
19
20
|
- [Readme reference](readme.md): Readme voice, structure, and content conventions
|
package/standards/intake.md
CHANGED
|
@@ -16,6 +16,7 @@ Governs an intake folder under `.claude/intake/<slug>/`: folder layout, the rese
|
|
|
16
16
|
Does not govern:
|
|
17
17
|
|
|
18
18
|
- One question measured in depth before anyone can plan against it: `groundwork.md`
|
|
19
|
+
- The feature plan a promoted item feeds, and the inverted answer contract it keeps: `plan.md`
|
|
19
20
|
- The task file promoting an item onto the board, and the origin line pointing back at the folder: `tasks.md`
|
|
20
21
|
- Voice and word choice: `prose.md`
|
|
21
22
|
- Headings, punctuation, and file references: `markdown.md`
|