@erclx/canon 5.27.0 → 5.29.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 (177) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/draft-ready/SKILL.md +3 -1
  3. package/claude/skills/git-pr/references/evidence.md +1 -1
  4. package/claude/skills/review-address/REQUIREMENT.md +4 -2
  5. package/claude/skills/review-address/SKILL.md +16 -7
  6. package/claude/skills/review-address/references/rebase-conflicts.md +4 -2
  7. package/claude/skills/review-craft/references/ui.md +4 -4
  8. package/claude/skills/review-ui/REQUIREMENT.md +2 -2
  9. package/claude/skills/review-ui/SKILL.md +13 -11
  10. package/claude/skills/review-ui/references/driving.md +8 -8
  11. package/claude/skills/review-ui/references/post.md +19 -7
  12. package/claude/skills/review-ui/references/tick.md +22 -0
  13. package/claude/skills/role-orchestrator/REQUIREMENT.md +15 -15
  14. package/claude/skills/role-orchestrator/SKILL.md +24 -26
  15. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +18 -16
  16. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +12 -2
  17. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +1 -1
  18. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +1 -1
  19. package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -4
  20. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +5 -5
  21. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +11 -2
  22. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +6 -6
  23. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +2 -2
  24. package/claude/skills/role-orchestrator/scripts/poll.ts +304 -10
  25. package/claude/skills/role-planner/REQUIREMENT.md +1 -1
  26. package/claude/skills/role-planner/SKILL.md +2 -2
  27. package/claude/skills/role-reviewer/SKILL.md +1 -1
  28. package/claude/skills/role-worker/SKILL.md +3 -2
  29. package/claude/skills/ui-checklist/SKILL.md +1 -1
  30. package/docs/agents/canvas.md +12 -4
  31. package/docs/agents/gate.md +3 -1
  32. package/docs/agents/index.md +2 -1
  33. package/docs/agents/pr-evidence.md +15 -14
  34. package/docs/agents/pr-tick.md +72 -0
  35. package/docs/agents/sandbox.md +2 -2
  36. package/docs/agents/tasks-plans.md +1 -1
  37. package/docs/agents/tasks-validate.md +18 -18
  38. package/docs/agents/tasks.md +1 -1
  39. package/docs/workflow/operating-model.md +10 -15
  40. package/docs/workflow/parallel-features.md +7 -7
  41. package/package.json +1 -1
  42. package/src/audits/baseline.ts +1 -1
  43. package/src/audits/run.ts +1 -1
  44. package/src/canvas/capture.ts +1 -1
  45. package/src/canvas/client/app.tsx +105 -4
  46. package/src/canvas/client/inspector/field.tsx +61 -2
  47. package/src/canvas/client/inspector/fields.css +78 -0
  48. package/src/canvas/client/inspector/token-select.tsx +109 -0
  49. package/src/canvas/client/inspector/values.ts +74 -2
  50. package/src/canvas/client/inspector.tsx +134 -15
  51. package/src/canvas/client/keys.ts +71 -0
  52. package/src/canvas/client/pages-panel.tsx +5 -1
  53. package/src/canvas/client/panel-handle.tsx +90 -0
  54. package/src/canvas/client/selection.tsx +3 -1
  55. package/src/canvas/client/shell.css +194 -4
  56. package/src/canvas/client/state.ts +255 -11
  57. package/src/canvas/client/surface.tsx +231 -18
  58. package/src/canvas/client/tools.tsx +153 -0
  59. package/src/canvas/content.ts +66 -1
  60. package/src/canvas/edit.ts +207 -25
  61. package/src/canvas/history.ts +116 -0
  62. package/src/canvas/server.ts +119 -13
  63. package/src/canvas/tokens.ts +1 -1
  64. package/src/claude/seeds.ts +2 -2
  65. package/src/claude/skills-reach.ts +1 -1
  66. package/src/{exec.ts → cli/exec.ts} +2 -2
  67. package/src/{help.ts → cli/help.ts} +1 -1
  68. package/src/cli.ts +1 -1
  69. package/src/commands/audits.ts +9 -1
  70. package/src/commands/autoship.ts +1 -1
  71. package/src/commands/canvas.ts +2 -2
  72. package/src/commands/capture.ts +1 -1
  73. package/src/commands/census.ts +1 -1
  74. package/src/commands/claude.ts +5 -5
  75. package/src/commands/comments.ts +1 -1
  76. package/src/commands/context.ts +1 -1
  77. package/src/commands/demo.ts +9 -1
  78. package/src/commands/deps.ts +1 -1
  79. package/src/commands/design.ts +12 -4
  80. package/src/commands/docs.ts +2 -2
  81. package/src/commands/driver.ts +9 -1
  82. package/src/commands/feedback.ts +3 -3
  83. package/src/commands/gate.ts +10 -3
  84. package/src/commands/gov.ts +3 -3
  85. package/src/commands/indexes.ts +1 -1
  86. package/src/commands/init.ts +11 -3
  87. package/src/commands/intake.ts +1 -1
  88. package/src/commands/inventory.ts +9 -1
  89. package/src/commands/labels.ts +1 -1
  90. package/src/commands/markdown.ts +1 -1
  91. package/src/commands/migrate.ts +2 -2
  92. package/src/commands/pr.ts +324 -9
  93. package/src/commands/records.ts +1 -1
  94. package/src/commands/repo.ts +1 -1
  95. package/src/commands/sandbox.ts +11 -14
  96. package/src/commands/secrets.ts +9 -1
  97. package/src/commands/sessions.ts +1 -1
  98. package/src/commands/slides.ts +2 -2
  99. package/src/commands/standards.ts +1 -1
  100. package/src/commands/sync.ts +3 -3
  101. package/src/commands/targets.ts +1 -1
  102. package/src/commands/tasks.ts +20 -3
  103. package/src/commands/teach.ts +1 -1
  104. package/src/commands/tooling.ts +2 -2
  105. package/src/commands/transcripts.ts +2 -2
  106. package/src/commands/upgrade.ts +2 -2
  107. package/src/commands/wiki.ts +2 -2
  108. package/src/commands/worktrees.ts +1 -1
  109. package/src/context/architecture.ts +1 -1
  110. package/src/context/citations.ts +2 -2
  111. package/src/context/classify/extract.ts +1 -1
  112. package/src/context/folders.ts +2 -2
  113. package/src/context/requirements.ts +1 -1
  114. package/src/docs/read.ts +1 -1
  115. package/src/feedback/github.ts +1 -1
  116. package/src/gate/measures.ts +2 -2
  117. package/src/gate/sequencer.ts +55 -3
  118. package/src/gate/stages.ts +8 -0
  119. package/src/gate/suite-lock.ts +214 -0
  120. package/src/init/run.ts +1 -1
  121. package/src/intake/folder.ts +1 -1
  122. package/src/labels/map.ts +1 -1
  123. package/src/labels/phase.ts +3 -3
  124. package/src/migrate/record-layout.ts +1 -1
  125. package/src/migrate/records.ts +11 -11
  126. package/src/migrate/rename.ts +1 -1
  127. package/src/migrate/scratch-evidence.ts +1 -1
  128. package/src/migrate/surface-roots.ts +6 -6
  129. package/src/pr/evidence.ts +188 -16
  130. package/src/pr/tick.ts +56 -0
  131. package/src/process/harness.ts +1 -1
  132. package/src/records/backup.ts +1 -1
  133. package/src/records/ordinal.ts +1 -1
  134. package/src/records/prune.ts +1 -1
  135. package/src/records/size.ts +1 -1
  136. package/src/records/stale-canonical.ts +1 -1
  137. package/src/records/stale.ts +1 -1
  138. package/src/records/validate.ts +2 -2
  139. package/src/{project-root.ts → roots/project.ts} +2 -2
  140. package/src/{record-root.ts → roots/record.ts} +1 -1
  141. package/src/sandbox/coverage.ts +11 -12
  142. package/src/sandbox/equivalence.ts +57 -26
  143. package/src/sandbox/headless/run.ts +1 -1
  144. package/src/sandbox/hooks.ts +1 -1
  145. package/src/sandbox/provision.ts +79 -29
  146. package/src/sandbox/scenario.ts +588 -0
  147. package/src/serve/report.ts +1 -1
  148. package/src/sessions/live.ts +4 -1
  149. package/src/sessions/transfer/bundle.ts +1 -1
  150. package/src/slides/decks.ts +1 -1
  151. package/src/standards/read.ts +1 -1
  152. package/src/sync/engine.ts +2 -2
  153. package/src/sync/git.ts +1 -1
  154. package/src/sync/layout.ts +2 -2
  155. package/src/sync/stamp.ts +1 -1
  156. package/src/sync/workflow.ts +1 -1
  157. package/src/targets/validate.ts +2 -2
  158. package/src/tasks/answers.ts +1 -1
  159. package/src/tasks/archive.ts +1 -1
  160. package/src/tasks/label.ts +1 -1
  161. package/src/tasks/reach.ts +1 -1
  162. package/src/tasks/validate.ts +38 -14
  163. package/src/teach/nav.ts +1 -1
  164. package/src/teach/workspace.ts +1 -1
  165. package/src/tooling/inject.ts +2 -2
  166. package/src/tooling/read.ts +1 -1
  167. package/src/tooling/scan.ts +1 -1
  168. package/src/tooling/verify-stack.ts +1 -1
  169. package/src/version/installed.ts +1 -1
  170. package/src/version/skew.ts +1 -1
  171. package/standards/board.md +3 -3
  172. package/standards/plan.md +13 -7
  173. package/tsconfig.json +1 -1
  174. /package/src/{cli-run.ts → cli/run.ts} +0 -0
  175. /package/src/{ui.ts → cli/ui.ts} +0 -0
  176. /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
  177. /package/src/{surface-root.ts → roots/surface.ts} +0 -0
@@ -1,6 +1,6 @@
1
1
  import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
- import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
3
+ import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
4
4
  import {
5
5
  injectConfigs,
6
6
  injectGitignore,
@@ -32,7 +32,7 @@ import {
32
32
  outro,
33
33
  palette,
34
34
  select,
35
- } from '@/ui'
35
+ } from '@/cli/ui'
36
36
 
37
37
  interface SyncOptions {
38
38
  readonly skip?: string
@@ -1,8 +1,8 @@
1
1
  import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
- import { recordDir } from '@/record-root'
3
+ import { recordDir } from '@/roots/record'
4
4
  import { ensureYtDlp, fetchOne } from '@/transcripts/fetch'
5
- import { palette } from '@/ui'
5
+ import { palette } from '@/cli/ui'
6
6
  import { mainWorktreeRoot } from '@/git/worktree'
7
7
 
8
8
  interface TranscriptOptions {
@@ -1,6 +1,6 @@
1
1
  import type { Command } from 'commander'
2
2
  import { execa } from 'execa'
3
- import { findCheckoutMismatch, PROJECT_ROOT } from '@/project-root'
3
+ import { findCheckoutMismatch, PROJECT_ROOT } from '@/roots/project'
4
4
  import {
5
5
  frameError,
6
6
  intro,
@@ -9,7 +9,7 @@ import {
9
9
  logWarn,
10
10
  outro,
11
11
  select,
12
- } from '@/ui'
12
+ } from '@/cli/ui'
13
13
  import { compareVersions, parseVersion } from '@/version/compare'
14
14
  import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
15
15
  import { detectManager, installCommand, type Manager } from '@/version/manager'
@@ -1,6 +1,6 @@
1
1
  import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
- import { PROJECT_ROOT } from '@/project-root'
3
+ import { PROJECT_ROOT } from '@/roots/project'
4
4
  import {
5
5
  intro,
6
6
  logAdd,
@@ -11,7 +11,7 @@ import {
11
11
  outro,
12
12
  palette,
13
13
  select,
14
- } from '@/ui'
14
+ } from '@/cli/ui'
15
15
  import {
16
16
  applyWikiInit,
17
17
  isWikiTarget,
@@ -8,7 +8,7 @@ import {
8
8
  outro,
9
9
  pipeOutput,
10
10
  plural,
11
- } from '@/ui'
11
+ } from '@/cli/ui'
12
12
  import {
13
13
  type Refusal,
14
14
  type ReclaimReport,
@@ -2,7 +2,7 @@ import { access, readFile } from 'node:fs/promises'
2
2
  import { join, relative } from 'node:path'
3
3
  import { AUDITS } from '@/audits/catalog'
4
4
  import { type BodyLine, bodyLines } from '@/markdown/scan'
5
- import { surfaceDir } from '@/surface-root'
5
+ import { surfaceDir } from '@/roots/surface'
6
6
 
7
7
  /**
8
8
  * The record this measures, relative to `root`.
@@ -2,8 +2,8 @@ import { existsSync } from 'node:fs'
2
2
  import { readFile } from 'node:fs/promises'
3
3
  import { resolve } from 'node:path'
4
4
  import { listRepositoryFiles } from '@/git/files'
5
- import { RECORD_ROOTS } from '@/record-root'
6
- import { SURFACE_ROOTS } from '@/surface-root'
5
+ import { RECORD_ROOTS } from '@/roots/record'
6
+ import { SURFACE_ROOTS } from '@/roots/surface'
7
7
 
8
8
  /**
9
9
  * Suppresses citation checking for the source line carrying it.
@@ -4,7 +4,7 @@ import { $ } from 'bun'
4
4
  import { resolveFolders } from '@/context/folders'
5
5
  import { gitEnv } from '@/git/env'
6
6
  import { listChangedFiles, resolveBaseRef } from '@/git/files'
7
- import { surfaceDir } from '@/surface-root'
7
+ import { surfaceDir } from '@/roots/surface'
8
8
 
9
9
  /**
10
10
  * The five canonical doc types the classifier reaches, matching the file
@@ -1,8 +1,8 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { dirname, relative, resolve } from 'node:path'
3
3
  import { INDEX_FILE, listIndexes } from '@/indexes/walk'
4
- import { RECORD_ROOTS } from '@/record-root'
5
- import { SURFACE_ROOTS } from '@/surface-root'
4
+ import { RECORD_ROOTS } from '@/roots/record'
5
+ import { SURFACE_ROOTS } from '@/roots/surface'
6
6
 
7
7
  /**
8
8
  * Folder names under a record root audited by default.
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises'
2
2
  import { join, relative } from 'node:path'
3
3
  import { wordCount } from '@/context/architecture'
4
4
  import { bodyLines } from '@/markdown/scan'
5
- import { surfaceDir } from '@/surface-root'
5
+ import { surfaceDir } from '@/roots/surface'
6
6
 
7
7
  /**
8
8
  * The record this measures, relative to `root`, resolved at either surface
package/src/docs/read.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
2
2
  import { basename, join } from 'node:path'
3
3
  import { stripFrontmatter } from '@/markdown/frontmatter'
4
- import { SURFACE_ROOTS } from '@/surface-root'
4
+ import { SURFACE_ROOTS } from '@/roots/surface'
5
5
 
6
6
  const INDEX_TOPIC = 'index'
7
7
 
@@ -1,6 +1,6 @@
1
1
  import { execa } from 'execa'
2
2
  import { gitEnv } from '@/git/env'
3
- import { PROJECT_ROOT } from '@/project-root'
3
+ import { PROJECT_ROOT } from '@/roots/project'
4
4
  import {
5
5
  buildIssueArgs,
6
6
  failureDetail,
@@ -37,9 +37,9 @@ import {
37
37
  SHIPPED_CORPORA,
38
38
  type ShippedReference,
39
39
  } from '@/shipped/references'
40
- import { surfaceDir } from '@/surface-root'
40
+ import { surfaceDir } from '@/roots/surface'
41
41
  import { seedRoots } from '@/tooling/seeds'
42
- import { plural } from '@/ui'
42
+ import { plural } from '@/cli/ui'
43
43
  import {
44
44
  README_PARAPHRASE_MARKER,
45
45
  readmeCitationsIn,
@@ -7,8 +7,13 @@ import type {
7
7
  RunCommand,
8
8
  } from '@/gate/measures'
9
9
  import type { Check, Stage } from '@/gate/stages'
10
+ import {
11
+ acquireSuiteLock,
12
+ type LockHolder,
13
+ machineLockPath,
14
+ } from '@/gate/suite-lock'
10
15
  import { gitEnv } from '@/git/env'
11
- import { PROJECT_ROOT } from '@/project-root'
16
+ import { PROJECT_ROOT } from '@/roots/project'
12
17
 
13
18
  export type StageStatus =
14
19
  /** Every check ran and none found a fact. */
@@ -27,8 +32,16 @@ export interface StageResult {
27
32
  readonly emissions: readonly Emission[]
28
33
  /** The remedy line a failed stage prints, naming what to do about it. */
29
34
  readonly failure?: string
30
- /** Wall time the stage's checks took, including every process spawn. */
35
+ /**
36
+ * Wall time the stage took, including every process spawn and, on a stage
37
+ * holding a machine lock, the time it queued behind another worktree.
38
+ */
31
39
  readonly ms: number
40
+ /**
41
+ * The part of `ms` spent queued behind another worktree's hold on the stage's
42
+ * machine lock. Absent where the stage never waited.
43
+ */
44
+ readonly queuedMs?: number
32
45
  }
33
46
 
34
47
  export interface GateContext extends MeasureContext {
@@ -39,6 +52,12 @@ export interface GateContext extends MeasureContext {
39
52
  * scoping is off and every stage runs.
40
53
  */
41
54
  readonly changed?: readonly string[]
55
+ /**
56
+ * Told when a stage starts queuing on its machine lock. A stage prints only
57
+ * once it returns, so without this a queued gate is silent for the whole
58
+ * wait and reads as a hung suite.
59
+ */
60
+ readonly onQueue?: (label: string, holder: LockHolder) => void
42
61
  }
43
62
 
44
63
  export interface ChangedSet {
@@ -157,7 +176,40 @@ async function executeStage(
157
176
  }
158
177
  }
159
178
 
160
- const emissions: Emission[] = []
179
+ // CI runs one suite per runner, so a lock there queues behind nothing.
180
+ if (stage.lock === undefined || ctx.ci) return runChecks(stage, ctx, [])
181
+
182
+ const held = await acquireSuiteLock({
183
+ path: machineLockPath(stage.lock),
184
+ root: ctx.root,
185
+ onWait: (holder) => ctx.onQueue?.(stage.label, holder),
186
+ })
187
+ const queued: Emission[] =
188
+ held.waitedOn === undefined
189
+ ? []
190
+ : [
191
+ {
192
+ kind: 'info',
193
+ text: `Waited ${Math.round(held.waitedMs / 1000)}s for the ${stage.label} stage another worktree held, at ${held.waitedOn.root}`,
194
+ },
195
+ ]
196
+
197
+ try {
198
+ const outcome = await runChecks(stage, ctx, queued)
199
+ return held.waitedOn === undefined
200
+ ? outcome
201
+ : { ...outcome, queuedMs: held.waitedMs }
202
+ } finally {
203
+ held.release()
204
+ }
205
+ }
206
+
207
+ async function runChecks(
208
+ stage: Stage,
209
+ ctx: GateContext,
210
+ leading: readonly Emission[],
211
+ ): Promise<StageOutcome> {
212
+ const emissions: Emission[] = [...leading]
161
213
 
162
214
  for (const check of stage.checks) {
163
215
  const outcome = await runCheck(check, ctx)
@@ -88,6 +88,11 @@ export interface Stage {
88
88
  */
89
89
  readonly when?: (options: { readonly write: boolean }) => boolean
90
90
  readonly checks: readonly Check[]
91
+ /**
92
+ * The machine lock this stage holds while its checks run, so one box runs it
93
+ * for one worktree at a time. Absent means the stage never queues.
94
+ */
95
+ readonly lock?: string
91
96
  /** The line a clean stage prints, where the checks emit none of their own. */
92
97
  readonly success?: string
93
98
  }
@@ -579,6 +584,9 @@ export const STAGES: readonly Stage[] = [
579
584
  label: 'Tests',
580
585
  scope: TESTS_SCOPE,
581
586
  skipped: 'Skipped, no TypeScript or asserted-corpus changes',
587
+ // Concurrent suites from a wave of worktrees starve each other's spawn
588
+ // and browser tests past their timeouts, so the box runs one at a time.
589
+ lock: 'tests',
582
590
  checks: [
583
591
  {
584
592
  kind: 'command',
@@ -0,0 +1,214 @@
1
+ import {
2
+ linkSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ unlinkSync,
7
+ writeFileSync,
8
+ } from 'node:fs'
9
+ import { dirname, join } from 'node:path'
10
+ import { liveness, type LivenessProbes, SYSTEM_PROBES } from '@/sessions/live'
11
+ import { stateDir } from '@/targets/registry'
12
+
13
+ /**
14
+ * Who holds a machine lock, as the file records it.
15
+ *
16
+ * The start time is what separates the holder from whatever inherits its pid
17
+ * after a crash, so a takeover never waits on a recycled number.
18
+ */
19
+ export interface LockHolder {
20
+ readonly pid: number
21
+ readonly procStart: string | undefined
22
+ /** The worktree whose gate holds the lock, which the wait line names. */
23
+ readonly root: string
24
+ }
25
+
26
+ export interface SuiteLockOptions {
27
+ readonly path: string
28
+ readonly root: string
29
+ readonly pid?: number
30
+ readonly probes?: LivenessProbes
31
+ readonly pollMs?: number
32
+ readonly sleep?: (ms: number) => Promise<void>
33
+ /** Called once, when the first poll finds a live holder, so a wait is never silent. */
34
+ readonly onWait?: (holder: LockHolder) => void
35
+ }
36
+
37
+ export interface HeldLock {
38
+ readonly release: () => void
39
+ /** The holder this acquirer queued behind, absent when it never waited. */
40
+ readonly waitedOn?: LockHolder
41
+ readonly waitedMs: number
42
+ }
43
+
44
+ export type AcquireOutcome =
45
+ | { readonly acquired: true; readonly release: () => void }
46
+ | { readonly acquired: false; readonly holder: LockHolder | undefined }
47
+
48
+ /**
49
+ * The file a named machine lock lives at, resolved at call time so a test run
50
+ * that redirects the state folder never touches the real one.
51
+ */
52
+ export function machineLockPath(name: string): string {
53
+ return join(stateDir(), 'locks', `${name}.lock`)
54
+ }
55
+
56
+ function isHolder(value: unknown): value is LockHolder {
57
+ if (typeof value !== 'object' || value === null) return false
58
+ const fields = value as Record<string, unknown>
59
+ return typeof fields.pid === 'number' && typeof fields.root === 'string'
60
+ }
61
+
62
+ function readText(path: string): string | undefined {
63
+ try {
64
+ return readFileSync(path, 'utf8')
65
+ } catch {
66
+ return undefined
67
+ }
68
+ }
69
+
70
+ function parseHolder(text: string): LockHolder | undefined {
71
+ try {
72
+ const value: unknown = JSON.parse(text)
73
+ return isHolder(value) ? value : undefined
74
+ } catch {
75
+ return undefined
76
+ }
77
+ }
78
+
79
+ function isCode(error: unknown, code: string): boolean {
80
+ return (error as NodeJS.ErrnoException).code === code
81
+ }
82
+
83
+ /**
84
+ * Publishes the holder's file whole or not at all.
85
+ *
86
+ * `link` refuses an existing name the way an exclusive open does, and the
87
+ * linked file is already written, so no reader meets an empty lock and mistakes
88
+ * a holder mid-write for a corrupt file it may take over.
89
+ */
90
+ function publish(path: string, text: string, pid: number): boolean {
91
+ const staged = `${path}.${pid}.tmp`
92
+ writeFileSync(staged, text)
93
+ try {
94
+ linkSync(staged, path)
95
+ return true
96
+ } catch (error) {
97
+ if (isCode(error, 'EEXIST')) return false
98
+ throw error
99
+ } finally {
100
+ unlinkSync(staged)
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Moves a dead holder's file aside, or puts back one that changed under it.
106
+ *
107
+ * Unlink then create is two calls, and two acquirers judging one stale file
108
+ * would both get through. A rename moves the file to a name only this acquirer
109
+ * uses, so only one of them gets it, and comparing what moved against what was
110
+ * judged catches the case where another acquirer already replaced it.
111
+ *
112
+ * One interleaving still gets through. A third acquirer publishing between the
113
+ * rename and the restore leaves the moved holder's file with nowhere to go, so
114
+ * two suites run at once. That costs one oversubscribed run and never a hang,
115
+ * and closing it needs a lock the kernel releases, which `node:fs` does not
116
+ * offer.
117
+ */
118
+ function displace(path: string, judged: string, pid: number): void {
119
+ const aside = `${path}.${pid}.stale`
120
+ try {
121
+ renameSync(path, aside)
122
+ } catch (error) {
123
+ if (isCode(error, 'ENOENT')) return
124
+ throw error
125
+ }
126
+
127
+ if (readText(aside) !== judged) {
128
+ try {
129
+ linkSync(aside, path)
130
+ } catch (error) {
131
+ if (!isCode(error, 'EEXIST')) throw error
132
+ }
133
+ }
134
+ unlinkSync(aside)
135
+ }
136
+
137
+ /**
138
+ * One attempt at the lock: take it free, take it over from a dead holder, or
139
+ * report who holds it.
140
+ */
141
+ export function tryAcquire(options: SuiteLockOptions): AcquireOutcome {
142
+ const pid = options.pid ?? process.pid
143
+ const probes = options.probes ?? SYSTEM_PROBES
144
+ const own: LockHolder = {
145
+ pid,
146
+ procStart: probes.procStartOf(pid) ?? undefined,
147
+ root: options.root,
148
+ }
149
+ const text = JSON.stringify(own)
150
+
151
+ mkdirSync(dirname(options.path), { recursive: true })
152
+
153
+ if (publish(options.path, text, pid)) {
154
+ return { acquired: true, release: () => release(options.path, text) }
155
+ }
156
+
157
+ const judged = readText(options.path)
158
+ if (judged === undefined) return { acquired: false, holder: undefined }
159
+
160
+ const holder = parseHolder(judged)
161
+ if (holder !== undefined && liveness(holder, probes).alive) {
162
+ return { acquired: false, holder }
163
+ }
164
+
165
+ displace(options.path, judged, pid)
166
+
167
+ if (publish(options.path, text, pid)) {
168
+ return { acquired: true, release: () => release(options.path, text) }
169
+ }
170
+ return { acquired: false, holder: parseHolder(readText(options.path) ?? '') }
171
+ }
172
+
173
+ /** Removes the file only while it is still this holder's own. */
174
+ function release(path: string, text: string): void {
175
+ if (readText(path) !== text) return
176
+ try {
177
+ unlinkSync(path)
178
+ } catch (error) {
179
+ if (!isCode(error, 'ENOENT')) throw error
180
+ }
181
+ }
182
+
183
+ const wait = (ms: number) =>
184
+ new Promise<void>((resolve) => setTimeout(resolve, ms))
185
+
186
+ /**
187
+ * Waits for the lock with no deadline while its holder lives. A deadline would
188
+ * turn a long queue back into the intermittent failure the lock removes, and a
189
+ * dead holder is taken over on the next poll rather than waited out.
190
+ */
191
+ export async function acquireSuiteLock(
192
+ options: SuiteLockOptions,
193
+ ): Promise<HeldLock> {
194
+ const sleep = options.sleep ?? wait
195
+ const pollMs = options.pollMs ?? 500
196
+ const startedAt = performance.now()
197
+ let waitedOn: LockHolder | undefined
198
+
199
+ for (;;) {
200
+ const outcome = tryAcquire(options)
201
+ if (outcome.acquired) {
202
+ return {
203
+ release: outcome.release,
204
+ waitedOn,
205
+ waitedMs: waitedOn === undefined ? 0 : performance.now() - startedAt,
206
+ }
207
+ }
208
+ if (waitedOn === undefined && outcome.holder !== undefined) {
209
+ options.onWait?.(outcome.holder)
210
+ }
211
+ waitedOn = outcome.holder ?? waitedOn
212
+ await sleep(pollMs)
213
+ }
214
+ }
package/src/init/run.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { logInfo, logStep, logWarn } from '@/ui'
1
+ import { logInfo, logStep, logWarn } from '@/cli/ui'
2
2
 
3
3
  /**
4
4
  * A domain either runs or announces why it did not. Governance under
@@ -9,7 +9,7 @@ import {
9
9
  readItems,
10
10
  writeAnswerLine,
11
11
  } from '@/intake/items'
12
- import { recordDir } from '@/record-root'
12
+ import { recordDir } from '@/roots/record'
13
13
 
14
14
  export const INTAKE_REFUSALS = [
15
15
  'no-intake',
package/src/labels/map.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { resolveExisting } from '@/legacy-path'
3
+ import { resolveExisting } from '@/roots/legacy'
4
4
 
5
5
  /**
6
6
  * Where a project declares its pull request label map.
@@ -1,6 +1,6 @@
1
1
  import { linesOutsideFences, maskCodeSpans } from '@/markdown/scan'
2
- import { RECORD_ENTRIES, RECORD_ROOTS } from '@/record-root'
3
- import type { RecordRoot } from '@/record-root'
2
+ import { RECORD_ENTRIES, RECORD_ROOTS } from '@/roots/record'
3
+ import type { RecordRoot } from '@/roots/record'
4
4
 
5
5
  /**
6
6
  * The two version namespaces `standards/versioning.md` keeps apart, and why a
@@ -114,7 +114,7 @@ const ROOT_READING: Record<RecordRoot, 'whole' | 'entries'> = {
114
114
  * `.claude/` is tracked and holds `rules`, `skills`, `hooks`, and `context`,
115
115
  * so a rule path resolves in any clone and is not a board reference, and only
116
116
  * the entries the record move relocated are unreadable there. Reading the
117
- * roots and the relocated entries from `src/record-root.ts` is what makes a
117
+ * roots and the relocated entries from `src/roots/record.ts` is what makes a
118
118
  * folder added there matched here without an edit, and the list above is what
119
119
  * covers the one thing that module deliberately does not carry.
120
120
  *
@@ -40,7 +40,7 @@ import {
40
40
  slugOf,
41
41
  } from '@/migrate/evidence-ordinal'
42
42
  import { presentFolders } from '@/records/backup'
43
- import { recordDir, SCRATCH, spell, type RecordRoot } from '@/record-root'
43
+ import { recordDir, SCRATCH, spell, type RecordRoot } from '@/roots/record'
44
44
 
45
45
  const CANON: RecordRoot = '.canon'
46
46
  const CLAUDE: RecordRoot = '.claude'
@@ -15,7 +15,7 @@ import {
15
15
  RECORD_ENTRIES,
16
16
  RECORD_ROOTS,
17
17
  spell,
18
- } from '@/record-root'
18
+ } from '@/roots/record'
19
19
 
20
20
  /** The root the entries below leave, exported so the writer can prune it. */
21
21
  export const FROM_ROOT: RecordRoot = '.claude'
@@ -26,7 +26,7 @@ const TO_ROOT: RecordRoot = '.canon'
26
26
  /**
27
27
  * Every entry the move relocates, at the name `.claude/` gave it.
28
28
  *
29
- * The list is `RECORD_ENTRIES`, which `record-root.ts` owns because a seed
29
+ * The list is `RECORD_ENTRIES`, which `roots/record.ts` owns because a seed
30
30
  * destination and a superseded-layout report ask the same question. Restating
31
31
  * it here would let the sweep and the resolver disagree about what moved.
32
32
  */
@@ -76,12 +76,12 @@ function escape(value: string): string {
76
76
  * paths a session actually opened. Rewriting either makes it testify to
77
77
  * something that never happened.
78
78
  *
79
- * This module and `src/record-root.ts` are two of the sources that state the old
80
- * root on purpose. Sweeping them turns every citation this expression is built
81
- * from into its own replacement, leaving a rewriter that maps `.canon/` to
82
- * `.canon/` and matches nothing. `src/surface-root.ts` joins the list a release
83
- * early, for the same reason: it spells `.claude` as data the moment it
84
- * exists, and excluding it later would leave one release where a records
79
+ * This module and `src/roots/record.ts` are two of the sources that state the
80
+ * old root on purpose. Sweeping them turns every citation this expression is
81
+ * built from into its own replacement, leaving a rewriter that maps `.canon/`
82
+ * to `.canon/` and matches nothing. `src/roots/surface.ts` joins the list a
83
+ * release early, for the same reason: it spells `.claude` as data the moment
84
+ * it exists, and excluding it later would leave one release where a records
85
85
  * migration in a target could rewrite the resolver that migration itself
86
86
  * depends on.
87
87
  *
@@ -112,8 +112,8 @@ const EXCLUDED_PREFIXES: readonly string[] = [
112
112
 
113
113
  const EXCLUDED_PATHS: readonly string[] = [
114
114
  'CHANGELOG.md',
115
- 'src/record-root.ts',
116
- 'src/surface-root.ts',
115
+ 'src/roots/record.ts',
116
+ 'src/roots/surface.ts',
117
117
  ]
118
118
 
119
119
  const EXCLUDED_SUFFIXES: readonly string[] = ['.test.ts']
@@ -171,7 +171,7 @@ export const RECORD_ONLY_ROOTS: readonly RecordRoot[] = RECORD_ROOTS.filter(
171
171
  * under `.claude/`, and a target's installed `.claude/rules/canon/core/035-tasks.md`
172
172
  * is the file the sweep exists to repoint, so a bare `.claude/` prefix strands
173
173
  * it silently. The old root is therefore entry-scoped, through `spell` so the
174
- * one naming variant stays decided in `record-root.ts`.
174
+ * one naming variant stays decided in `roots/record.ts`.
175
175
  *
176
176
  * Joined with a literal separator rather than through `join`, the way
177
177
  * `EXCLUDED_PREFIXES` already is. These are matched against what `git ls-files`
@@ -211,7 +211,7 @@ export const AITK_RULES: RenameRules = defineRenameRules({
211
211
  'src/sync/stamp.test.ts',
212
212
  'src/targets/registry.test.ts',
213
213
  'src/targets/sweep.test.ts',
214
- 'src/ui.test.ts',
214
+ 'src/cli/ui.test.ts',
215
215
  ],
216
216
  articleFixups: [{ pattern: AITK_ARTICLE, replacement: '$1$2$3' }],
217
217
  })
@@ -34,7 +34,7 @@ import {
34
34
  slugOf,
35
35
  } from '@/migrate/evidence-ordinal'
36
36
  import { presentFolders } from '@/records/backup'
37
- import { recordDir, SCRATCH } from '@/record-root'
37
+ import { recordDir, SCRATCH } from '@/roots/record'
38
38
 
39
39
  /**
40
40
  * Every folder this promotion moves, at the name it carries under scratch.
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { join } from 'node:path'
12
- import { SURFACE_ENTRIES, spell, type SurfaceRoot } from '@/surface-root'
12
+ import { SURFACE_ENTRIES, spell, type SurfaceRoot } from '@/roots/surface'
13
13
 
14
14
  /** The root the entries below leave. */
15
15
  export const FROM_ROOT: SurfaceRoot = '.claude'
@@ -80,8 +80,8 @@ function escape(value: string): string {
80
80
  *
81
81
  * The changelog is release history, and an eval result is a transcript of
82
82
  * paths a session actually opened; rewriting either makes it testify to
83
- * something that never happened. `src/surface-root.ts` and
84
- * `src/record-root.ts` are the two sources that spell the old root as data on
83
+ * something that never happened. `src/roots/surface.ts` and
84
+ * `src/roots/record.ts` are the two sources that spell the old root as data on
85
85
  * purpose, so sweeping either would turn the very literals this verb reads
86
86
  * into their own replacement. A hook is the third such source, guarding on a
87
87
  * `case` carrying an arm per root, so `.claude/hooks/` and the seed's copy of
@@ -102,8 +102,8 @@ const EXCLUDED_PREFIXES: readonly string[] = [
102
102
 
103
103
  const EXCLUDED_PATHS: readonly string[] = [
104
104
  'CHANGELOG.md',
105
- 'src/surface-root.ts',
106
- 'src/record-root.ts',
105
+ 'src/roots/surface.ts',
106
+ 'src/roots/record.ts',
107
107
  ]
108
108
 
109
109
  export function isExcludedPath(path: string): boolean {
@@ -380,7 +380,7 @@ export interface SurfaceRootsPlan {
380
380
  * moved entry, and whether its content cites one. Either alone is enough to
381
381
  * produce an entry, and a file excluded from the citation rewrite still moves
382
382
  * when its path itself is inside a moved entry, since the two entries excluded
383
- * today, `src/surface-root.ts` and `src/record-root.ts`, both sit outside
383
+ * today, `src/roots/surface.ts` and `src/roots/record.ts`, both sit outside
384
384
  * every moved entry and never take that branch in practice.
385
385
  */
386
386
  export function planSurfaceRootsMove(