@erclx/canon 5.47.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/git-pr/references/closing-issues.md +2 -0
- 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 +2 -2
- package/claude/skills/review-ui/SKILL.md +7 -14
- 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 +36 -3
- package/claude/skills/review-ui/references/tick.md +4 -1
- 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-poll.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +4 -2
- 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-reviewer/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 +5 -3
- package/claude/skills/task-complete/SKILL.md +1 -0
- package/claude/skills/task-complete/references/archive.md +2 -1
- package/docs/agents/migrate.md +2 -2
- package/docs/agents/records.md +1 -1
- package/docs/target/migrations.md +4 -2
- package/docs/workflow/operating-model.md +2 -2
- package/docs/workflow/parallel-features.md +2 -2
- package/package.json +1 -1
- package/src/commands/claude.ts +4 -0
- package/src/commands/migrate.ts +5 -4
- package/src/commands/pr.ts +712 -58
- package/src/commands/records.ts +45 -8
- package/src/commands/tasks.ts +21 -1
- package/src/hooks/post-merge.ts +11 -0
- package/src/migrate/session-defaults.ts +20 -5
- package/src/pr/drive-copy.ts +218 -0
- package/src/pr/lift.ts +19 -0
- package/src/pr/local.ts +26 -0
- package/src/pr/merge.ts +175 -0
- package/src/pr/rest.ts +3 -0
- package/src/pr/review-scope.ts +40 -1
- package/src/pr/tick.ts +21 -1
- package/src/records/structure.ts +147 -0
- package/src/records/validate.ts +113 -12
- package/src/tasks/archive.ts +154 -8
- package/src/tasks/board.ts +68 -13
- package/src/tasks/closes.ts +9 -5
- package/src/tasks/validate.ts +43 -4
- package/tooling/base/configs/.github/workflows/pr-local-preview.yml +1 -1
- package/tooling/claude/reference.md +3 -1
- package/tooling/claude/seeds/.claude/settings.json +1 -1
package/src/pr/merge.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { ChecksGate } from '@/pr/lift'
|
|
2
|
+
import { readSection } from '@/pr/paths'
|
|
3
|
+
import { UI_PASS_MARKER } from '@/pr/tick'
|
|
4
|
+
|
|
5
|
+
/** Why the verb did not merge, in the order the refusals are read. */
|
|
6
|
+
export type MergeRefusal =
|
|
7
|
+
| 'pull-closed'
|
|
8
|
+
| 'pull-draft'
|
|
9
|
+
| 'missing-summary'
|
|
10
|
+
| 'missing-key-changes'
|
|
11
|
+
| 'ci-failing'
|
|
12
|
+
| 'ci-conflicted'
|
|
13
|
+
| 'ci-pending'
|
|
14
|
+
| 'head-moved'
|
|
15
|
+
| 'not-mergeable'
|
|
16
|
+
| 'gh-failed'
|
|
17
|
+
|
|
18
|
+
export interface MergePull {
|
|
19
|
+
readonly isOpen: boolean
|
|
20
|
+
readonly isDraft: boolean
|
|
21
|
+
readonly title: string
|
|
22
|
+
readonly body: string
|
|
23
|
+
/** The head the merge is pinned to, so a push after the read refuses. */
|
|
24
|
+
readonly head: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MergeRequest {
|
|
28
|
+
readonly title: string
|
|
29
|
+
readonly description: string
|
|
30
|
+
readonly sha: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What the merge endpoint answered, with 409 and 405 told apart from a merge. */
|
|
34
|
+
export type MergeOutcome =
|
|
35
|
+
| { readonly kind: 'merged'; readonly sha: string }
|
|
36
|
+
| { readonly kind: 'head-moved' }
|
|
37
|
+
| { readonly kind: 'not-mergeable' }
|
|
38
|
+
| { readonly kind: 'failed' }
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Everything the verb reads or writes, injected so every refusal runs without
|
|
42
|
+
* a network. A read that failed returns undefined, and the verb refuses
|
|
43
|
+
* `gh-failed` rather than reading absence as a pass.
|
|
44
|
+
*/
|
|
45
|
+
export interface MergeApi {
|
|
46
|
+
readPull(): Promise<MergePull | undefined>
|
|
47
|
+
readChecks(head: string): Promise<ChecksGate | undefined>
|
|
48
|
+
putMerge(request: MergeRequest): Promise<MergeOutcome>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface MergeOptions {
|
|
52
|
+
readonly number: number
|
|
53
|
+
readonly isDryRun: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface MergeRecord {
|
|
57
|
+
readonly reason: 'merged' | 'dry-run' | MergeRefusal
|
|
58
|
+
readonly isOk: boolean
|
|
59
|
+
readonly number: number
|
|
60
|
+
readonly title?: string
|
|
61
|
+
readonly description?: string
|
|
62
|
+
/** The squash commit, present only on a merge. */
|
|
63
|
+
readonly sha?: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type Composed =
|
|
67
|
+
| { readonly kind: 'composed'; readonly description: string }
|
|
68
|
+
| {
|
|
69
|
+
readonly kind: 'refused'
|
|
70
|
+
readonly reason: 'missing-summary' | 'missing-key-changes'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A section's text with the UI pass line dropped, or undefined when nothing is left. */
|
|
74
|
+
function keptSection(body: string, title: string): string | undefined {
|
|
75
|
+
const text = readSection(body, title)
|
|
76
|
+
?.split('\n')
|
|
77
|
+
.filter((line) => !line.includes(UI_PASS_MARKER))
|
|
78
|
+
.join('\n')
|
|
79
|
+
.trim()
|
|
80
|
+
return text === undefined || text === ''
|
|
81
|
+
? undefined
|
|
82
|
+
: `## ${title}\n\n${text}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The squash description: Summary, Key changes, and Technical context, in that
|
|
87
|
+
* order, under their headings. Testing, the reviewer requests, and the UI pass
|
|
88
|
+
* line describe the review rather than the change, so none reaches the trunk.
|
|
89
|
+
* Technical context is optional, and an empty one leaves no bare heading.
|
|
90
|
+
*/
|
|
91
|
+
export function composeDescription(body: string): Composed {
|
|
92
|
+
const summary = keptSection(body, 'Summary')
|
|
93
|
+
if (summary === undefined)
|
|
94
|
+
return { kind: 'refused', reason: 'missing-summary' }
|
|
95
|
+
const keyChanges = keptSection(body, 'Key changes')
|
|
96
|
+
if (keyChanges === undefined) {
|
|
97
|
+
return { kind: 'refused', reason: 'missing-key-changes' }
|
|
98
|
+
}
|
|
99
|
+
const context = keptSection(body, 'Technical context')
|
|
100
|
+
return {
|
|
101
|
+
kind: 'composed',
|
|
102
|
+
description: [summary, keyChanges, context]
|
|
103
|
+
.filter((block) => block !== undefined)
|
|
104
|
+
.join('\n\n'),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The suffix the web merge adds and the trunk log carries. */
|
|
109
|
+
export function mergeTitle(title: string, number: number): string {
|
|
110
|
+
return `${title} (#${number})`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function refused(
|
|
114
|
+
reason: MergeRefusal,
|
|
115
|
+
number: number,
|
|
116
|
+
message: { title?: string; description?: string } = {},
|
|
117
|
+
): MergeRecord {
|
|
118
|
+
return { reason, isOk: false, number, ...message }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Squash-merges one ready pull request with a message carrying only the
|
|
123
|
+
* change. Every refusal is read before the one write, and the write sends the
|
|
124
|
+
* head it read as `sha`, so GitHub refuses rather than merging a commit pushed
|
|
125
|
+
* after the checks were read. The verb reads the draft flag and not the review
|
|
126
|
+
* comments, since a ready pull request already passed the lift's gates.
|
|
127
|
+
*/
|
|
128
|
+
export async function merge(
|
|
129
|
+
api: MergeApi,
|
|
130
|
+
opts: MergeOptions,
|
|
131
|
+
): Promise<MergeRecord> {
|
|
132
|
+
const { number } = opts
|
|
133
|
+
|
|
134
|
+
const pull = await api.readPull()
|
|
135
|
+
if (pull === undefined) return refused('gh-failed', number)
|
|
136
|
+
if (!pull.isOpen) return refused('pull-closed', number)
|
|
137
|
+
if (pull.isDraft) return refused('pull-draft', number)
|
|
138
|
+
|
|
139
|
+
const title = mergeTitle(pull.title, number)
|
|
140
|
+
const composed = composeDescription(pull.body)
|
|
141
|
+
if (composed.kind === 'refused') {
|
|
142
|
+
return refused(composed.reason, number, { title })
|
|
143
|
+
}
|
|
144
|
+
const message = { title, description: composed.description }
|
|
145
|
+
|
|
146
|
+
const checks = await api.readChecks(pull.head)
|
|
147
|
+
if (checks === undefined) return refused('gh-failed', number, message)
|
|
148
|
+
if (checks.state === 'failing') return refused('ci-failing', number, message)
|
|
149
|
+
if (checks.state !== 'passing') {
|
|
150
|
+
return refused(
|
|
151
|
+
checks.isConflicted ? 'ci-conflicted' : 'ci-pending',
|
|
152
|
+
number,
|
|
153
|
+
message,
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (opts.isDryRun)
|
|
158
|
+
return { reason: 'dry-run', isOk: true, number, ...message }
|
|
159
|
+
|
|
160
|
+
const outcome = await api.putMerge({ ...message, sha: pull.head })
|
|
161
|
+
if (outcome.kind === 'merged') {
|
|
162
|
+
return {
|
|
163
|
+
reason: 'merged',
|
|
164
|
+
isOk: true,
|
|
165
|
+
number,
|
|
166
|
+
...message,
|
|
167
|
+
sha: outcome.sha,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return refused(
|
|
171
|
+
outcome.kind === 'failed' ? 'gh-failed' : outcome.kind,
|
|
172
|
+
number,
|
|
173
|
+
message,
|
|
174
|
+
)
|
|
175
|
+
}
|
package/src/pr/rest.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { ReviewRow } from '@/pr/review-scope'
|
|
|
13
13
|
/** One row of `repos/{owner}/{repo}/pulls/<n>` or of the pulls listing. */
|
|
14
14
|
export interface RestPull {
|
|
15
15
|
readonly number?: number
|
|
16
|
+
readonly title?: string
|
|
16
17
|
readonly body?: string | null
|
|
17
18
|
readonly head?: { readonly ref?: string; readonly sha?: string }
|
|
18
19
|
readonly base?: { readonly ref?: string }
|
|
@@ -24,6 +25,8 @@ export interface RestPull {
|
|
|
24
25
|
|
|
25
26
|
/** One row of `repos/{owner}/{repo}/pulls/<n>/reviews`. */
|
|
26
27
|
export interface RestReview {
|
|
28
|
+
/** The id the update call `pulls/<n>/reviews/<id>` names. */
|
|
29
|
+
readonly id?: number
|
|
27
30
|
readonly body?: string | null
|
|
28
31
|
readonly commit_id?: string | null
|
|
29
32
|
readonly submitted_at?: string | null
|
package/src/pr/review-scope.ts
CHANGED
|
@@ -136,11 +136,21 @@ export const UI_HEADINGS = ['## UI review', '## UI review closed'] as const
|
|
|
136
136
|
|
|
137
137
|
export type UiHeading = (typeof UI_HEADINGS)[number]
|
|
138
138
|
|
|
139
|
+
/** The four totals on a verdict's count line. */
|
|
140
|
+
export interface UiCounts {
|
|
141
|
+
readonly failed: number
|
|
142
|
+
readonly eyes: number
|
|
143
|
+
readonly passed: number
|
|
144
|
+
readonly notDriven: number
|
|
145
|
+
}
|
|
146
|
+
|
|
139
147
|
export interface UiScope {
|
|
140
148
|
readonly heading?: UiHeading
|
|
141
149
|
readonly state: ReviewState
|
|
142
150
|
/** The head the verdict covered, a full or abbreviated sha. */
|
|
143
151
|
readonly commit?: string
|
|
152
|
+
/** The counts the verdict wrote, absent when its count line does not parse. */
|
|
153
|
+
readonly counts?: UiCounts
|
|
144
154
|
/** `marker` is the verdict's own record, `fallback` is GitHub's stamp. */
|
|
145
155
|
readonly source: ScopeSource
|
|
146
156
|
}
|
|
@@ -152,6 +162,32 @@ export interface UiScope {
|
|
|
152
162
|
*/
|
|
153
163
|
const UI_MARKER = /^<!--\s*review-ui:\s*head=([0-9a-f]{7,40})\s*-->$/
|
|
154
164
|
|
|
165
|
+
/**
|
|
166
|
+
* The count line `review-ui`'s `post.md` fixes as the verdict's first line
|
|
167
|
+
* under the heading. It is model-written prose, so a body that drifts from the
|
|
168
|
+
* form reads as no counts and a caller treats that as unchecked.
|
|
169
|
+
*/
|
|
170
|
+
const UI_COUNTS =
|
|
171
|
+
/^(\d+) failed · (\d+) need your eyes · (\d+) passed · (\d+) not driven$/
|
|
172
|
+
|
|
173
|
+
export function readUiCounts(body: string): UiCounts | undefined {
|
|
174
|
+
for (const raw of body.split('\n').slice(1, 6)) {
|
|
175
|
+
const match = UI_COUNTS.exec(raw.trim())
|
|
176
|
+
if (match === null) continue
|
|
177
|
+
const [, failed, eyes, passed, notDriven] = match.map(Number)
|
|
178
|
+
if (
|
|
179
|
+
failed === undefined ||
|
|
180
|
+
eyes === undefined ||
|
|
181
|
+
passed === undefined ||
|
|
182
|
+
notDriven === undefined
|
|
183
|
+
) {
|
|
184
|
+
return undefined
|
|
185
|
+
}
|
|
186
|
+
return { failed, eyes, passed, notDriven }
|
|
187
|
+
}
|
|
188
|
+
return undefined
|
|
189
|
+
}
|
|
190
|
+
|
|
155
191
|
function lastLine(body: string): string | undefined {
|
|
156
192
|
const lines = body.split('\n')
|
|
157
193
|
|
|
@@ -185,9 +221,11 @@ export function resolveUiScope(listing: ReviewListing): UiScope {
|
|
|
185
221
|
const line = lastLine(row.body ?? '')
|
|
186
222
|
const match = line === undefined ? null : UI_MARKER.exec(line)
|
|
187
223
|
const marked = match?.[1]
|
|
224
|
+
const counts = readUiCounts(row.body ?? '')
|
|
225
|
+
const counted = counts === undefined ? {} : { counts }
|
|
188
226
|
|
|
189
227
|
if (marked !== undefined) {
|
|
190
|
-
return { heading, state, commit: marked, source: 'marker' }
|
|
228
|
+
return { heading, state, commit: marked, ...counted, source: 'marker' }
|
|
191
229
|
}
|
|
192
230
|
|
|
193
231
|
const oid = row.commit?.oid
|
|
@@ -195,6 +233,7 @@ export function resolveUiScope(listing: ReviewListing): UiScope {
|
|
|
195
233
|
heading,
|
|
196
234
|
state,
|
|
197
235
|
...(oid === undefined || oid === '' ? {} : { commit: oid }),
|
|
236
|
+
...counted,
|
|
198
237
|
source: 'fallback',
|
|
199
238
|
}
|
|
200
239
|
}
|
package/src/pr/tick.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type ChecklistBox,
|
|
2
3
|
CHECKLIST_END,
|
|
3
4
|
CHECKLIST_START,
|
|
4
5
|
parseBoxLine,
|
|
@@ -7,7 +8,7 @@ import {
|
|
|
7
8
|
writeBoxLine,
|
|
8
9
|
} from '@/pr/evidence'
|
|
9
10
|
|
|
10
|
-
const UI_PASS_MARKER = '<!-- pr-ui-pass -->'
|
|
11
|
+
export const UI_PASS_MARKER = '<!-- pr-ui-pass -->'
|
|
11
12
|
const TESTING_HEADING = /^## Testing\s*$/
|
|
12
13
|
const SECTION_HEADING = /^## /
|
|
13
14
|
const REVIEWER_HEADING = /^## For the reviewer\s*$/
|
|
@@ -48,6 +49,25 @@ export function uiPassLine(summary: UiPassSummary): string {
|
|
|
48
49
|
return `- [${box}] UI pass, ${summary.passed} of ${summary.total} boxes passed at ${at}${tail} ${UI_PASS_MARKER}`
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The driven boxes ticked at the head. A tick counts as this pass's only when
|
|
54
|
+
* stamped at the head, since an unstamped one is the author's claim and an
|
|
55
|
+
* older stamp names another head. Taste boxes are left out, as the body line
|
|
56
|
+
* leaves them out.
|
|
57
|
+
*/
|
|
58
|
+
export function countTicksAtHead(
|
|
59
|
+
boxes: readonly ChecklistBox[],
|
|
60
|
+
head: string,
|
|
61
|
+
): number {
|
|
62
|
+
return boxes.filter(
|
|
63
|
+
(box) =>
|
|
64
|
+
!box.isTaste &&
|
|
65
|
+
box.isTicked &&
|
|
66
|
+
box.stamp !== undefined &&
|
|
67
|
+
head.startsWith(box.stamp),
|
|
68
|
+
).length
|
|
69
|
+
}
|
|
70
|
+
|
|
51
71
|
/** The short sha the marked line names, absent when the body carries no such line. */
|
|
52
72
|
export function readUiPassHead(body: string): string | undefined {
|
|
53
73
|
const line = body.split('\n').find((row) => row.includes(UI_PASS_MARKER))
|
|
@@ -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
|
}
|