@erclx/canon 5.48.0 ā 5.49.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/auto-ship/SKILL.md +1 -1
- package/claude/skills/plan-feature/SKILL.md +18 -15
- package/claude/skills/plan-feature/references/batches.md +5 -5
- package/claude/skills/plan-feature/references/plan.md +82 -33
- package/claude/skills/plan-groundwork/SKILL.md +8 -0
- package/claude/skills/plan-groundwork/references/groundwork.md +2 -1
- package/claude/skills/plan-intake/SKILL.md +8 -0
- package/claude/skills/plan-intake/references/intake.md +9 -1
- package/claude/skills/plan-ready/REQUIREMENT.md +3 -3
- package/claude/skills/plan-ready/SKILL.md +3 -3
- package/claude/skills/plan-ready/references/assembly.md +7 -7
- package/claude/skills/plan-ready/references/ready.md +8 -6
- package/claude/skills/review-ui/REQUIREMENT.md +1 -1
- package/claude/skills/review-ui/SKILL.md +4 -11
- package/claude/skills/review-ui/references/close-out.md +1 -1
- package/claude/skills/review-ui/references/drive-copy.md +38 -0
- package/claude/skills/review-ui/references/post.md +24 -3
- package/claude/skills/review-ui/references/verb-absent.md +18 -0
- package/claude/skills/role-orchestrator/SKILL.md +3 -2
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +60 -0
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -0
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
- package/claude/skills/role-planner/SKILL.md +1 -1
- package/claude/skills/role-worker/SKILL.md +4 -2
- package/claude/skills/session-compact/references/handoff-note.md +3 -1
- package/claude/skills/session-map/references/session.md +1 -1
- package/claude/skills/task-board/references/board.md +2 -2
- package/claude/skills/task-board/references/tasks.md +1 -1
- package/docs/agents/records.md +1 -1
- package/docs/workflow/parallel-features.md +2 -2
- package/package.json +1 -1
- package/src/commands/pr.ts +411 -23
- package/src/commands/records.ts +45 -8
- package/src/commands/tasks.ts +12 -0
- package/src/hooks/post-merge.ts +11 -0
- package/src/pr/drive-copy.ts +218 -0
- package/src/pr/local.ts +26 -0
- package/src/pr/rest.ts +2 -0
- package/src/records/structure.ts +147 -0
- package/src/records/validate.ts +113 -12
- package/src/tasks/archive.ts +137 -4
- package/src/tasks/board.ts +68 -13
- package/src/tasks/validate.ts +39 -2
- package/tooling/base/configs/.github/workflows/pr-local-preview.yml +1 -1
package/src/commands/tasks.ts
CHANGED
|
@@ -222,6 +222,11 @@ export function register(program: Command): void {
|
|
|
222
222
|
'listed, and pending-branch while a Pending branch: line names a branch',
|
|
223
223
|
'whose own pull request is not recorded yet. A stem skips both.',
|
|
224
224
|
'',
|
|
225
|
+
'On an earlier-slice or open-outcomes refusal from --pull-request, the',
|
|
226
|
+
'slice plan whose derived branch is the pull request head, read through',
|
|
227
|
+
'gh, moves to the plans archive and the task stays open. The record',
|
|
228
|
+
'names it under `slice`.',
|
|
229
|
+
'',
|
|
225
230
|
'Refusals, read off `reason`:',
|
|
226
231
|
' no-board the repository holds no `.canon/tasks/` board',
|
|
227
232
|
' no-match no task matches the selector',
|
|
@@ -2187,6 +2192,10 @@ function report(
|
|
|
2187
2192
|
logStep('Refused')
|
|
2188
2193
|
logError(outcome.message)
|
|
2189
2194
|
if (outcome.detail.length > 0) pipeOutput(outcome.detail.join('\n'))
|
|
2195
|
+
if (outcome.slice) {
|
|
2196
|
+
logStep('Slice plan archived')
|
|
2197
|
+
logPlanMoves([outcome.slice], root)
|
|
2198
|
+
}
|
|
2190
2199
|
outro()
|
|
2191
2200
|
return 1
|
|
2192
2201
|
}
|
|
@@ -2276,6 +2285,9 @@ function recordFor(
|
|
|
2276
2285
|
reason: outcome.reason,
|
|
2277
2286
|
message: outcome.message,
|
|
2278
2287
|
detail: outcome.detail,
|
|
2288
|
+
...(outcome.slice
|
|
2289
|
+
? { slice: planMovesFor([outcome.slice], root)[0] }
|
|
2290
|
+
: {}),
|
|
2279
2291
|
}
|
|
2280
2292
|
}
|
|
2281
2293
|
|
package/src/hooks/post-merge.ts
CHANGED
|
@@ -161,8 +161,19 @@ function archiveLines(
|
|
|
161
161
|
if (reason === undefined || reason === 'no-match' || reason === 'no-board') {
|
|
162
162
|
return undefined
|
|
163
163
|
}
|
|
164
|
+
// A merged slice moves its own plan while the task stays open, so the line
|
|
165
|
+
// names the plan that dropped off the board row.
|
|
166
|
+
const slice =
|
|
167
|
+
fields.slice !== null && typeof fields.slice === 'object'
|
|
168
|
+
? text(fields.slice as Fields, 'from')
|
|
169
|
+
: undefined
|
|
170
|
+
const moved =
|
|
171
|
+
slice === undefined
|
|
172
|
+
? ''
|
|
173
|
+
: `šļø Archived the slice plan ${slice}, and the task stays open.\n`
|
|
164
174
|
return (
|
|
165
175
|
`\nš Task not archived for #${number}: ${text(fields, 'message') ?? reason}\n` +
|
|
176
|
+
moved +
|
|
166
177
|
`Run /task-complete archive #${number} to resolve it by hand.\n\n`
|
|
167
178
|
)
|
|
168
179
|
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { cpSync, existsSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
3
|
+
import { isAbsolute, join } from 'node:path'
|
|
4
|
+
import { $ } from 'bun'
|
|
5
|
+
import { gitEnv } from '@/git/env'
|
|
6
|
+
|
|
7
|
+
/** Where a project names the folders its running preview saves into. */
|
|
8
|
+
export const PREVIEW_CONFIG_REL = join('canon', 'config', 'preview.json')
|
|
9
|
+
|
|
10
|
+
/** The variable a preview server reads for the copy root when the project names none. */
|
|
11
|
+
export const DEFAULT_PREVIEW_ENV = 'CANON_PREVIEW_ROOT'
|
|
12
|
+
|
|
13
|
+
/** The type vocabulary of `git-branch`'s branch standard, which the slug transform drops. */
|
|
14
|
+
const BRANCH_TYPES = new Set([
|
|
15
|
+
'feat',
|
|
16
|
+
'fix',
|
|
17
|
+
'refactor',
|
|
18
|
+
'docs',
|
|
19
|
+
'chore',
|
|
20
|
+
'perf',
|
|
21
|
+
'test',
|
|
22
|
+
'style',
|
|
23
|
+
'build',
|
|
24
|
+
'ci',
|
|
25
|
+
'revert',
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
export interface PreviewConfig {
|
|
29
|
+
readonly writes: readonly string[]
|
|
30
|
+
readonly env: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type PreviewConfigRead =
|
|
34
|
+
| { readonly kind: 'absent' }
|
|
35
|
+
| { readonly kind: 'read'; readonly config: PreviewConfig }
|
|
36
|
+
| {
|
|
37
|
+
readonly kind: 'refused'
|
|
38
|
+
readonly reason: 'bad-config' | 'unsafe-path'
|
|
39
|
+
readonly detail: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface StatusEntry {
|
|
43
|
+
readonly path: string
|
|
44
|
+
readonly status: string
|
|
45
|
+
/** The content digest, or null where no regular file stands at the path. */
|
|
46
|
+
readonly digest: string | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The branch-to-slug transform every branch-derived name shares: a leading
|
|
51
|
+
* type segment comes off when a further segment follows it, and each
|
|
52
|
+
* remaining `/` becomes `-`. The worker reads its branch off the worktree and
|
|
53
|
+
* the reviewer off the pull request's head ref, so this one function is what
|
|
54
|
+
* lands both on the same folder.
|
|
55
|
+
*/
|
|
56
|
+
export function branchSlug(branch: string): string {
|
|
57
|
+
const segments = branch.split('/')
|
|
58
|
+
const rest =
|
|
59
|
+
segments.length > 1 && BRANCH_TYPES.has(segments[0] ?? '')
|
|
60
|
+
? segments.slice(1)
|
|
61
|
+
: segments
|
|
62
|
+
return rest.join('-')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isContained(path: string): boolean {
|
|
66
|
+
return (
|
|
67
|
+
path.length > 0 && !isAbsolute(path) && !path.split(/[\\/]/).includes('..')
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Reads `canon/config/preview.json` as `{ "writes": [...], "env": "<NAME>" }`.
|
|
73
|
+
* A file that does not parse refuses rather than reading as absent, since a
|
|
74
|
+
* typo there would otherwise drive the real folders while the pass reports a
|
|
75
|
+
* copy. A `writes` path that is absolute or climbs with `..` refuses, so the
|
|
76
|
+
* copy never reads or writes outside its two roots.
|
|
77
|
+
*/
|
|
78
|
+
export function loadPreviewConfig(worktree: string): PreviewConfigRead {
|
|
79
|
+
const path = join(worktree, PREVIEW_CONFIG_REL)
|
|
80
|
+
if (!existsSync(path)) return { kind: 'absent' }
|
|
81
|
+
|
|
82
|
+
let parsed: unknown
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
85
|
+
} catch {
|
|
86
|
+
return {
|
|
87
|
+
kind: 'refused',
|
|
88
|
+
reason: 'bad-config',
|
|
89
|
+
detail: `${PREVIEW_CONFIG_REL} does not parse as JSON.`,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
93
|
+
return {
|
|
94
|
+
kind: 'refused',
|
|
95
|
+
reason: 'bad-config',
|
|
96
|
+
detail: `${PREVIEW_CONFIG_REL} is not an object.`,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const { writes, env } = parsed as Record<string, unknown>
|
|
101
|
+
if (
|
|
102
|
+
!Array.isArray(writes) ||
|
|
103
|
+
!writes.every((item): item is string => typeof item === 'string')
|
|
104
|
+
) {
|
|
105
|
+
return {
|
|
106
|
+
kind: 'refused',
|
|
107
|
+
reason: 'bad-config',
|
|
108
|
+
detail: `${PREVIEW_CONFIG_REL} carries no "writes" list of paths.`,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (env !== undefined && (typeof env !== 'string' || env === '')) {
|
|
112
|
+
return {
|
|
113
|
+
kind: 'refused',
|
|
114
|
+
reason: 'bad-config',
|
|
115
|
+
detail: `${PREVIEW_CONFIG_REL} names an "env" that is not a variable name.`,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const unsafe = writes.find((item) => !isContained(item))
|
|
120
|
+
if (unsafe !== undefined) {
|
|
121
|
+
return {
|
|
122
|
+
kind: 'refused',
|
|
123
|
+
reason: 'unsafe-path',
|
|
124
|
+
detail: `${PREVIEW_CONFIG_REL} names "${unsafe}", which leaves the worktree.`,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
kind: 'read',
|
|
130
|
+
config: { writes, env: env ?? DEFAULT_PREVIEW_ENV },
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Replaces the copy root with a fresh copy of each declared path, so the last
|
|
136
|
+
* pass's drives never reach the next one. A path the worktree does not hold
|
|
137
|
+
* is named rather than failing the copy.
|
|
138
|
+
*/
|
|
139
|
+
export function buildDriveCopy(
|
|
140
|
+
worktree: string,
|
|
141
|
+
copyRoot: string,
|
|
142
|
+
writes: readonly string[],
|
|
143
|
+
): { readonly copied: readonly string[]; readonly missing: readonly string[] } {
|
|
144
|
+
rmSync(copyRoot, { recursive: true, force: true })
|
|
145
|
+
const copied: string[] = []
|
|
146
|
+
const missing: string[] = []
|
|
147
|
+
for (const path of writes) {
|
|
148
|
+
const source = join(worktree, path)
|
|
149
|
+
if (!existsSync(source)) {
|
|
150
|
+
missing.push(path)
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
cpSync(source, join(copyRoot, path), { recursive: true })
|
|
154
|
+
copied.push(path)
|
|
155
|
+
}
|
|
156
|
+
return { copied, missing }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function digestOf(path: string): string | null {
|
|
160
|
+
try {
|
|
161
|
+
if (!statSync(path).isFile()) return null
|
|
162
|
+
return createHash('sha1').update(readFileSync(path)).digest('hex')
|
|
163
|
+
} catch {
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Every entry `git status` reports, untracked files listed one by one so a
|
|
170
|
+
* file a drive adds inside a tracked folder names itself, each with a content
|
|
171
|
+
* digest so a file already dirty at the baseline still reads as changed when
|
|
172
|
+
* a drive writes it again. Undefined when git cannot read the tree.
|
|
173
|
+
*/
|
|
174
|
+
export async function readWorktreeStatus(
|
|
175
|
+
worktree: string,
|
|
176
|
+
): Promise<readonly StatusEntry[] | undefined> {
|
|
177
|
+
const result =
|
|
178
|
+
await $`git -C ${worktree} status --porcelain=v1 -z --untracked-files=all`
|
|
179
|
+
.env(gitEnv())
|
|
180
|
+
.quiet()
|
|
181
|
+
.nothrow()
|
|
182
|
+
if (result.exitCode !== 0) return undefined
|
|
183
|
+
|
|
184
|
+
const tokens = result.stdout.toString().split('\0')
|
|
185
|
+
const entries: StatusEntry[] = []
|
|
186
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
187
|
+
const token = tokens[index] ?? ''
|
|
188
|
+
if (token.length < 4) continue
|
|
189
|
+
const status = token.slice(0, 2)
|
|
190
|
+
const path = token.slice(3)
|
|
191
|
+
// A rename or copy carries its source as the next token.
|
|
192
|
+
if (status.includes('R') || status.includes('C')) index++
|
|
193
|
+
entries.push({ path, status, digest: digestOf(join(worktree, path)) })
|
|
194
|
+
}
|
|
195
|
+
return entries
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The paths whose status or content differs from the baseline, in the order
|
|
200
|
+
* git reports them. An entry gone since the baseline is not reported, since a
|
|
201
|
+
* worker committing its own edit mid-pass clears it the same way.
|
|
202
|
+
*/
|
|
203
|
+
export function changedSince(
|
|
204
|
+
baseline: readonly StatusEntry[],
|
|
205
|
+
current: readonly StatusEntry[],
|
|
206
|
+
): readonly string[] {
|
|
207
|
+
const before = new Map(baseline.map((entry) => [entry.path, entry]))
|
|
208
|
+
return current
|
|
209
|
+
.filter((entry) => {
|
|
210
|
+
const prior = before.get(entry.path)
|
|
211
|
+
return (
|
|
212
|
+
prior === undefined ||
|
|
213
|
+
prior.status !== entry.status ||
|
|
214
|
+
prior.digest !== entry.digest
|
|
215
|
+
)
|
|
216
|
+
})
|
|
217
|
+
.map((entry) => entry.path)
|
|
218
|
+
}
|
package/src/pr/local.ts
CHANGED
|
@@ -64,3 +64,29 @@ export function dropAddressLine(body: string): string | undefined {
|
|
|
64
64
|
if (isGapAbove && (lines[first] ?? '').trim() === '') lines.splice(first, 1)
|
|
65
65
|
return lines.join('\n')
|
|
66
66
|
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A loopback URL with any port and path, and the backticks or angle brackets a
|
|
70
|
+
* pass may have wrapped it in. These hosts resolve on one machine for one
|
|
71
|
+
* session, so no later reader of the thread can open them.
|
|
72
|
+
*/
|
|
73
|
+
const LOOPBACK_URL =
|
|
74
|
+
/[`<]?https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/[^\s`>,]*)?[`>]?/g
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Rewrites the loopback address on a UI review's `Driven at` line to "a local
|
|
78
|
+
* preview", keeping the head and the renderer. Only that line is read, so a
|
|
79
|
+
* loopback URL a box reason quotes from the page stays as content. Undefined
|
|
80
|
+
* when no line changes, which is also what a second mask of the same body
|
|
81
|
+
* returns.
|
|
82
|
+
*/
|
|
83
|
+
export function maskLocalAddress(body: string): string | undefined {
|
|
84
|
+
let changed = false
|
|
85
|
+
const lines = body.split('\n').map((line) => {
|
|
86
|
+
if (!line.startsWith('Driven at ')) return line
|
|
87
|
+
const masked = line.replace(LOOPBACK_URL, 'a local preview')
|
|
88
|
+
if (masked !== line) changed = true
|
|
89
|
+
return masked
|
|
90
|
+
})
|
|
91
|
+
return changed ? lines.join('\n') : undefined
|
|
92
|
+
}
|
package/src/pr/rest.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface RestPull {
|
|
|
25
25
|
|
|
26
26
|
/** One row of `repos/{owner}/{repo}/pulls/<n>/reviews`. */
|
|
27
27
|
export interface RestReview {
|
|
28
|
+
/** The id the update call `pulls/<n>/reviews/<id>` names. */
|
|
29
|
+
readonly id?: number
|
|
28
30
|
readonly body?: string | null
|
|
29
31
|
readonly commit_id?: string | null
|
|
30
32
|
readonly submitted_at?: string | null
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { type DensityKind, densityFindings } from '@/markdown/density'
|
|
2
|
+
import { bodyLines } from '@/markdown/scan'
|
|
3
|
+
import { isSectionMarker } from '@/markdown/structure'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The criteria `plan.md`'s `### Structure` states for every hand-written
|
|
7
|
+
* record. `S6`, a full path in a section a parser reads, is not here, since
|
|
8
|
+
* `canon tasks plan-reach` already reports a bare filename as unclaimed.
|
|
9
|
+
*/
|
|
10
|
+
export const STRUCTURE_CRITERIA = ['S1', 'S2', 'S3', 'S4', 'S5'] as const
|
|
11
|
+
|
|
12
|
+
export type StructureCriterion = (typeof STRUCTURE_CRITERIA)[number]
|
|
13
|
+
|
|
14
|
+
/** One place a record misses a criterion, or an intake field spilling past its line. */
|
|
15
|
+
export interface StructureEntry {
|
|
16
|
+
readonly criterion: StructureCriterion | 'intake-spill'
|
|
17
|
+
/** The line as an editor numbers it, frontmatter included. */
|
|
18
|
+
readonly line: number
|
|
19
|
+
readonly message: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The density limits a record criterion takes over. The paragraph limits are
|
|
24
|
+
* left out, since the record standard caps no paragraph and names no run.
|
|
25
|
+
*/
|
|
26
|
+
const CRITERION_OF: Partial<Record<DensityKind, StructureCriterion>> = {
|
|
27
|
+
'bullet-run': 'S2',
|
|
28
|
+
'h3-opening': 'S3',
|
|
29
|
+
'bullet-length': 'S4',
|
|
30
|
+
'heading-depth': 'S5',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const MESSAGE: Record<StructureCriterion, string> = {
|
|
34
|
+
S1: 'is a bold label standing alone as a section marker. Write it as an H2.',
|
|
35
|
+
S2: 'opens a run of more than 5 plain bullets. Group them under H3s.',
|
|
36
|
+
S3: 'opens with no lead sentence carrying what its bullets share.',
|
|
37
|
+
S4: 'is a bullet past 200 characters. Make it a short paragraph, or split it.',
|
|
38
|
+
S5: 'is an H4 or deeper. Fold it into its H3.',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every place a record misses `S1` to `S5`, in line order.
|
|
43
|
+
*
|
|
44
|
+
* `S2` to `S5` are the markdown density limits read on the record's own
|
|
45
|
+
* terms, so a labeled field run stays exempt from `S2` here as it does there.
|
|
46
|
+
* `S1` is any line `isSectionMarker` reads as a bold section marker, which an
|
|
47
|
+
* old plan carries on every section and still parses through.
|
|
48
|
+
*/
|
|
49
|
+
export function structureFindings(text: string): StructureEntry[] {
|
|
50
|
+
const lines = bodyLines(text)
|
|
51
|
+
const found: StructureEntry[] = []
|
|
52
|
+
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
if (!line.fenced && isSectionMarker(line.text)) {
|
|
55
|
+
found.push({ criterion: 'S1', line: line.number, message: MESSAGE.S1 })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const density of densityFindings(lines)) {
|
|
60
|
+
const criterion = CRITERION_OF[density.kind]
|
|
61
|
+
if (criterion) {
|
|
62
|
+
found.push({ criterion, line: density.line, message: MESSAGE[criterion] })
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return found.sort((a, b) => a.line - b.line)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** An item heading, as `readItems` in `src/intake/items.ts` reads one. */
|
|
70
|
+
const ITEM_HEADING = /^###\s+\d+[a-z]*\.\s/i
|
|
71
|
+
|
|
72
|
+
/** A bolded field bullet, the one line `readItems` takes from a field. */
|
|
73
|
+
const FIELD = /^-\s+\*\*([^*]+):\*\*/
|
|
74
|
+
|
|
75
|
+
/** Any heading, which closes the item above it. */
|
|
76
|
+
const SECTION = /^#{1,6}\s/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Every block of content sitting under an intake field rather than on its
|
|
80
|
+
* line, one entry at the block's first line.
|
|
81
|
+
*
|
|
82
|
+
* `canon intake list` reads a field's own line and nothing under it, so a
|
|
83
|
+
* nested list or a paragraph below a field never reaches the operator
|
|
84
|
+
* answering in chat. A trailing paragraph after the last field is reported
|
|
85
|
+
* too, since it is dropped the same way.
|
|
86
|
+
*/
|
|
87
|
+
export function intakeSpill(text: string): StructureEntry[] {
|
|
88
|
+
const found: StructureEntry[] = []
|
|
89
|
+
let item: string | undefined
|
|
90
|
+
let field: string | undefined
|
|
91
|
+
let spilling = false
|
|
92
|
+
|
|
93
|
+
for (const line of bodyLines(text)) {
|
|
94
|
+
if (line.fenced) continue
|
|
95
|
+
|
|
96
|
+
const heading = ITEM_HEADING.exec(line.text)
|
|
97
|
+
if (heading || SECTION.test(line.text)) {
|
|
98
|
+
item = heading
|
|
99
|
+
? line.text.replace(/^###\s+/, '').split('.')[0]
|
|
100
|
+
: undefined
|
|
101
|
+
field = undefined
|
|
102
|
+
spilling = false
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (item === undefined || line.text.trim() === '') continue
|
|
107
|
+
|
|
108
|
+
const match = FIELD.exec(line.text)
|
|
109
|
+
if (match) {
|
|
110
|
+
field = match[1].trim()
|
|
111
|
+
spilling = false
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (spilling) continue
|
|
116
|
+
spilling = true
|
|
117
|
+
|
|
118
|
+
const under = field ? `the ${field} field of item ${item}` : `item ${item}`
|
|
119
|
+
found.push({
|
|
120
|
+
criterion: 'intake-spill',
|
|
121
|
+
line: line.number,
|
|
122
|
+
message: `sits under ${under}, and canon intake list reads only a field's own line. Keep the content on the line, or split the item.`,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return found
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The structure report for an intake cluster file. Item headings open on
|
|
131
|
+
* their fields by the template's design, so `S3` skips them, and a field
|
|
132
|
+
* carries its whole content on one line, so `S4` skips it.
|
|
133
|
+
*/
|
|
134
|
+
export function intakeStructure(text: string): StructureEntry[] {
|
|
135
|
+
const lines = text.split('\n')
|
|
136
|
+
const exempt = (entry: StructureEntry): boolean => {
|
|
137
|
+
const source = lines[entry.line - 1] ?? ''
|
|
138
|
+
if (entry.criterion === 'S3') return ITEM_HEADING.test(source)
|
|
139
|
+
if (entry.criterion === 'S4') return FIELD.test(source)
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return [
|
|
144
|
+
...structureFindings(text).filter((entry) => !exempt(entry)),
|
|
145
|
+
...intakeSpill(text),
|
|
146
|
+
].sort((a, b) => a.line - b.line)
|
|
147
|
+
}
|
package/src/records/validate.ts
CHANGED
|
@@ -3,6 +3,11 @@ import { readdir, readFile } from 'node:fs/promises'
|
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
5
5
|
import { linesOutsideFences } from '@/markdown/scan'
|
|
6
|
+
import {
|
|
7
|
+
intakeStructure,
|
|
8
|
+
type StructureEntry,
|
|
9
|
+
structureFindings,
|
|
10
|
+
} from '@/records/structure'
|
|
6
11
|
import {
|
|
7
12
|
recordDir as resolveRecordDir,
|
|
8
13
|
recordDirs as resolveRecordDirs,
|
|
@@ -27,7 +32,11 @@ export type RecordKind = (typeof RECORD_KINDS)[number]
|
|
|
27
32
|
* union covering only what the walk returns would type a record the command can
|
|
28
33
|
* emit as impossible.
|
|
29
34
|
*/
|
|
30
|
-
export const VALIDATE_REFUSALS = [
|
|
35
|
+
export const VALIDATE_REFUSALS = [
|
|
36
|
+
'no-folder',
|
|
37
|
+
'no-record',
|
|
38
|
+
'unknown-kind',
|
|
39
|
+
] as const
|
|
31
40
|
|
|
32
41
|
export type ValidateRefusal = (typeof VALIDATE_REFUSALS)[number]
|
|
33
42
|
|
|
@@ -61,11 +70,24 @@ export interface Finding {
|
|
|
61
70
|
readonly message: string
|
|
62
71
|
}
|
|
63
72
|
|
|
73
|
+
/**
|
|
74
|
+
* A place a record misses a structure criterion. It sits apart from `Finding`
|
|
75
|
+
* so it never sets the exit, since every record written before the criteria
|
|
76
|
+
* misses `S1` and a gate on it would fail them all.
|
|
77
|
+
*/
|
|
78
|
+
export interface StructureFinding extends StructureEntry {
|
|
79
|
+
/** The record the entry sits in, relative to the validated folder. */
|
|
80
|
+
readonly record: string
|
|
81
|
+
/** The file inside a folder record, or the record itself for a plan. */
|
|
82
|
+
readonly subject: string
|
|
83
|
+
}
|
|
84
|
+
|
|
64
85
|
export interface ValidateReport {
|
|
65
86
|
readonly ok: true
|
|
66
87
|
readonly kind: RecordKind
|
|
67
88
|
readonly records: number
|
|
68
89
|
readonly findings: readonly Finding[]
|
|
90
|
+
readonly structure: readonly StructureFinding[]
|
|
69
91
|
}
|
|
70
92
|
|
|
71
93
|
export interface ValidateRefused {
|
|
@@ -267,7 +289,7 @@ export function sectionMarker(line: string): PlanSection | undefined {
|
|
|
267
289
|
|
|
268
290
|
/** The spelling a finding names, which is the one the standard's template ships. */
|
|
269
291
|
export function preferredMarker(section: PlanSection): string {
|
|
270
|
-
return
|
|
292
|
+
return `## ${section}`
|
|
271
293
|
}
|
|
272
294
|
|
|
273
295
|
export function splitPlanSections(text: string): Map<string, string[]> {
|
|
@@ -915,6 +937,42 @@ const FOLDER_CHECK: Readonly<
|
|
|
915
937
|
teach: checkWorkspace,
|
|
916
938
|
}
|
|
917
939
|
|
|
940
|
+
/**
|
|
941
|
+
* The files of a folder record the structure report reads. A groundwork
|
|
942
|
+
* track's `evidence/`, `scripts/`, and `clones/` sit below the top level and
|
|
943
|
+
* hold copies and raw output, so only its index and numbered files are read.
|
|
944
|
+
* An intake folder's reserved files hold no items, so only clusters are.
|
|
945
|
+
*/
|
|
946
|
+
const STRUCTURE_FILES: Readonly<
|
|
947
|
+
Partial<Record<RecordKind, (file: string) => boolean>>
|
|
948
|
+
> = {
|
|
949
|
+
groundwork: (file) => file === GROUNDWORK_INDEX || NUMBERED_FILE.test(file),
|
|
950
|
+
intake: (file) => file !== INTAKE_INDEX && file !== INTAKE_HANDOFF,
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
async function folderStructure(
|
|
954
|
+
dir: string,
|
|
955
|
+
kind: RecordKind,
|
|
956
|
+
slug: string,
|
|
957
|
+
): Promise<StructureFinding[]> {
|
|
958
|
+
const reads = STRUCTURE_FILES[kind]
|
|
959
|
+
if (!reads) return []
|
|
960
|
+
|
|
961
|
+
const report = kind === 'intake' ? intakeStructure : structureFindings
|
|
962
|
+
const files = (await listMarkdown(dir)).filter(reads)
|
|
963
|
+
const perFile = await Promise.all(
|
|
964
|
+
files.map(async (file) =>
|
|
965
|
+
report(await readFile(join(dir, file), 'utf8')).map((entry) => ({
|
|
966
|
+
...entry,
|
|
967
|
+
record: slug,
|
|
968
|
+
subject: file,
|
|
969
|
+
})),
|
|
970
|
+
),
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
return perFile.flat()
|
|
974
|
+
}
|
|
975
|
+
|
|
918
976
|
function refuse(reason: ValidateRefusal, message: string): ValidateRefused {
|
|
919
977
|
return { ok: false, reason, message }
|
|
920
978
|
}
|
|
@@ -923,17 +981,42 @@ function refuse(reason: ValidateRefusal, message: string): ValidateRefused {
|
|
|
923
981
|
async function validateFiles(
|
|
924
982
|
dir: string,
|
|
925
983
|
kind: RecordKind,
|
|
984
|
+
files: readonly string[],
|
|
926
985
|
check: (name: string, text: string) => Finding[],
|
|
927
986
|
): Promise<ValidateReport> {
|
|
928
|
-
const files = await listMarkdown(dir)
|
|
929
|
-
|
|
930
987
|
const perFile = await Promise.all(
|
|
931
|
-
files.map(async (file) =>
|
|
932
|
-
|
|
933
|
-
|
|
988
|
+
files.map(async (file) => {
|
|
989
|
+
const text = await readFile(join(dir, file), 'utf8')
|
|
990
|
+
return {
|
|
991
|
+
findings: check(file, text),
|
|
992
|
+
structure: structureFindings(text).map((entry) => ({
|
|
993
|
+
...entry,
|
|
994
|
+
record: file,
|
|
995
|
+
subject: file,
|
|
996
|
+
})),
|
|
997
|
+
}
|
|
998
|
+
}),
|
|
934
999
|
)
|
|
935
1000
|
|
|
936
|
-
return {
|
|
1001
|
+
return {
|
|
1002
|
+
ok: true,
|
|
1003
|
+
kind,
|
|
1004
|
+
records: files.length,
|
|
1005
|
+
findings: perFile.flatMap((one) => one.findings),
|
|
1006
|
+
structure: perFile.flatMap((one) => one.structure),
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Narrows a listing to the one record a caller named, by a plan's stem or a
|
|
1012
|
+
* folder's slug, so a writing skill reads its own record and not every old one.
|
|
1013
|
+
*/
|
|
1014
|
+
function narrow(
|
|
1015
|
+
names: readonly string[],
|
|
1016
|
+
record: string | undefined,
|
|
1017
|
+
): readonly string[] {
|
|
1018
|
+
if (record === undefined) return names
|
|
1019
|
+
return names.filter((name) => name === record || name === `${record}.md`)
|
|
937
1020
|
}
|
|
938
1021
|
|
|
939
1022
|
/**
|
|
@@ -944,6 +1027,7 @@ async function validateFiles(
|
|
|
944
1027
|
export async function validateRecords(
|
|
945
1028
|
root: string,
|
|
946
1029
|
kind: RecordKind,
|
|
1030
|
+
record?: string,
|
|
947
1031
|
): Promise<ValidateOutcome> {
|
|
948
1032
|
const dir = recordsDir(root, kind)
|
|
949
1033
|
|
|
@@ -954,13 +1038,30 @@ export async function validateRecords(
|
|
|
954
1038
|
)
|
|
955
1039
|
}
|
|
956
1040
|
|
|
957
|
-
|
|
1041
|
+
const names = narrow(
|
|
1042
|
+
kind === 'plans' ? await listMarkdown(dir) : await listFolders(dir),
|
|
1043
|
+
record,
|
|
1044
|
+
)
|
|
1045
|
+
|
|
1046
|
+
if (record !== undefined && names.length === 0) {
|
|
1047
|
+
return refuse('no-record', `No ${kind} record named ${record} in ${dir}.`)
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
if (kind === 'plans') return validateFiles(dir, kind, names, checkPlan)
|
|
958
1051
|
|
|
959
|
-
const folders = await listFolders(dir)
|
|
960
1052
|
const check = FOLDER_CHECK[kind]
|
|
961
1053
|
const perFolder = await Promise.all(
|
|
962
|
-
|
|
1054
|
+
names.map(async (slug) => ({
|
|
1055
|
+
findings: await check(join(dir, slug), slug),
|
|
1056
|
+
structure: await folderStructure(join(dir, slug), kind, slug),
|
|
1057
|
+
})),
|
|
963
1058
|
)
|
|
964
1059
|
|
|
965
|
-
return {
|
|
1060
|
+
return {
|
|
1061
|
+
ok: true,
|
|
1062
|
+
kind,
|
|
1063
|
+
records: names.length,
|
|
1064
|
+
findings: perFolder.flatMap((one) => one.findings),
|
|
1065
|
+
structure: perFolder.flatMap((one) => one.structure),
|
|
1066
|
+
}
|
|
966
1067
|
}
|