@erclx/canon 5.8.0 → 5.10.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.
Files changed (97) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/REQUIREMENT.md +51 -0
  3. package/claude/skills/api-design/SKILL.md +102 -0
  4. package/claude/skills/api-design/references/adopted.md +40 -0
  5. package/claude/skills/codebase-layout/REQUIREMENT.md +1 -1
  6. package/claude/skills/codebase-layout/SKILL.md +2 -1
  7. package/claude/skills/git-followup/SKILL.md +1 -1
  8. package/claude/skills/git-pr/references/evidence.md +1 -1
  9. package/claude/skills/review-craft/REQUIREMENT.md +8 -2
  10. package/claude/skills/review-craft/SKILL.md +63 -7
  11. package/claude/skills/review-craft/references/adopted.md +49 -0
  12. package/claude/skills/review-ui/REQUIREMENT.md +3 -0
  13. package/claude/skills/review-ui/SKILL.md +15 -1
  14. package/claude/skills/review-ui/references/driving.md +3 -3
  15. package/claude/skills/review-ui/references/post.md +16 -3
  16. package/claude/skills/systematic-debugging/REQUIREMENT.md +14 -0
  17. package/claude/skills/systematic-debugging/SKILL.md +45 -17
  18. package/claude/skills/systematic-debugging/references/adopted.md +44 -0
  19. package/docs/agents/capture.md +2 -2
  20. package/docs/agents/commands.md +3 -1
  21. package/docs/agents/hooks.md +56 -0
  22. package/docs/agents/index.md +1 -0
  23. package/docs/agents/install-and-sync.md +1 -1
  24. package/docs/agents/markdown-audit.md +1 -1
  25. package/docs/agents/pr-evidence.md +33 -17
  26. package/docs/agents/records-push.md +1 -1
  27. package/docs/agents/records-validate.md +1 -1
  28. package/docs/agents/scripting.md +6 -3
  29. package/docs/agents/tasks-archive.md +4 -2
  30. package/docs/agents/tasks-plans.md +8 -5
  31. package/docs/agents/tasks.md +1 -1
  32. package/docs/agents/worktrees.md +2 -2
  33. package/docs/target/scenarios.md +2 -2
  34. package/docs/workflow/skill-map.md +1 -0
  35. package/package.json +1 -1
  36. package/scripts/core/regen-agent-fixture.sh +3 -3
  37. package/scripts/core/regen-hero.ts +119 -0
  38. package/scripts/core/regen-web-favicon.ts +1 -1
  39. package/src/capture/frame-values.ts +359 -0
  40. package/src/claude/cases/misc.ts +5 -0
  41. package/src/claude/skills-audit.ts +3 -0
  42. package/src/cli.ts +2 -0
  43. package/src/commands/claude.ts +1 -1
  44. package/src/commands/docs.ts +51 -11
  45. package/src/commands/hooks.ts +67 -0
  46. package/src/commands/pr.ts +146 -76
  47. package/src/commands/standards.ts +33 -2
  48. package/src/commands/tasks.ts +57 -2
  49. package/src/commands/tooling.ts +5 -5
  50. package/src/commands/upgrade.ts +3 -2
  51. package/src/commands/worktrees.ts +4 -3
  52. package/src/counts/catalogs.ts +3 -3
  53. package/src/design/tokens.ts +1 -1
  54. package/src/docs/list.ts +103 -0
  55. package/src/docs/read.ts +63 -19
  56. package/src/gate/stages.ts +1 -1
  57. package/src/gov/stacks.ts +1 -1
  58. package/src/help.ts +2 -1
  59. package/src/hooks/post-merge.ts +331 -0
  60. package/src/pr/evidence.ts +21 -0
  61. package/src/records/validate.ts +1 -38
  62. package/src/standards/list.ts +33 -0
  63. package/src/standards/read.ts +13 -0
  64. package/src/standards/scope.ts +56 -0
  65. package/src/sync/check.ts +38 -4
  66. package/src/tasks/archive.ts +195 -3
  67. package/src/tasks/record.ts +25 -2
  68. package/src/tooling/inject.ts +4 -3
  69. package/src/tooling/manifest.ts +62 -8
  70. package/src/tooling/scan.ts +4 -3
  71. package/src/tooling/stamp.ts +11 -2
  72. package/src/tooling/subfolder.ts +7 -3
  73. package/standards/tasks.md +2 -2
  74. package/tooling/astro/reference.md +1 -1
  75. package/tooling/base/configs/scripts/verify.json +3 -0
  76. package/tooling/base/configs/scripts/verify.sh +1 -110
  77. package/tooling/base/configs/scripts/verify.ts +378 -0
  78. package/tooling/base/manifest.toml +1 -0
  79. package/tooling/base/reference.md +5 -3
  80. package/tooling/go/configs/scripts/verify.json +10 -0
  81. package/tooling/go/reference.md +5 -4
  82. package/tooling/php/configs/scripts/verify.json +7 -0
  83. package/tooling/php/reference.md +5 -4
  84. package/tooling/python/configs/scripts/verify.json +4 -0
  85. package/tooling/python/reference.md +3 -3
  86. package/tooling/web/configs/scripts/verify.json +3 -0
  87. package/tooling/web/reference.md +3 -3
  88. package/scripts/core/regen-hero.sh +0 -419
  89. package/scripts/docs/list.sh +0 -184
  90. package/scripts/lib/frontmatter.sh +0 -30
  91. package/scripts/standards/list.sh +0 -115
  92. package/scripts/tooling/create.sh +0 -109
  93. package/src/commands/pass-through.ts +0 -39
  94. package/tooling/go/configs/scripts/verify.sh +0 -142
  95. package/tooling/php/configs/scripts/verify.sh +0 -147
  96. package/tooling/python/configs/scripts/verify.sh +0 -141
  97. package/tooling/web/configs/scripts/verify.sh +0 -153
@@ -0,0 +1,33 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { parseFrontmatter, readField } from '@/indexes/frontmatter'
3
+ import { resolveAllStandards } from '@/standards/read'
4
+ import { appliesTo } from '@/standards/scope'
5
+
6
+ export interface StandardEntry {
7
+ readonly name: string
8
+ readonly description: string
9
+ readonly appliesTo: string[]
10
+ /** The whole file, frontmatter included. */
11
+ readonly content: string
12
+ /** The root-labeled path `canon standards <name>` would read. */
13
+ readonly source: string
14
+ }
15
+
16
+ /**
17
+ * Lists every standard a resolve reaches, the first root winning, so a project
18
+ * standard shows beside the toolkit's and a local override of a package name
19
+ * shows once, as the local copy.
20
+ */
21
+ export function listStandardEntries(root: string): StandardEntry[] {
22
+ return resolveAllStandards(root).map(({ name, path, source }) => {
23
+ const content = readFileSync(path, 'utf8')
24
+
25
+ return {
26
+ name,
27
+ description: readField(parseFrontmatter(content), 'description') ?? '',
28
+ appliesTo: appliesTo(content),
29
+ content,
30
+ source,
31
+ }
32
+ })
33
+ }
@@ -95,6 +95,19 @@ export function listStandards(root: string): string[] {
95
95
  return [...names].sort()
96
96
  }
97
97
 
98
+ /**
99
+ * Pairs each listed name with the copy a resolve would read, so a catalog shows
100
+ * what `canon standards <name>` prints rather than every file a root holds.
101
+ */
102
+ export function resolveAllStandards(
103
+ root: string,
104
+ ): Array<ResolvedStandard & { readonly name: string }> {
105
+ return listStandards(root).flatMap((name) => {
106
+ const resolved = resolveStandard(root, name)
107
+ return resolved ? [{ name, ...resolved }] : []
108
+ })
109
+ }
110
+
98
111
  export function readStandard(standard: ResolvedStandard): string {
99
112
  return stripFrontmatter(readFileSync(standard.path, 'utf8'))
100
113
  }
@@ -0,0 +1,56 @@
1
+ import { linesOutsideFences } from '@/markdown/scan'
2
+
3
+ const SCOPE_HEADING = /^##[ \t]+Scope[ \t]*$/
4
+ const ANY_HEADING = /^#{1,6}[ \t]+\S/
5
+ const CODE_SPAN = /`([^`]+)`/g
6
+
7
+ /** What an attribute standard says of itself, since it names no path. */
8
+ export const ATTRIBUTE_MARKER = 'attribute standard'
9
+
10
+ export interface Scope {
11
+ /** The first non-blank line under the heading, which is the statement. */
12
+ readonly statement: string
13
+ readonly lines: readonly string[]
14
+ }
15
+
16
+ export function readScope(text: string): Scope | undefined {
17
+ const lines = linesOutsideFences(text)
18
+ const opened = lines.findIndex((line) => SCOPE_HEADING.test(line.trim()))
19
+ if (opened === -1) return undefined
20
+
21
+ const body: string[] = []
22
+
23
+ for (const line of lines.slice(opened + 1)) {
24
+ if (ANY_HEADING.test(line.trim())) break
25
+ body.push(line)
26
+ }
27
+
28
+ const statement = body.find((line) => line.trim().length > 0)
29
+
30
+ return { statement: statement?.trim() ?? '', lines: body }
31
+ }
32
+
33
+ /**
34
+ * The paths a scope statement declares: backticked spans in the first sentence
35
+ * alone. The catalog's `appliesTo` field and the records gate both read the
36
+ * statement through this, since one sentence read two ways would let a standard
37
+ * pass the gate while publishing a different jurisdiction to every consumer.
38
+ */
39
+ export function governedPaths(statement: string): string[] {
40
+ const [sentence] = statement.split('. ')
41
+ return [...sentence.matchAll(CODE_SPAN)].map((match) => match[1])
42
+ }
43
+
44
+ /**
45
+ * The jurisdiction a standard publishes. The marker is read only where the
46
+ * first sentence backticks nothing, so a statement naming a path publishes that
47
+ * path however the rest of it describes itself. An empty array means the
48
+ * statement did not parse, which consumers report rather than skip.
49
+ */
50
+ export function appliesTo(text: string): string[] {
51
+ const statement = readScope(text)?.statement ?? ''
52
+ const paths = governedPaths(statement)
53
+
54
+ if (paths.length === 0 && statement.includes(ATTRIBUTE_MARKER)) return ['*']
55
+ return paths
56
+ }
package/src/sync/check.ts CHANGED
@@ -28,7 +28,12 @@ import {
28
28
  type StampDomain,
29
29
  } from '@/sync/stamp'
30
30
  import { isDirectory } from '@/target'
31
- import { loadManifest } from '@/tooling/manifest'
31
+ import {
32
+ isWholeStack,
33
+ loadManifest,
34
+ type Manifest,
35
+ resolveChain,
36
+ } from '@/tooling/manifest'
32
37
  import { scan } from '@/tooling/scan'
33
38
  import { readSkew, type SkewReport } from '@/version/skew'
34
39
 
@@ -253,9 +258,7 @@ export function buildToolingReport(
253
258
  stamp: Stamp | undefined,
254
259
  ): ToolingReport {
255
260
  const chain = stampedChain(stamp, 'tooling')
256
- const manifests = chain
257
- .map((name) => loadManifest(toolkitRoot, name))
258
- .filter((manifest) => manifest !== undefined)
261
+ const manifests = resolveRecorded(toolkitRoot, chain)
259
262
 
260
263
  if (manifests.length === 0) return { ...UNMEASURED_TOOLING, chain }
261
264
 
@@ -281,6 +284,37 @@ export function buildToolingReport(
281
284
  }
282
285
  }
283
286
 
287
+ /**
288
+ * Loads each recorded stack whole. When the recorded chain stops short of its
289
+ * last stack's parent, the install skipped that parent, so the chain is
290
+ * re-resolved with it as the skip to bring back any per-root configs the
291
+ * skipped stack still shipped. The re-resolution is kept only when its whole
292
+ * stacks are exactly the recorded ones.
293
+ */
294
+ function resolveRecorded(
295
+ toolkitRoot: string,
296
+ recorded: readonly string[],
297
+ ): Manifest[] {
298
+ const loaded = recorded
299
+ .map((name) => loadManifest(toolkitRoot, name))
300
+ .filter((manifest) => manifest !== undefined)
301
+
302
+ const parent = loaded.at(-1)?.parent
303
+ if (
304
+ loaded.length !== recorded.length ||
305
+ parent === undefined ||
306
+ recorded.includes(parent)
307
+ ) {
308
+ return loaded
309
+ }
310
+
311
+ const resolved = resolveChain(toolkitRoot, recorded[0], {
312
+ skipStack: parent,
313
+ })
314
+ const whole = resolved.filter(isWholeStack).map((manifest) => manifest.name)
315
+ return whole.join('\0') === recorded.join('\0') ? resolved : loaded
316
+ }
317
+
284
318
  /**
285
319
  * Whether the target is a toolkit-managed project at all. Seeds are enumerated
286
320
  * from the source rather than from what a target installed, so without this gate
@@ -84,6 +84,23 @@ export interface ReadyMove {
84
84
  readonly to: string
85
85
  }
86
86
 
87
+ /** A task file rewritten because it linked the task that moved. */
88
+ export interface InboundRelink {
89
+ readonly file: string
90
+ readonly links: number
91
+ }
92
+
93
+ /** A task file the pass could not read or write, left as it was. */
94
+ export interface RelinkFailure {
95
+ readonly file: string
96
+ readonly message: string
97
+ }
98
+
99
+ export interface RepointOutcome {
100
+ readonly relinked: readonly InboundRelink[]
101
+ readonly failed: readonly RelinkFailure[]
102
+ }
103
+
87
104
  export interface ArchiveSuccess {
88
105
  readonly ok: true
89
106
  readonly stem: string
@@ -97,6 +114,10 @@ export interface ArchiveSuccess {
97
114
  readonly ready: ReadyMove | undefined
98
115
  readonly closed: number
99
116
  readonly cut: number
117
+ /** Every task file whose links to the moved task were rewritten. */
118
+ readonly relinked: readonly InboundRelink[]
119
+ /** Every task file the pass could not read or write, left as it was. */
120
+ readonly relinkFailed: readonly RelinkFailure[]
100
121
  }
101
122
 
102
123
  export interface ArchiveRefused {
@@ -118,6 +139,10 @@ export interface DeclineSuccess {
118
139
  readonly indexRegenerated: boolean
119
140
  /** Every live plan the task cited that no other live task still holds, in line order. */
120
141
  readonly plans: readonly PlanMove[]
142
+ /** Every task file whose links to the moved task were rewritten. */
143
+ readonly relinked: readonly InboundRelink[]
144
+ /** Every task file the pass could not read or write, left as it was. */
145
+ readonly relinkFailed: readonly RelinkFailure[]
121
146
  }
122
147
 
123
148
  export interface DeclineRefused {
@@ -392,6 +417,33 @@ export function retargetPlanLine(
392
417
  return lines.join('\n')
393
418
  }
394
419
 
420
+ /**
421
+ * Appends a link to a located `Plan:` line right after its last target, so
422
+ * trailing prose and a line ending stay where they were. A bare target is
423
+ * rewritten as a link on the way, since the reader takes bare paths only when
424
+ * no link sits on the line and the appended one would otherwise hide them.
425
+ */
426
+ export function appendPlanLink(line: string, target: string): string {
427
+ const tokens = planTokens(line)
428
+ if (tokens.length === 0) {
429
+ const trailing = /\s*$/.exec(line)?.[0] ?? ''
430
+ return `${line.slice(0, line.length - trailing.length)} ${planLink(target)}${trailing}`
431
+ }
432
+
433
+ let rewritten = ''
434
+ let at = 0
435
+ for (const token of tokens) {
436
+ const isLink = line[token.start] === '['
437
+ rewritten += line.slice(at, token.start)
438
+ rewritten += isLink
439
+ ? line.slice(token.start, token.end)
440
+ : planLink(token.target)
441
+ at = token.end
442
+ }
443
+
444
+ return `${rewritten}, ${planLink(target)}${line.slice(at)}`
445
+ }
446
+
395
447
  /** The `Ready:` line in either form, the link's target captured ahead of the bare path. */
396
448
  const READY_PATTERN = /^Ready:[ \t]*(?:\[[^\]]*\]\(([^)]+)\)|(\S+))[ \t]*$/m
397
449
 
@@ -942,6 +994,7 @@ export async function archiveTask(
942
994
  : retargeted,
943
995
  )
944
996
 
997
+ const { relinked, failed } = await repointInboundLinks(root, from, to)
945
998
  const priorityRowRemoved = await clearPriorityRow(dir, stem)
946
999
  const regen = await regenOne(dir, { dryRun: false })
947
1000
 
@@ -956,6 +1009,8 @@ export async function archiveTask(
956
1009
  ready: ready || undefined,
957
1010
  closed: closed.length,
958
1011
  cut: cut.length,
1012
+ relinked,
1013
+ relinkFailed: failed,
959
1014
  }
960
1015
  }
961
1016
 
@@ -1106,6 +1161,8 @@ export function linkTo(taskDir: string, plan: string): string {
1106
1161
  const MARKDOWN_LINK_PATTERN = /(\]\()([^)\s]+)/g
1107
1162
  /** The capture group makes `split` keep each code span, at the odd positions. */
1108
1163
  const INLINE_CODE_PATTERN = /(`+[^`]*`+)/
1164
+ /** A reference-style definition, whose target is the whole line past the label. */
1165
+ const REFERENCE_DEFINITION_PATTERN = /^(\s{0,3}\[[^\]]+\]:[ \t]*)(\S+)[ \t]*$/
1109
1166
  const BARE_ORIGIN_PATTERN =
1110
1167
  /^((?:Plan|Ready|Groundwork|Intake):[ \t]*)([^\s[\]()]+)[ \t]*$/
1111
1168
 
@@ -1113,12 +1170,18 @@ function isRelativeTarget(target: string): boolean {
1113
1170
  return !/^(?:[a-z][a-z0-9+.-]*:|\/|#)/i.test(target)
1114
1171
  }
1115
1172
 
1173
+ function splitSuffix(target: string): { path: string; suffix: string } {
1174
+ const suffixAt = target.search(/[#?]/)
1175
+
1176
+ return suffixAt === -1
1177
+ ? { path: target, suffix: '' }
1178
+ : { path: target.slice(0, suffixAt), suffix: target.slice(suffixAt) }
1179
+ }
1180
+
1116
1181
  function rebaseTarget(target: string, fromDir: string, toDir: string): string {
1117
1182
  if (!isRelativeTarget(target)) return target
1118
1183
 
1119
- const suffixAt = target.search(/[#?]/)
1120
- const path = suffixAt === -1 ? target : target.slice(0, suffixAt)
1121
- const suffix = suffixAt === -1 ? '' : target.slice(suffixAt)
1184
+ const { path, suffix } = splitSuffix(target)
1122
1185
  if (path === '') return target
1123
1186
 
1124
1187
  const rebased = relative(toDir, resolve(fromDir, path)).split(sep).join('/')
@@ -1166,6 +1229,132 @@ export function rebaseRelativeLinks(
1166
1229
  .join('\n')
1167
1230
  }
1168
1231
 
1232
+ /**
1233
+ * A link resolving to `from`, rewritten to read `to` from the citing file's own
1234
+ * folder. Resolution decides the match, so a link naming the same stem in
1235
+ * another folder is left as written.
1236
+ */
1237
+ function repointTarget(
1238
+ target: string,
1239
+ fileDir: string,
1240
+ from: string,
1241
+ to: string,
1242
+ ): string | undefined {
1243
+ if (!isRelativeTarget(target)) return undefined
1244
+
1245
+ const { path, suffix } = splitSuffix(target)
1246
+ if (path === '' || resolve(fileDir, path) !== from) return undefined
1247
+
1248
+ return `${relative(fileDir, to).split(sep).join('/')}${suffix}`
1249
+ }
1250
+
1251
+ function repointLinks(
1252
+ text: string,
1253
+ fileDir: string,
1254
+ from: string,
1255
+ to: string,
1256
+ ): { text: string; links: number } {
1257
+ const lines = text.split('\n')
1258
+ const fenced = fenceMask(lines)
1259
+ let links = 0
1260
+
1261
+ const repointed = lines.map((line, index) => {
1262
+ if (fenced[index]) return line
1263
+
1264
+ const definition = REFERENCE_DEFINITION_PATTERN.exec(line)
1265
+ if (definition) {
1266
+ const next = repointTarget(definition[2], fileDir, from, to)
1267
+ if (next === undefined) return line
1268
+
1269
+ links += 1
1270
+ return `${definition[1]}${next}`
1271
+ }
1272
+
1273
+ return line
1274
+ .split(INLINE_CODE_PATTERN)
1275
+ .map((part, position) =>
1276
+ position % 2 === 1
1277
+ ? part
1278
+ : part.replace(
1279
+ MARKDOWN_LINK_PATTERN,
1280
+ (whole: string, open: string, target: string) => {
1281
+ const next = repointTarget(target, fileDir, from, to)
1282
+ if (next === undefined) return whole
1283
+
1284
+ links += 1
1285
+ return `${open}${next}`
1286
+ },
1287
+ ),
1288
+ )
1289
+ .join('')
1290
+ })
1291
+
1292
+ return { text: repointed.join('\n'), links }
1293
+ }
1294
+
1295
+ /**
1296
+ * Rewrites every link in the task folders that resolves to the moved task, so
1297
+ * a citation follows the file. The moved file and the reserved siblings are
1298
+ * skipped: the first was rebased on its own, and `citedStem` reads a blocker in
1299
+ * `priority.md` only while its target carries no `/`. Fenced samples and code
1300
+ * spans stay verbatim, the way `rebaseRelativeLinks` leaves them.
1301
+ *
1302
+ * It runs after the rename and ahead of the ordering-row removal and the index
1303
+ * regen, which the unattended post-merge hook cannot redo once the task has
1304
+ * moved. A read or write that fails is therefore reported in `failed` and never
1305
+ * thrown, so the steps after it always run.
1306
+ */
1307
+ export async function repointInboundLinks(
1308
+ root: string,
1309
+ from: string,
1310
+ to: string,
1311
+ ): Promise<RepointOutcome> {
1312
+ const moved = resolve(from)
1313
+ const destination = resolve(to)
1314
+ const relinked: InboundRelink[] = []
1315
+ const failed: RelinkFailure[] = []
1316
+
1317
+ for (const dir of [tasksDir(root), archiveDir(root), declinedDir(root)]) {
1318
+ if (!existsSync(dir)) continue
1319
+
1320
+ let names: string[]
1321
+ try {
1322
+ names = (await readdir(dir)).filter((name) => name.endsWith('.md'))
1323
+ } catch (error) {
1324
+ failed.push({ file: dir, message: describeError(error) })
1325
+ continue
1326
+ }
1327
+ names.sort()
1328
+
1329
+ for (const name of names) {
1330
+ const file = join(dir, name)
1331
+ if (isReservedStem(name.slice(0, -'.md'.length))) continue
1332
+ if (resolve(file) === destination) continue
1333
+
1334
+ try {
1335
+ const repointed = repointLinks(
1336
+ await readFile(file, 'utf8'),
1337
+ dir,
1338
+ moved,
1339
+ destination,
1340
+ )
1341
+ if (repointed.links === 0) continue
1342
+
1343
+ await writeFile(file, repointed.text)
1344
+ relinked.push({ file, links: repointed.links })
1345
+ } catch (error) {
1346
+ failed.push({ file, message: describeError(error) })
1347
+ }
1348
+ }
1349
+ }
1350
+
1351
+ return { relinked, failed }
1352
+ }
1353
+
1354
+ function describeError(error: unknown): string {
1355
+ return error instanceof Error ? error.message : String(error)
1356
+ }
1357
+
1169
1358
  async function clearPriorityRow(dir: string, stem: string): Promise<boolean> {
1170
1359
  const path = join(dir, 'priority.md')
1171
1360
  if (!existsSync(path)) return false
@@ -1239,6 +1428,7 @@ export async function declineTask(
1239
1428
  )
1240
1429
  await writeFile(to, declined)
1241
1430
 
1431
+ const { relinked, failed } = await repointInboundLinks(root, from, to)
1242
1432
  const priorityRowRemoved = await clearPriorityRow(dir, stem)
1243
1433
  const backlogRowRemoved = await clearBacklogRow(dir, stem)
1244
1434
  const regen = await regenOne(dir, { dryRun: false })
@@ -1252,5 +1442,7 @@ export async function declineTask(
1252
1442
  backlogRowRemoved,
1253
1443
  indexRegenerated: regen.action === 'written',
1254
1444
  plans: plans.moves,
1445
+ relinked,
1446
+ relinkFailed: failed,
1255
1447
  }
1256
1448
  }
@@ -3,6 +3,7 @@ import { readFile, writeFile } from 'node:fs/promises'
3
3
  import { basename, join, relative } from 'node:path'
4
4
  import { planCandidates } from '@/tasks/answers'
5
5
  import {
6
+ appendPlanLink,
6
7
  describeUnmatchedStem,
7
8
  fenceMask,
8
9
  linkTo,
@@ -240,12 +241,33 @@ export type PlanLineWrite =
240
241
  * A line already linking several plans is refused whole, even when one of them
241
242
  * is the target, since overwriting it drops links the caller never named and
242
243
  * reporting it unchanged hides a line that breaks the one-plan rule.
244
+ *
245
+ * `add` is the sliced task's writer, which links one plan per slice. It keeps
246
+ * every link in order and appends the target, reports `unchanged` when the
247
+ * target is already listed, and so never refuses a line holding several.
243
248
  */
244
- export function writePlanLine(text: string, target: string): PlanLineWrite {
249
+ export function writePlanLine(
250
+ text: string,
251
+ target: string,
252
+ add = false,
253
+ ): PlanLineWrite {
245
254
  const line = planLine(target)
246
255
  const lines = text.split('\n')
247
256
  const existing = planLineIndex(lines)
248
257
 
258
+ if (existing !== -1 && add) {
259
+ if (readPlanTargets(lines[existing]).includes(target)) {
260
+ return { ok: true, text, action: 'unchanged', replaced: undefined }
261
+ }
262
+ lines[existing] = appendPlanLink(lines[existing], target)
263
+ return {
264
+ ok: true,
265
+ text: lines.join('\n'),
266
+ action: 'appended',
267
+ replaced: undefined,
268
+ }
269
+ }
270
+
249
271
  if (existing !== -1) {
250
272
  const targets = readPlanTargets(lines[existing])
251
273
  if (targets.length > 1) return { ok: false, targets }
@@ -449,6 +471,7 @@ export async function recordPlan(
449
471
  root: string,
450
472
  stem: string,
451
473
  reference: string,
474
+ add = false,
452
475
  ): Promise<PlanOutcome> {
453
476
  const opened = await openTask(root, { kind: 'stem', stem })
454
477
  if ('ok' in opened) return opened
@@ -464,7 +487,7 @@ export async function recordPlan(
464
487
  }
465
488
 
466
489
  const target = linkTo(dir, plan)
467
- const written = writePlanLine(await readFile(path, 'utf8'), target)
490
+ const written = writePlanLine(await readFile(path, 'utf8'), target, add)
468
491
 
469
492
  if (!written.ok) {
470
493
  return refuse(
@@ -8,9 +8,10 @@ import { resolveSurfacePath } from '@/surface-root'
8
8
  import { mergeSections, pruneSections } from '@/tooling/gitignore'
9
9
  import {
10
10
  ancestorsFirst,
11
+ configFiles,
11
12
  configPaths,
12
- listFiles,
13
13
  type Manifest,
14
+ seedFiles,
14
15
  } from '@/tooling/manifest'
15
16
  import {
16
17
  applyScripts,
@@ -36,7 +37,7 @@ export async function injectConfigs(
36
37
  const isSubfolder = isSubfolderTarget(target)
37
38
 
38
39
  for (const manifest of ancestorsFirst(chain)) {
39
- const files = listFiles(manifest.configsDir).filter(
40
+ const files = configFiles(manifest).filter(
40
41
  (rel) => !(isSubfolder && isWithheldInSubfolder(rel)),
41
42
  )
42
43
  if (files.length === 0) continue
@@ -106,7 +107,7 @@ export async function injectSeeds(
106
107
  const shadowed = configPaths(chain)
107
108
 
108
109
  for (const manifest of ancestorsFirst(chain)) {
109
- const files = listFiles(manifest.seedsDir).filter(
110
+ const files = seedFiles(manifest).filter(
110
111
  (rel) =>
111
112
  !shadowed.has(rel) && !(isSubfolder && isWithheldInSubfolder(rel)),
112
113
  )
@@ -18,6 +18,18 @@ export interface Manifest {
18
18
  readonly scriptOverrides: Readonly<Record<string, string>>
19
19
  readonly gitignore: readonly GitignoreSection[]
20
20
  readonly devPackages: readonly string[]
21
+ /**
22
+ * Config paths this stack still ships into a folder that skips it, read
23
+ * from `[sync] per_root`. Each is a per-root entry point a subfolder needs
24
+ * as much as the repository root does.
25
+ */
26
+ readonly perRoot?: readonly string[]
27
+ /**
28
+ * Set only on a stack kept in a chain that skips it. The stack then ships
29
+ * these configs and nothing else: no seeds, scripts, dependencies, or
30
+ * gitignore entries.
31
+ */
32
+ readonly onlyConfigs?: readonly string[]
21
33
  }
22
34
 
23
35
  export interface ChainOptions {
@@ -50,7 +62,28 @@ export function listFiles(dir: string): string[] {
50
62
  * `injectSeeds` both read this, so the report and the write cannot disagree.
51
63
  */
52
64
  export function configPaths(chain: readonly Manifest[]): Set<string> {
53
- return new Set(chain.flatMap((manifest) => listFiles(manifest.configsDir)))
65
+ return new Set(chain.flatMap(configFiles))
66
+ }
67
+
68
+ /**
69
+ * The config paths a manifest ships, narrowed to `onlyConfigs` on a stack the
70
+ * chain skipped. `scan` and `injectConfigs` both read this, so the diff and
71
+ * the write cannot disagree on what a skipped stack still carries.
72
+ */
73
+ export function configFiles(manifest: Manifest): string[] {
74
+ const files = listFiles(manifest.configsDir)
75
+ const only = manifest.onlyConfigs
76
+ return only === undefined ? files : files.filter((rel) => only.includes(rel))
77
+ }
78
+
79
+ /** The seed paths a manifest ships. A skipped stack ships none. */
80
+ export function seedFiles(manifest: Manifest): string[] {
81
+ return manifest.onlyConfigs === undefined ? listFiles(manifest.seedsDir) : []
82
+ }
83
+
84
+ /** Whether the chain carries this stack whole rather than skipped. */
85
+ export function isWholeStack(manifest: Manifest): boolean {
86
+ return manifest.onlyConfigs === undefined
54
87
  }
55
88
 
56
89
  /**
@@ -100,6 +133,7 @@ export function loadManifest(
100
133
  const parent =
101
134
  typeof stackTable.extends === 'string' ? stackTable.extends : ''
102
135
  const scriptsTable = asTable(parsed.scripts)
136
+ const perRoot = readStrings(asTable(parsed.sync).per_root)
103
137
  const dir = join(toolingDir(root), stack)
104
138
 
105
139
  return {
@@ -112,6 +146,7 @@ export function loadManifest(
112
146
  scriptOverrides: pickStrings(asTable(scriptsTable.override)),
113
147
  gitignore: readGitignoreSections(asTable(parsed.gitignore)),
114
148
  devPackages: readDevPackages(parsed.dependencies),
149
+ ...(perRoot.length > 0 ? { perRoot } : {}),
115
150
  }
116
151
  }
117
152
 
@@ -120,8 +155,11 @@ export function loadManifest(
120
155
  * values before child values reverse the result. Replaces the seventeen
121
156
  * copies of `grep '^extends' | cut -d'"' -f2` the bash carried.
122
157
  *
123
- * A stack matching `skipStack` truncates the chain at that point, mirroring
124
- * the `[ "$stack" = "${SKIP_STACK:-}" ] && return` guard.
158
+ * A stack matching `skipStack` drops from the chain along with every
159
+ * ancestor past it, except that one declaring `[sync] per_root` stays as a
160
+ * restricted manifest shipping those configs alone. A `--skip base` subfolder
161
+ * keeps its own verify entry point that way, while husky, prettier, and the
162
+ * rest stay single at the repository root.
125
163
  */
126
164
  export function resolveChain(
127
165
  root: string,
@@ -131,22 +169,35 @@ export function resolveChain(
131
169
  const chain: Manifest[] = []
132
170
  const seen = new Set<string>()
133
171
  let current: string | undefined = stack
172
+ let isSkipping = false
134
173
 
135
174
  while (current !== undefined) {
136
- if (current === options.skipStack) break
175
+ if (current === options.skipStack) isSkipping = true
137
176
  if (seen.has(current)) break
138
177
  seen.add(current)
139
178
 
140
179
  const manifest: Manifest | undefined = loadManifest(root, current)
141
180
  if (!manifest) break
142
181
 
143
- chain.push(manifest)
182
+ if (!isSkipping) chain.push(manifest)
183
+ else if (manifest.perRoot !== undefined) chain.push(restrict(manifest))
144
184
  current = manifest.parent
145
185
  }
146
186
 
147
187
  return chain
148
188
  }
149
189
 
190
+ function restrict(manifest: Manifest): Manifest {
191
+ return {
192
+ ...manifest,
193
+ onlyConfigs: manifest.perRoot,
194
+ scripts: {},
195
+ scriptOverrides: {},
196
+ gitignore: [],
197
+ devPackages: [],
198
+ }
199
+ }
200
+
150
201
  function asTable(value: unknown): Record<string, unknown> {
151
202
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
152
203
  return {}
@@ -177,9 +228,12 @@ function readGitignoreSections(
177
228
  }
178
229
 
179
230
  function readDevPackages(dependencies: unknown): string[] {
180
- const dev = asTable(asTable(dependencies).dev)
181
- if (!Array.isArray(dev.packages)) return []
182
- return dev.packages.filter(
231
+ return readStrings(asTable(asTable(dependencies).dev).packages)
232
+ }
233
+
234
+ function readStrings(value: unknown): string[] {
235
+ if (!Array.isArray(value)) return []
236
+ return value.filter(
183
237
  (entry): entry is string => typeof entry === 'string' && entry !== '',
184
238
  )
185
239
  }
@@ -4,9 +4,10 @@ import { resolveSurfacePath } from '@/surface-root'
4
4
  import { mergeSections } from '@/tooling/gitignore'
5
5
  import {
6
6
  ancestorsFirst,
7
+ configFiles,
7
8
  configPaths,
8
- listFiles,
9
9
  type Manifest,
10
+ seedFiles,
10
11
  } from '@/tooling/manifest'
11
12
  import {
12
13
  collectDeps,
@@ -83,7 +84,7 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
83
84
  const seenConfigs = new Set<string>()
84
85
 
85
86
  for (const manifest of chain) {
86
- for (const rel of listFiles(manifest.configsDir)) {
87
+ for (const rel of configFiles(manifest)) {
87
88
  if (seenConfigs.has(rel)) continue
88
89
  seenConfigs.add(rel)
89
90
 
@@ -109,7 +110,7 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
109
110
  const seenSeeds = configPaths(chain)
110
111
 
111
112
  for (const manifest of chain) {
112
- for (const rel of listFiles(manifest.seedsDir)) {
113
+ for (const rel of seedFiles(manifest)) {
113
114
  if (seenSeeds.has(rel)) continue
114
115
  seenSeeds.add(rel)
115
116