@erclx/canon 5.48.0 → 5.50.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/canvas-board/REQUIREMENT.md +4 -3
- package/claude/skills/canvas-board/SKILL.md +8 -9
- package/claude/skills/context-fold/references/context.md +4 -3
- package/claude/skills/design-record/REQUIREMENT.md +33 -0
- package/claude/skills/design-record/SKILL.md +84 -0
- package/claude/skills/design-record/references/folder.md +69 -0
- package/claude/skills/design-taste/SKILL.md +1 -1
- package/claude/skills/markdown-craft/REQUIREMENT.md +1 -0
- package/claude/skills/markdown-craft/SKILL.md +6 -1
- package/claude/skills/markdown-craft/references/markdown.md +3 -3
- package/claude/skills/markdown-craft/references/tables.md +30 -0
- package/claude/skills/pick-by-eye/SKILL.md +10 -8
- package/claude/skills/plan-feature/SKILL.md +31 -16
- 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/session-worktree/SKILL.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/index.md +1 -1
- package/docs/agents/markdown-audit.md +5 -2
- package/docs/agents/records.md +1 -1
- package/docs/target/migrations.md +7 -0
- package/docs/target/projects.md +1 -1
- package/docs/workflow/parallel-features.md +2 -2
- package/docs/workflow/skill-map.md +2 -1
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/governance/ledger/ui/design-reference.md +1 -0
- package/governance/rules/ui/design-reference.md +1 -0
- package/package.json +1 -1
- package/src/claude/cases/misc.ts +5 -0
- package/src/commands/claude.ts +26 -1
- package/src/commands/gate.ts +57 -16
- package/src/commands/markdown.ts +98 -19
- package/src/commands/pr.ts +411 -23
- package/src/commands/records.ts +45 -8
- package/src/commands/tasks.ts +12 -0
- package/src/context/audit.ts +6 -5
- package/src/design/budget.ts +1 -1
- package/src/design/components.ts +1 -1
- package/src/design/document.ts +1 -1
- package/src/design/regen.ts +1 -1
- package/src/design/render.ts +1 -1
- package/src/gate/hand-suite.ts +52 -0
- package/src/gate/measures.ts +9 -2
- package/src/gate/sequencer.ts +17 -8
- package/src/hooks/post-merge.ts +11 -0
- package/src/markdown/gated.ts +5 -7
- package/src/markdown/scope.ts +60 -10
- package/src/markdown/structure.ts +1 -1
- package/src/markdown/tables.ts +150 -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 +54 -14
- package/tooling/base/configs/.github/workflows/pr-local-preview.yml +1 -1
- package/tooling/claude/reference.md +1 -0
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +11 -1
- package/tooling/claude/seeds/CLAUDE.md +9 -0
- /package/claude/skills/{canvas-board → design-record}/references/design.md +0 -0
package/src/tasks/archive.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
+
import { execa } from 'execa'
|
|
2
3
|
import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
3
4
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
5
|
+
import { gitEnv } from '@/git/env'
|
|
4
6
|
import { regenOne } from '@/indexes/regen'
|
|
7
|
+
import { rerenderRow } from '@/tasks/board'
|
|
8
|
+
import { planBranch } from '@/tasks/branch'
|
|
5
9
|
import { isUnder } from '@/tasks/paths'
|
|
6
10
|
import { recordDir, recordDirs } from '@/roots/record'
|
|
7
11
|
|
|
@@ -128,8 +132,25 @@ export interface ArchiveRefused {
|
|
|
128
132
|
readonly reason: ArchiveRefusal
|
|
129
133
|
readonly message: string
|
|
130
134
|
readonly detail: readonly string[]
|
|
135
|
+
/**
|
|
136
|
+
* The slice plan a merged pull request settled while its task stayed open,
|
|
137
|
+
* moved into the archive. Set only on `earlier-slice` and `open-outcomes`.
|
|
138
|
+
*/
|
|
139
|
+
readonly slice?: PlanMove
|
|
131
140
|
}
|
|
132
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Answers the head branch a pull request merged from, or undefined when it
|
|
144
|
+
* cannot be read. Tests supply one rather than reaching for `gh`.
|
|
145
|
+
*/
|
|
146
|
+
export type HeadReader = (pullRequest: number) => Promise<string | undefined>
|
|
147
|
+
|
|
148
|
+
export interface ArchiveOptions {
|
|
149
|
+
readonly head?: HeadReader
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const GH_TIMEOUT_MS = 15_000
|
|
153
|
+
|
|
133
154
|
export type ArchiveOutcome = ArchiveSuccess | ArchiveRefused
|
|
134
155
|
|
|
135
156
|
export interface DeclineSuccess {
|
|
@@ -925,6 +946,7 @@ async function resolveStem(
|
|
|
925
946
|
export async function archiveTask(
|
|
926
947
|
root: string,
|
|
927
948
|
selector: TaskSelector,
|
|
949
|
+
options: ArchiveOptions = {},
|
|
928
950
|
): Promise<ArchiveOutcome> {
|
|
929
951
|
const dir = tasksDir(root)
|
|
930
952
|
|
|
@@ -944,9 +966,13 @@ export async function archiveTask(
|
|
|
944
966
|
// while the last slice is still open. Only the last number closes the task.
|
|
945
967
|
const last = readPullRequest(text).at(-1)
|
|
946
968
|
if (selector.kind === 'pull-request' && last !== selector.number) {
|
|
947
|
-
return
|
|
948
|
-
|
|
949
|
-
|
|
969
|
+
return withMergedSlice(
|
|
970
|
+
refuse(
|
|
971
|
+
'earlier-slice',
|
|
972
|
+
`${stem} lists #${last} after #${selector.number}, so the task closes when #${last} merges.`,
|
|
973
|
+
),
|
|
974
|
+
{ dir, root, stem, text, number: selector.number },
|
|
975
|
+
options.head ?? ghHeadReader(root),
|
|
950
976
|
)
|
|
951
977
|
}
|
|
952
978
|
|
|
@@ -966,11 +992,18 @@ export async function archiveTask(
|
|
|
966
992
|
const { open, closed, cut, operator } = readOutcomes(text)
|
|
967
993
|
|
|
968
994
|
if (open.length > 0) {
|
|
969
|
-
|
|
995
|
+
const refused = refuse(
|
|
970
996
|
'open-outcomes',
|
|
971
997
|
`${stem} has ${open.length} open outcome(s). Close them, or cut one with \`- ~~<outcome>~~ <why>\`, then archive.`,
|
|
972
998
|
open,
|
|
973
999
|
)
|
|
1000
|
+
return selector.kind === 'pull-request'
|
|
1001
|
+
? withMergedSlice(
|
|
1002
|
+
refused,
|
|
1003
|
+
{ dir, root, stem, text, number: selector.number },
|
|
1004
|
+
options.head ?? ghHeadReader(root),
|
|
1005
|
+
)
|
|
1006
|
+
: refused
|
|
974
1007
|
}
|
|
975
1008
|
|
|
976
1009
|
if (closed.length === 0 && cut.length === 0 && operator.length === 0) {
|
|
@@ -1038,6 +1071,106 @@ export async function archiveTask(
|
|
|
1038
1071
|
}
|
|
1039
1072
|
}
|
|
1040
1073
|
|
|
1074
|
+
interface MergedSlice {
|
|
1075
|
+
readonly dir: string
|
|
1076
|
+
readonly root: string
|
|
1077
|
+
readonly stem: string
|
|
1078
|
+
readonly text: string
|
|
1079
|
+
readonly number: number
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Moves the slice plan a merged pull request built into the archive while its
|
|
1084
|
+
* task stays open, and retargets the task's `Plan:` line at it, so the board
|
|
1085
|
+
* row drops the slice through `livePlans`. The refusal still answers, since
|
|
1086
|
+
* the task did not close.
|
|
1087
|
+
*
|
|
1088
|
+
* Only a task citing more than one live plan has a slice to drop. The plan is
|
|
1089
|
+
* the one whose derived branch is the pull request's head, so a plan a worker
|
|
1090
|
+
* renamed off its branch matches nothing and moves nothing. A plan another
|
|
1091
|
+
* live task cites stays, for the reason `plansToArchive` gives, and an
|
|
1092
|
+
* unreadable head leaves everything as it was.
|
|
1093
|
+
*/
|
|
1094
|
+
async function withMergedSlice(
|
|
1095
|
+
refused: ArchiveRefused,
|
|
1096
|
+
merged: MergedSlice,
|
|
1097
|
+
head: HeadReader,
|
|
1098
|
+
): Promise<ArchiveRefused> {
|
|
1099
|
+
const { dir, root, stem, text } = merged
|
|
1100
|
+
const live = readPlanTargets(text).flatMap((target) => {
|
|
1101
|
+
const path = resolveLivePlan(target, dir, root)
|
|
1102
|
+
return path && existsSync(path) ? [{ target, path }] : []
|
|
1103
|
+
})
|
|
1104
|
+
if (new Set(live.map((entry) => entry.path)).size < 2) return refused
|
|
1105
|
+
|
|
1106
|
+
const branch = await head(merged.number)
|
|
1107
|
+
if (branch === undefined) return refused
|
|
1108
|
+
|
|
1109
|
+
const slice = live.find((entry) => {
|
|
1110
|
+
const derived = planBranch(root, entry.path)
|
|
1111
|
+
return derived.ok && derived.branch === branch
|
|
1112
|
+
})
|
|
1113
|
+
if (!slice) return refused
|
|
1114
|
+
|
|
1115
|
+
const shared = await otherTasksCitingPlan(dir, root, slice.path, stem)
|
|
1116
|
+
if (shared.length > 0) return refused
|
|
1117
|
+
|
|
1118
|
+
const move = {
|
|
1119
|
+
from: slice.path,
|
|
1120
|
+
to: join(recordDir(root, PLANS, ARCHIVE), basename(slice.path)),
|
|
1121
|
+
}
|
|
1122
|
+
if (existsSync(move.to)) return refused
|
|
1123
|
+
|
|
1124
|
+
await movePlans([move])
|
|
1125
|
+
const targets = live.filter((entry) => entry.path === slice.path)
|
|
1126
|
+
await writeFile(
|
|
1127
|
+
join(dir, `${stem}.md`),
|
|
1128
|
+
retargetPlanLine(
|
|
1129
|
+
text,
|
|
1130
|
+
new Map(targets.map((entry) => [entry.target, linkTo(dir, move.to)])),
|
|
1131
|
+
),
|
|
1132
|
+
)
|
|
1133
|
+
// The closing path clears its row, and this one keeps it, so the row is
|
|
1134
|
+
// rendered again to drop the slice from its Plan and Touches cells. A row
|
|
1135
|
+
// the board cannot write, being absent or busy, stays for `validate` to report.
|
|
1136
|
+
await rerenderRow(root, stem)
|
|
1137
|
+
|
|
1138
|
+
return { ...refused, slice: move }
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Reads a pull request's head branch through `gh`, in the repository `root`
|
|
1143
|
+
* resolves to. No `gh` on the path, or a failed read, answers undefined, so a
|
|
1144
|
+
* machine without it keeps a slice plan live rather than guessing.
|
|
1145
|
+
*/
|
|
1146
|
+
export function ghHeadReader(root: string): HeadReader {
|
|
1147
|
+
return async (pullRequest) => {
|
|
1148
|
+
if (Bun.which('gh') === null) return undefined
|
|
1149
|
+
|
|
1150
|
+
try {
|
|
1151
|
+
// `gh` resolves its repository through git, so the hook's resolution
|
|
1152
|
+
// variables would point it elsewhere. `reclaim.ts` strips them the same way.
|
|
1153
|
+
const result = await execa(
|
|
1154
|
+
'gh',
|
|
1155
|
+
[
|
|
1156
|
+
'pr',
|
|
1157
|
+
'view',
|
|
1158
|
+
String(pullRequest),
|
|
1159
|
+
'--json',
|
|
1160
|
+
'headRefName',
|
|
1161
|
+
'--jq',
|
|
1162
|
+
'.headRefName',
|
|
1163
|
+
],
|
|
1164
|
+
{ cwd: root, timeout: GH_TIMEOUT_MS, env: gitEnv(), extendEnv: false },
|
|
1165
|
+
)
|
|
1166
|
+
const branch = result.stdout.trim()
|
|
1167
|
+
return branch.length > 0 ? branch : undefined
|
|
1168
|
+
} catch {
|
|
1169
|
+
return undefined
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1041
1174
|
interface PlansToArchive {
|
|
1042
1175
|
readonly moves: readonly PlanMove[]
|
|
1043
1176
|
/** Each moved target as the line wrote it, mapped to its link from `taskDir`. */
|
package/src/tasks/board.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
BOARD_GROUPS,
|
|
14
14
|
type BoardGroup,
|
|
15
15
|
backlogPath,
|
|
16
|
+
declaredRows,
|
|
16
17
|
type FindingKind,
|
|
17
18
|
groupRuleFindings,
|
|
18
19
|
orderingPath,
|
|
@@ -30,8 +31,15 @@ export const GROUP_COLUMNS: Readonly<Record<BoardGroup, readonly string[]>> = {
|
|
|
30
31
|
'Needs a plan': ['Task', 'Waiting on'],
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* How many folders a `Touches` cell names. A backticked path never wraps, so
|
|
36
|
+
* three of them pushed a table past an editor preview, and two short folders
|
|
37
|
+
* fit beside the count.
|
|
38
|
+
*/
|
|
39
|
+
const TOUCHES_FOLDERS = 2
|
|
40
|
+
|
|
41
|
+
/** How deep a `Touches` folder reads, as in `src/tasks/` or `claude/skills/`. */
|
|
42
|
+
const FOLDER_DEPTH = 2
|
|
35
43
|
|
|
36
44
|
/**
|
|
37
45
|
* The widest a `Waiting on` cell may render. The live board's cells measured 14
|
|
@@ -205,6 +213,8 @@ export interface DerivedCells {
|
|
|
205
213
|
readonly task: string
|
|
206
214
|
readonly touches: string
|
|
207
215
|
readonly plan: string
|
|
216
|
+
/** The declared paths the Touches cell counts, which the overlap checks read in its place. */
|
|
217
|
+
readonly paths: readonly string[]
|
|
208
218
|
}
|
|
209
219
|
|
|
210
220
|
function titleOf(text: string): string | undefined {
|
|
@@ -227,17 +237,41 @@ function planLabel(path: string): string {
|
|
|
227
237
|
}
|
|
228
238
|
|
|
229
239
|
/**
|
|
230
|
-
* The
|
|
231
|
-
*
|
|
232
|
-
* the plural.
|
|
240
|
+
* The folder a path sits under, `FOLDER_DEPTH` segments deep at most and with a
|
|
241
|
+
* trailing slash, or undefined for a file at the root.
|
|
233
242
|
*/
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
243
|
+
function folderOf(path: string): string | undefined {
|
|
244
|
+
const segments = path.split('/').slice(0, -1).slice(0, FOLDER_DEPTH)
|
|
245
|
+
return segments.length === 0 ? undefined : `${segments.join('/')}/`
|
|
246
|
+
}
|
|
238
247
|
|
|
239
|
-
|
|
240
|
-
|
|
248
|
+
/**
|
|
249
|
+
* A count of the declared paths and the folders holding most of them, at most
|
|
250
|
+
* `TOUCHES_FOLDERS`, with ties going to the folder declared first. The file
|
|
251
|
+
* list lives in the plan the row links, so the cell only says where a row
|
|
252
|
+
* works. Root files count and name no folder.
|
|
253
|
+
*/
|
|
254
|
+
export function touchesCell(paths: readonly string[]): string {
|
|
255
|
+
if (paths.length === 0) return ''
|
|
256
|
+
|
|
257
|
+
const counts = new Map<string, number>()
|
|
258
|
+
for (const path of paths) {
|
|
259
|
+
const folder = folderOf(path)
|
|
260
|
+
if (folder !== undefined) counts.set(folder, (counts.get(folder) ?? 0) + 1)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const folders = [...counts.entries()]
|
|
264
|
+
.sort((left, right) => right[1] - left[1])
|
|
265
|
+
.map(([folder]) => folder)
|
|
266
|
+
const shown = folders
|
|
267
|
+
.slice(0, TOUCHES_FOLDERS)
|
|
268
|
+
.map((folder) => `\`${folder}\``)
|
|
269
|
+
const files = paths.length === 1 ? '1 file' : `${paths.length} files`
|
|
270
|
+
if (shown.length === 0) return files
|
|
271
|
+
|
|
272
|
+
const rest = folders.length - shown.length
|
|
273
|
+
const tail = rest === 0 ? '' : `, and ${rest} more`
|
|
274
|
+
return `${files} under ${shown.join(', ')}${tail}`
|
|
241
275
|
}
|
|
242
276
|
|
|
243
277
|
/**
|
|
@@ -270,6 +304,7 @@ export async function deriveCells(
|
|
|
270
304
|
task: `[${row.label || row.stem}](${row.stem}.md)`,
|
|
271
305
|
touches: '',
|
|
272
306
|
plan: '',
|
|
307
|
+
paths: [],
|
|
273
308
|
}
|
|
274
309
|
}
|
|
275
310
|
|
|
@@ -279,13 +314,15 @@ export async function deriveCells(
|
|
|
279
314
|
plans.map(async (plan) => readDeclarations(await readFile(plan, 'utf8'))),
|
|
280
315
|
)
|
|
281
316
|
const dir = tasksDir(root)
|
|
317
|
+
const paths = [...new Set(declared.flat())]
|
|
282
318
|
|
|
283
319
|
return {
|
|
284
320
|
task: `[${titleOf(text) ?? (row.label || row.stem)}](${row.stem}.md)`,
|
|
285
|
-
touches: touchesCell(
|
|
321
|
+
touches: touchesCell(paths),
|
|
286
322
|
plan: plans
|
|
287
323
|
.map((plan) => `[${planLabel(plan)}](${linkTo(dir, plan)})`)
|
|
288
324
|
.join(', '),
|
|
325
|
+
paths,
|
|
289
326
|
}
|
|
290
327
|
}
|
|
291
328
|
|
|
@@ -594,7 +631,8 @@ async function writeLocked(
|
|
|
594
631
|
|
|
595
632
|
if (edited.ruled) {
|
|
596
633
|
const forbidden = GROUP_RULES[edited.ruled]
|
|
597
|
-
const
|
|
634
|
+
const rows = await declaredRows(readBoard(text).rows, root)
|
|
635
|
+
const broken = (await groupRuleFindings(rows, root)).filter(
|
|
598
636
|
(finding) => finding.subject === stem && forbidden.includes(finding.kind),
|
|
599
637
|
)
|
|
600
638
|
if (broken.length > 0) {
|
|
@@ -793,6 +831,23 @@ export async function promoteOnLink(
|
|
|
793
831
|
: { moved: false, from: found.group, note: outcome.message }
|
|
794
832
|
}
|
|
795
833
|
|
|
834
|
+
/**
|
|
835
|
+
* Rewrites the board with a task's row where it sits, so its derived cells
|
|
836
|
+
* catch up with a plan that moved under it. A merged slice archives its plan
|
|
837
|
+
* from the post-merge hook, where no other board verb runs.
|
|
838
|
+
*/
|
|
839
|
+
export async function rerenderRow(
|
|
840
|
+
root: string,
|
|
841
|
+
stem: string,
|
|
842
|
+
): Promise<BoardOutcome> {
|
|
843
|
+
return writeBoard(root, stem, (board) => {
|
|
844
|
+
const found = findRow(board, stem)
|
|
845
|
+
if (!found) return refuse('no-row', `${stem} has no row on the board.`)
|
|
846
|
+
|
|
847
|
+
return { board, group: found.group, from: found.group, ruled: undefined }
|
|
848
|
+
})
|
|
849
|
+
}
|
|
850
|
+
|
|
796
851
|
/** Repositions a row inside the group it already sits in. */
|
|
797
852
|
export async function moveTask(
|
|
798
853
|
root: string,
|
package/src/tasks/validate.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { readdir, readFile } from 'node:fs/promises'
|
|
3
3
|
import { join, resolve } from 'node:path'
|
|
4
|
+
import { codeSpans, unbrokenRun } from '@/markdown/tables'
|
|
4
5
|
import {
|
|
5
6
|
archiveDir,
|
|
6
7
|
declinedDir,
|
|
@@ -23,6 +24,9 @@ import { gitTrunkReader, type TrunkReader } from '@/tasks/trunk'
|
|
|
23
24
|
const ORDERING_FILE = 'priority.md'
|
|
24
25
|
const BACKLOG_FILE = 'backlog.md'
|
|
25
26
|
|
|
27
|
+
/** The opening `touchesCell` writes, as in `12 files under` or a bare `2 files`. */
|
|
28
|
+
const DERIVED_COUNT = /^\d+ files?(?: under |$)/
|
|
29
|
+
|
|
26
30
|
/**
|
|
27
31
|
* The readiness headings `claude/skills/task-board/references/board.md` fixes. The names are the
|
|
28
32
|
* contract rather than a suggestion, so a board grouping under names of its own
|
|
@@ -152,7 +156,11 @@ export interface BoardRow {
|
|
|
152
156
|
readonly label: string
|
|
153
157
|
readonly stem: string | undefined
|
|
154
158
|
readonly plan: string | undefined
|
|
155
|
-
/**
|
|
159
|
+
/**
|
|
160
|
+
* Absent when the group fixes no `Touches` column, empty when it read none.
|
|
161
|
+
* `readBoard` reads the written cell, and `declaredRows` replaces it with the
|
|
162
|
+
* paths the task's plans declare.
|
|
163
|
+
*/
|
|
156
164
|
readonly touches: readonly string[] | undefined
|
|
157
165
|
/** The `Touches` cell as written, which the stale check compares against the derivation. */
|
|
158
166
|
readonly touchesText: string | undefined
|
|
@@ -235,8 +243,14 @@ function stemOf(target: string): string | undefined {
|
|
|
235
243
|
* An extension opens with a letter, which is what separates `commands.md` from
|
|
236
244
|
* a task version like `v40.2`. Reading the version as a path would collide two
|
|
237
245
|
* rows that merely cite the same task.
|
|
246
|
+
*
|
|
247
|
+
* A cell a board verb derived opens with its file count, and its folders say
|
|
248
|
+
* where the row works rather than claiming every file under them, so it yields
|
|
249
|
+
* nothing. Its reader takes the declared paths from the plans instead.
|
|
238
250
|
*/
|
|
239
251
|
export function readPaths(cell: string): string[] {
|
|
252
|
+
if (DERIVED_COUNT.test(cell.trim())) return []
|
|
253
|
+
|
|
240
254
|
const spans = cell.match(/`[^`]+`/g) ?? []
|
|
241
255
|
const paths = spans
|
|
242
256
|
.map((span) => span.slice(1, -1).trim())
|
|
@@ -1071,28 +1085,30 @@ function checkFolderClaims(
|
|
|
1071
1085
|
/**
|
|
1072
1086
|
* The widest the longest unbroken run in a code span may be before a table
|
|
1073
1087
|
* scrolls in the preview pane. A 57-character span fit and a 78-character one
|
|
1074
|
-
* did not, so the limit sits at the nearest round number above the first.
|
|
1088
|
+
* did not, so the limit sits at the nearest round number above the first. It
|
|
1089
|
+
* is still too high: a 36-character path with no hyphen widened a board column
|
|
1090
|
+
* until the next one ran off the pane, which is why a table cell reads the same
|
|
1091
|
+
* measure at 30. The board keeps 60 until its own report is revisited.
|
|
1075
1092
|
*/
|
|
1076
1093
|
const WIDE_LIMIT = 60
|
|
1077
1094
|
|
|
1078
1095
|
/**
|
|
1079
|
-
* Reports a code span a table cell cannot wrap
|
|
1080
|
-
*
|
|
1081
|
-
*
|
|
1082
|
-
*
|
|
1083
|
-
*
|
|
1084
|
-
* and bare words at hyphens,
|
|
1096
|
+
* Reports a code span a table cell cannot wrap, measured by `unbrokenRun`,
|
|
1097
|
+
* which `canon markdown audit` reads for a table too. A rendered preview breaks
|
|
1098
|
+
* a span at whitespace and after a hyphen or a slash, so a long run either
|
|
1099
|
+
* widens its column or splits mid-token. Every group is scanned, since the
|
|
1100
|
+
* overflow this exists for happened under `## Up next`, where the folder-claim
|
|
1101
|
+
* scan would not have looked. Prose breaks at spaces and bare words at hyphens,
|
|
1102
|
+
* so only a span is read.
|
|
1085
1103
|
*/
|
|
1086
1104
|
function checkWideTokens(rows: readonly BoardRow[]): WideToken[] {
|
|
1087
1105
|
const wide: WideToken[] = []
|
|
1088
1106
|
|
|
1089
1107
|
for (const row of rows) {
|
|
1090
1108
|
for (const cell of row.cells) {
|
|
1091
|
-
const
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
const text = span.slice(1, -1)
|
|
1095
|
-
const run = Math.max(...text.split(/\s+/).map((part) => part.length))
|
|
1109
|
+
for (const text of codeSpans(cell)) {
|
|
1110
|
+
const span = `\`${text}\``
|
|
1111
|
+
const run = unbrokenRun(text)
|
|
1096
1112
|
|
|
1097
1113
|
if (/\{[^}]*,[^}]*\}/.test(text)) {
|
|
1098
1114
|
wide.push({
|
|
@@ -1516,6 +1532,29 @@ async function checkStaleTouches(
|
|
|
1516
1532
|
return found.filter((finding): finding is Finding => finding !== undefined)
|
|
1517
1533
|
}
|
|
1518
1534
|
|
|
1535
|
+
/**
|
|
1536
|
+
* Gives each row the paths its task's live plans declare in place of the ones
|
|
1537
|
+
* read off its `Touches` cell, which states a count and folders rather than
|
|
1538
|
+
* files. A row whose task is gone, or whose plans declare nothing, keeps what
|
|
1539
|
+
* its cell named, so a hand-written cell still feeds the overlap checks.
|
|
1540
|
+
*/
|
|
1541
|
+
export async function declaredRows(
|
|
1542
|
+
rows: readonly BoardRow[],
|
|
1543
|
+
root: string,
|
|
1544
|
+
): Promise<BoardRow[]> {
|
|
1545
|
+
return Promise.all(
|
|
1546
|
+
rows.map(async (row) => {
|
|
1547
|
+
if (row.touches === undefined || !row.stem) return row
|
|
1548
|
+
|
|
1549
|
+
const { paths } = await deriveCells(root, {
|
|
1550
|
+
stem: row.stem,
|
|
1551
|
+
label: row.label,
|
|
1552
|
+
})
|
|
1553
|
+
return paths.length === 0 ? row : { ...row, touches: paths }
|
|
1554
|
+
}),
|
|
1555
|
+
)
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1519
1558
|
/**
|
|
1520
1559
|
* The claims a group name makes about a row, being a stated plan and file set
|
|
1521
1560
|
* under `## Run now`, a live plan under `## Up next`, and none under
|
|
@@ -1566,10 +1605,11 @@ export async function validateBoard(
|
|
|
1566
1605
|
}
|
|
1567
1606
|
|
|
1568
1607
|
const {
|
|
1569
|
-
rows,
|
|
1608
|
+
rows: written,
|
|
1570
1609
|
groups,
|
|
1571
1610
|
findings: shapeFindings,
|
|
1572
1611
|
} = readBoard(await readFile(ordering, 'utf8'))
|
|
1612
|
+
const rows = await declaredRows(written, root)
|
|
1573
1613
|
|
|
1574
1614
|
if (groups.length === 0) {
|
|
1575
1615
|
return refuse(
|
|
@@ -24,7 +24,7 @@ jobs:
|
|
|
24
24
|
with:
|
|
25
25
|
bun-version: latest
|
|
26
26
|
|
|
27
|
-
- name: Drop the preview line
|
|
27
|
+
- name: Drop the evidence preview line and mask UI review addresses
|
|
28
28
|
if: github.event.pull_request.head.repo.full_name == github.repository
|
|
29
29
|
env:
|
|
30
30
|
GH_TOKEN: ${{ github.token }}
|
|
@@ -57,6 +57,7 @@ A project installed before the board became a folder still holds `.claude/TASKS.
|
|
|
57
57
|
|
|
58
58
|
- Fill in the seed's placeholder project name, description, and key paths after init
|
|
59
59
|
- Keep its sections `Context`, `Commands`, and `Key paths`, since skills detect, extend, or skip by heading
|
|
60
|
+
- Group `Key paths` under H3s, each opening on a lead sentence, before it runs past 5 bullets. `canon markdown audit` and `canon claude sync` both report a longer run.
|
|
60
61
|
- Keep the `Context` section to one line pointing at the state docs, since `plan-feature` does the parallel read when invoked
|
|
61
62
|
|
|
62
63
|
## Seed docs
|
|
@@ -81,6 +81,11 @@ colons=$(printf '%s' "$record" |
|
|
|
81
81
|
density=$(printf '%s' "$record" |
|
|
82
82
|
jq -r '.entries[]?.density[]? | ":\(.line) \(.kind) " + (if .limit == null then "opens with neither a paragraph nor a table" else "\(.value) past \(.limit)" end) + (if .gating then " gating" else "" end)' 2>/dev/null)
|
|
83
83
|
|
|
84
|
+
# Table findings report and never gate, so each kind is handed back with the
|
|
85
|
+
# fix its shape takes rather than the seam a density finding splits at.
|
|
86
|
+
tables=$(printf '%s' "$record" |
|
|
87
|
+
jq -r '.entries[]?.tables[]? | ":\(.line) \(.kind) " + (if .limit == null then "a list follows the table with no lead sentence" else "\(.value) past \(.limit)" end)' 2>/dev/null)
|
|
88
|
+
|
|
84
89
|
# A page marked `kind: reference` carries reference shape findings, handed back
|
|
85
90
|
# whole the same way, since a gated one fails the push like a density finding.
|
|
86
91
|
shape=$(printf '%s' "$record" |
|
|
@@ -92,7 +97,7 @@ shape=$(printf '%s' "$record" |
|
|
|
92
97
|
empty=$(printf '%s' "$record" |
|
|
93
98
|
jq -r '[.bans.emptySets[]?] | join(", ")' 2>/dev/null)
|
|
94
99
|
|
|
95
|
-
[ -z "$hits" ] && [ -z "$headings" ] && [ -z "$colons" ] && [ -z "$density" ] && [ -z "$shape" ] && [ -z "$unread" ] && [ -z "$empty" ] && exit 0
|
|
100
|
+
[ -z "$hits" ] && [ -z "$headings" ] && [ -z "$colons" ] && [ -z "$density" ] && [ -z "$tables" ] && [ -z "$shape" ] && [ -z "$unread" ] && [ -z "$empty" ] && exit 0
|
|
96
101
|
|
|
97
102
|
nl=$'\n'
|
|
98
103
|
msg=""
|
|
@@ -125,6 +130,11 @@ if [ -n "$density" ]; then
|
|
|
125
130
|
msg="${msg:+$msg$nl}$found"
|
|
126
131
|
fi
|
|
127
132
|
|
|
133
|
+
if [ -n "$tables" ]; then
|
|
134
|
+
found=$(printf 'Standards-audit: %s breaks the markdown table limits, being a 120-character cell, 8 body rows, a list directly after a table, and a code span holding a run past 30 characters. These report and never fail a push. Move a long cell into a bullet under the table'"'"'s lead, split a long table into groups each under a lead sentence, give a trailing list its own lead sentence, and move a wide token out of the table.\n%s' "$file" "$tables")
|
|
135
|
+
msg="${msg:+$msg$nl}$found"
|
|
136
|
+
fi
|
|
137
|
+
|
|
128
138
|
if [ -n "$shape" ]; then
|
|
129
139
|
found=$(printf 'Standards-audit: %s is marked kind: reference and breaks its shape, being an opening, then Output, Refusals, and See also, each at most once and in that order, no H3, and no flag table. Point at the command'"'"'s own --help for flags. A finding marked gating fails the push.\n%s' "$file" "$shape")
|
|
130
140
|
msg="${msg:+$msg$nl}$found"
|
|
@@ -15,5 +15,14 @@
|
|
|
15
15
|
|
|
16
16
|
## Key paths
|
|
17
17
|
|
|
18
|
+
### Code
|
|
19
|
+
|
|
20
|
+
[One sentence on what the code paths below hold]
|
|
21
|
+
|
|
18
22
|
- `src/`: [description]
|
|
23
|
+
|
|
24
|
+
### Content
|
|
25
|
+
|
|
26
|
+
[One sentence on what the content paths below hold]
|
|
27
|
+
|
|
19
28
|
- `canon/DESIGN.md`: visual intent, pointing at the stylesheet that holds the values
|
|
File without changes
|