@erclx/canon 5.32.0 → 5.33.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 (210) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +1 -1
  3. package/claude/skills/auto-ship/references/worktree-name.md +1 -1
  4. package/claude/skills/backlog-triage/REQUIREMENT.md +2 -2
  5. package/claude/skills/backlog-triage/SKILL.md +1 -1
  6. package/claude/skills/canon-cli/REQUIREMENT.md +16 -13
  7. package/claude/skills/canon-cli/SKILL.md +75 -32
  8. package/claude/skills/canon-feedback/SKILL.md +1 -1
  9. package/claude/skills/{canvas → canvas-board}/REQUIREMENT.md +2 -2
  10. package/claude/skills/{canvas → canvas-board}/SKILL.md +2 -2
  11. package/{standards → claude/skills/canvas-board/references}/design.md +11 -2
  12. package/claude/skills/context-fold/REQUIREMENT.md +25 -33
  13. package/claude/skills/context-fold/SKILL.md +25 -70
  14. package/claude/skills/context-fold/references/classify.md +3 -3
  15. package/claude/skills/context-fold/references/completion-report.md +4 -4
  16. package/claude/skills/context-fold/references/context-refresh.md +5 -5
  17. package/{standards → claude/skills/context-fold/references}/context.md +16 -5
  18. package/claude/skills/context-fold/references/unusable-baseline.md +3 -5
  19. package/claude/skills/create-rule/SKILL.md +2 -2
  20. package/{standards → claude/skills/create-rule/references}/rule.md +12 -5
  21. package/claude/skills/create-skill/SKILL.md +2 -2
  22. package/{standards → claude/skills/create-skill/references}/skill.md +17 -14
  23. package/claude/skills/design-taste/REQUIREMENT.md +3 -3
  24. package/claude/skills/design-taste/SKILL.md +1 -1
  25. package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
  26. package/claude/skills/docs-sync/SKILL.md +3 -3
  27. package/{standards → claude/skills/docs-sync/references}/docs.md +11 -0
  28. package/{standards → claude/skills/docs-sync/references}/readme.md +9 -2
  29. package/claude/skills/draft-and-pick/SKILL.md +5 -5
  30. package/claude/skills/draft-doc/SKILL.md +6 -6
  31. package/claude/skills/draft-figure/REQUIREMENT.md +2 -2
  32. package/claude/skills/draft-figure/SKILL.md +2 -2
  33. package/claude/skills/draft-figure/references/hand-drawn.md +1 -1
  34. package/claude/skills/draft-figure/references/mermaid-path.md +1 -1
  35. package/claude/skills/draft-identity/REQUIREMENT.md +2 -2
  36. package/claude/skills/draft-identity/SKILL.md +2 -2
  37. package/claude/skills/draft-ready/SKILL.md +1 -1
  38. package/claude/skills/git-branch/SKILL.md +1 -1
  39. package/{standards → claude/skills/git-branch/references}/branch.md +1 -1
  40. package/claude/skills/git-commit/SKILL.md +2 -2
  41. package/claude/skills/git-followup/REQUIREMENT.md +1 -1
  42. package/claude/skills/git-followup/SKILL.md +1 -1
  43. package/claude/skills/git-issue/SKILL.md +1 -1
  44. package/claude/skills/git-pr/SKILL.md +13 -13
  45. package/claude/skills/git-pr/references/evidence.md +2 -2
  46. package/claude/skills/git-pr/references/final-command.md +38 -14
  47. package/claude/skills/git-pr/references/labels.md +5 -1
  48. package/{standards → claude/skills/git-pr/references}/pr.md +19 -9
  49. package/{standards → claude/skills/git-pr/references}/publish.md +4 -2
  50. package/claude/skills/git-pr/references/task-number.md +1 -1
  51. package/claude/skills/git-ship/SKILL.md +15 -14
  52. package/claude/skills/git-split/SKILL.md +3 -3
  53. package/claude/skills/git-stage/SKILL.md +1 -1
  54. package/claude/skills/plan-feature/REQUIREMENT.md +1 -1
  55. package/claude/skills/review-address/SKILL.md +1 -1
  56. package/claude/skills/review-address/references/post-review-findings.md +1 -1
  57. package/claude/skills/review-pr/REQUIREMENT.md +1 -1
  58. package/claude/skills/review-pr/SKILL.md +3 -3
  59. package/claude/skills/review-pr/references/body-variants.md +2 -2
  60. package/claude/skills/review-pr/references/key-changes.md +3 -3
  61. package/claude/skills/review-pr/references/post.md +4 -2
  62. package/claude/skills/review-ui/REQUIREMENT.md +1 -1
  63. package/claude/skills/review-ui/SKILL.md +1 -1
  64. package/claude/skills/review-ui/references/post.md +1 -1
  65. package/claude/skills/role-orchestrator/REQUIREMENT.md +3 -0
  66. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  67. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +2 -2
  68. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +8 -5
  69. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +23 -1
  70. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +2 -2
  71. package/claude/skills/role-orchestrator/references/orchestrator-resume.md +3 -0
  72. package/claude/skills/role-planner/REQUIREMENT.md +2 -1
  73. package/claude/skills/role-planner/SKILL.md +13 -2
  74. package/claude/skills/role-reviewer/SKILL.md +1 -1
  75. package/claude/skills/role-worker/REQUIREMENT.md +1 -0
  76. package/claude/skills/role-worker/SKILL.md +9 -1
  77. package/claude/skills/session-map/SKILL.md +1 -1
  78. package/claude/skills/session-resume/REQUIREMENT.md +2 -2
  79. package/claude/skills/session-worktree/SKILL.md +5 -5
  80. package/claude/skills/target-setup/references/detect.md +1 -1
  81. package/claude/skills/task-board/REQUIREMENT.md +7 -12
  82. package/claude/skills/task-board/SKILL.md +5 -17
  83. package/claude/skills/task-board/references/tasks.md +2 -2
  84. package/claude/skills/task-complete/REQUIREMENT.md +47 -0
  85. package/claude/skills/task-complete/SKILL.md +86 -0
  86. package/claude/skills/{task-board → task-complete}/references/archive.md +4 -4
  87. package/claude/skills/{context-fold → task-complete}/references/receipt-sweep.md +5 -5
  88. package/claude/skills/task-complete/references/unusable-baseline.md +19 -0
  89. package/claude/skills/teach-workspace/REQUIREMENT.md +6 -2
  90. package/claude/skills/teach-workspace/SKILL.md +8 -4
  91. package/claude/skills/teach-workspace/references/lesson-craft.md +1 -1
  92. package/claude/skills/{context-fold/references/promoted-pages.md → teach-workspace/references/promotion-fold.md} +3 -3
  93. package/claude/skills/teach-workspace/references/promotion.md +3 -3
  94. package/claude/skills/ui-checklist/SKILL.md +1 -1
  95. package/claude/skills/ux-audit/SKILL.md +1 -1
  96. package/claude/skills/ux-measure/SKILL.md +1 -1
  97. package/docs/agents/canvas.md +1 -1
  98. package/docs/agents/commands.md +3 -2
  99. package/docs/agents/context-audit-checks.md +4 -4
  100. package/docs/agents/index.md +3 -3
  101. package/docs/agents/indexes.md +1 -1
  102. package/docs/agents/install-and-sync.md +6 -11
  103. package/docs/agents/key-changes.md +5 -5
  104. package/docs/agents/label-coverage.md +11 -0
  105. package/docs/agents/migrate.md +1 -1
  106. package/docs/agents/records-push.md +2 -2
  107. package/docs/agents/records-stale.md +12 -47
  108. package/docs/agents/records-validate.md +3 -7
  109. package/docs/agents/records.md +5 -27
  110. package/docs/agents/rule-citations.md +1 -1
  111. package/docs/agents/sandbox.md +4 -4
  112. package/docs/agents/skills-audit.md +1 -1
  113. package/docs/agents/tasks-plans.md +2 -2
  114. package/docs/agents/tasks.md +2 -2
  115. package/docs/target/migrations.md +10 -2
  116. package/docs/target/projects.md +1 -1
  117. package/docs/target/sync.md +1 -1
  118. package/docs/workflow/operating-model.md +2 -2
  119. package/docs/workflow/parallel-features.md +7 -7
  120. package/docs/workflow/skill-map-on-request.md +9 -9
  121. package/docs/workflow/skill-map.md +12 -11
  122. package/docs/workflow/visual-design-workflow.md +2 -2
  123. package/governance/renames.toml +0 -11
  124. package/governance/rules/canon/context.md +13 -0
  125. package/governance/rules/claude/skill.md +17 -0
  126. package/governance/stacks/base.toml +2 -4
  127. package/package.json +4 -4
  128. package/scripts/core/install-check.sh +3 -3
  129. package/scripts/lib/ui.sh +0 -159
  130. package/src/audits/catalog.ts +0 -1
  131. package/src/claude/cases/misc.ts +1 -1
  132. package/src/claude/cases/setup.ts +4 -0
  133. package/src/claude/cases/workflow.ts +6 -1
  134. package/src/claude/seeds.ts +1 -1
  135. package/src/claude/skills-audit.ts +2 -2
  136. package/src/cli/help.ts +3 -1
  137. package/src/cli/ui.ts +2 -3
  138. package/src/commands/claude.ts +1 -1
  139. package/src/commands/gov.ts +2 -15
  140. package/src/commands/labels.ts +326 -7
  141. package/src/commands/migrate.ts +6 -1
  142. package/src/commands/pr.ts +1 -1
  143. package/src/commands/records.ts +13 -399
  144. package/src/commands/sandbox.ts +3 -6
  145. package/src/commands/tasks.ts +3 -3
  146. package/src/context/audit.ts +5 -5
  147. package/src/context/folders.ts +1 -1
  148. package/src/design/budget.ts +1 -1
  149. package/src/design/components.ts +1 -1
  150. package/src/design/document.ts +1 -1
  151. package/src/design/regen.ts +1 -1
  152. package/src/design/render.ts +1 -1
  153. package/src/gate/measures.ts +1 -1
  154. package/src/gate/sequencer.ts +56 -1
  155. package/src/gate/stages.ts +1 -7
  156. package/src/gov/install.ts +2 -3
  157. package/src/gov/stacks.ts +1 -1
  158. package/src/hooks/post-merge.ts +1 -1
  159. package/src/labels/apply.ts +39 -0
  160. package/src/labels/format.ts +3 -3
  161. package/src/labels/phase.ts +3 -3
  162. package/src/labels/sections.ts +43 -0
  163. package/src/migrate/skill-names.ts +20 -1
  164. package/src/pr/paths.ts +4 -4
  165. package/src/records/backup.ts +16 -6
  166. package/src/records/stale-canonical.ts +31 -1
  167. package/src/records/validate.ts +2 -188
  168. package/src/sandbox/census.ts +1 -1
  169. package/src/sandbox/coverage.ts +2 -6
  170. package/src/sandbox/equivalence.ts +5 -44
  171. package/src/sandbox/expect.ts +4 -4
  172. package/src/sandbox/provision.ts +13 -36
  173. package/src/sandbox/scenario.ts +26 -48
  174. package/src/sandbox/tree.ts +2 -6
  175. package/src/standards/read.ts +14 -1
  176. package/src/sync/git.ts +1 -1
  177. package/src/tasks/archive.ts +5 -5
  178. package/src/tasks/branch.ts +3 -3
  179. package/src/tasks/label.ts +1 -1
  180. package/src/tasks/record.ts +2 -2
  181. package/src/tasks/validate.ts +1 -1
  182. package/standards/index.md +0 -13
  183. package/standards/standard.md +1 -7
  184. package/tooling/base/reference.md +1 -1
  185. package/tooling/base/seeds/.github/pull_request_template.md +2 -2
  186. package/tooling/claude/reference.md +1 -1
  187. package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +9 -1
  188. package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +171 -0
  189. package/tooling/claude/seeds/.claude/settings.json +9 -0
  190. package/governance/rules/standards/context.md +0 -14
  191. package/governance/rules/standards/design.md +0 -12
  192. package/governance/rules/standards/readme.md +0 -12
  193. package/governance/rules/standards/rule.md +0 -13
  194. package/governance/rules/standards/skill.md +0 -21
  195. package/governance/rules/standards/standard.md +0 -13
  196. package/scripts/config.sh +0 -11
  197. package/scripts/lib/gov.sh +0 -14
  198. package/scripts/lib/sandbox-fixtures.sh +0 -191
  199. package/scripts/lib/sandbox-git.sh +0 -125
  200. package/scripts/lib/sandbox-path.sh +0 -50
  201. package/scripts/sandbox-hook.sh +0 -99
  202. package/src/gov/standard-rules.ts +0 -179
  203. package/src/records/migrate.ts +0 -78
  204. package/src/records/stale.ts +0 -243
  205. package/src/sandbox/hooks.ts +0 -128
  206. /package/claude/skills/{canvas → canvas-board}/references/canvas.md +0 -0
  207. /package/{standards → claude/skills/draft-figure/references}/figures.md +0 -0
  208. /package/{standards → claude/skills/git-branch/references}/slug.md +0 -0
  209. /package/{standards → claude/skills/git-commit/references}/commit.md +0 -0
  210. /package/{standards → claude/skills/git-commit/references}/versioning.md +0 -0
@@ -22,7 +22,6 @@ export const RECORD_KINDS = [
22
22
  'plans',
23
23
  'groundwork',
24
24
  'intake',
25
- 'memory',
26
25
  'standards',
27
26
  'teach',
28
27
  ] as const
@@ -60,7 +59,6 @@ export type ValidateRefusal = (typeof VALIDATE_REFUSALS)[number]
60
59
  export const FINDING_KINDS = [
61
60
  'name-malformed',
62
61
  'title-missing',
63
- 'title-is-slug',
64
62
  'section-missing',
65
63
  'scope-unanchored',
66
64
  'entry-unreasoned',
@@ -72,7 +70,6 @@ export const FINDING_KINDS = [
72
70
  'state-missing',
73
71
  'closing-partial',
74
72
  'item-incomplete',
75
- 'category-mismatch',
76
73
  'operator-call-phrasing',
77
74
  'batch-unsplit',
78
75
  'source-unquoted',
@@ -81,22 +78,12 @@ export const FINDING_KINDS = [
81
78
 
82
79
  export type FindingKind = (typeof FINDING_KINDS)[number]
83
80
 
84
- /**
85
- * The transforms `migrate.ts` carries. Most finding kinds have none, since a
86
- * transform is only safe where the old shape is recoverable from the file
87
- * itself, so this stays optional on `Finding` rather than required.
88
- */
89
- export const FINDING_REMEDIES = ['category-from-name'] as const
90
-
91
- export type FindingRemedy = (typeof FINDING_REMEDIES)[number]
92
-
93
81
  export interface Finding {
94
82
  readonly kind: FindingKind
95
83
  /** The record the finding sits in, relative to the validated folder. */
96
84
  readonly record: string
97
85
  readonly subject: string
98
86
  readonly message: string
99
- readonly remedy?: FindingRemedy
100
87
  }
101
88
 
102
89
  export interface ValidateReport {
@@ -214,9 +201,8 @@ function finding(
214
201
  record: string,
215
202
  subject: string,
216
203
  message: string,
217
- remedy?: FindingRemedy,
218
204
  ): Finding {
219
- return { kind, record, subject, message, remedy }
205
+ return { kind, record, subject, message }
220
206
  }
221
207
 
222
208
  async function listMarkdown(dir: string): Promise<string[]> {
@@ -934,169 +920,6 @@ async function checkWorkspace(dir: string, slug: string): Promise<Finding[]> {
934
920
  ]
935
921
  }
936
922
 
937
- const MEMORY_INDEX = 'index.md'
938
- const MEMORY_FIELDS = ['title', 'description', 'category'] as const
939
-
940
- /**
941
- * The filename prefix and the `category` field are one fact in two spellings,
942
- * so the map is the whole type list and the comparison against it is what
943
- * catches a prefix outside the set, a field disagreeing with the prefix, and a
944
- * casing drift that would open a second group in the catalog.
945
- */
946
- export const CATEGORY_BY_TYPE = {
947
- feedback: 'Feedback',
948
- project: 'Project',
949
- user: 'User',
950
- reference: 'Reference',
951
- } as const
952
-
953
- export type MemoryType = keyof typeof CATEGORY_BY_TYPE
954
-
955
- const MEMORY_TYPES = Object.keys(CATEGORY_BY_TYPE) as readonly MemoryType[]
956
-
957
- export const MEMORY_NAME = /^([a-z]+)-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/
958
-
959
- /** The two markers a rule-bearing body carries, on top of the rule line itself. */
960
- const MEMORY_MARKERS = ['**Why:**', '**How to apply:**'] as const
961
-
962
- export function memoryType(value: string): MemoryType | undefined {
963
- return MEMORY_TYPES.find((type) => type === value)
964
- }
965
-
966
- /**
967
- * The one missing-field shape `migrate.ts` can repair: `category` alone,
968
- * recoverable from the same filename prefix `checkMemory` already read it
969
- * from. `title` and `description` are prose nobody wrote down, so a finding
970
- * naming either carries no remedy.
971
- */
972
- function memoryRemedy(
973
- missing: readonly string[],
974
- named: MemoryType | undefined,
975
- ): FindingRemedy | undefined {
976
- return named && missing.length === 1 && missing[0] === 'category'
977
- ? 'category-from-name'
978
- : undefined
979
- }
980
-
981
- export function checkMemory(name: string, text: string): Finding[] {
982
- const findings: Finding[] = []
983
- const match = MEMORY_NAME.exec(name)
984
- const named = match ? memoryType(match[1]) : undefined
985
-
986
- if (!named) {
987
- findings.push(
988
- finding(
989
- 'name-malformed',
990
- name,
991
- name,
992
- `is not named <type>-<slug>.md with a type of ${MEMORY_TYPES.join(', ')}.`,
993
- ),
994
- )
995
- }
996
-
997
- const frontmatter = parseFrontmatter(text)
998
- const missing = MEMORY_FIELDS.filter(
999
- (field) => !readField(frontmatter, field),
1000
- )
1001
-
1002
- if (missing.length > 0) {
1003
- findings.push(
1004
- finding(
1005
- 'frontmatter-incomplete',
1006
- name,
1007
- name,
1008
- `carries no ${missing.join(' and no ')}.`,
1009
- memoryRemedy(missing, named),
1010
- ),
1011
- )
1012
- }
1013
-
1014
- // Its own kind rather than `title-missing`, which means an absent heading on a
1015
- // plan. One kind covering both leaves a caller filtering the JSON unable to
1016
- // tell a record with no title from one whose title is its own slug.
1017
- if (readField(frontmatter, 'title') === name.replace(/\.md$/, '')) {
1018
- findings.push(
1019
- finding(
1020
- 'title-is-slug',
1021
- name,
1022
- name,
1023
- 'is titled with its own filename, so the catalog renders a slug where the rule belongs.',
1024
- ),
1025
- )
1026
- }
1027
-
1028
- const category = readField(frontmatter, 'category')
1029
-
1030
- // Reported against the prefix alone. A name the prefix rule already failed
1031
- // has no type to compare against, and reporting it twice names one defect as
1032
- // two.
1033
- if (named && category && category !== CATEGORY_BY_TYPE[named]) {
1034
- findings.push(
1035
- finding(
1036
- 'category-mismatch',
1037
- name,
1038
- category,
1039
- `is not ${CATEGORY_BY_TYPE[named]}, which the filename prefix declares.`,
1040
- ),
1041
- )
1042
- }
1043
-
1044
- return [
1045
- ...findings,
1046
- ...checkMemoryBody(
1047
- name,
1048
- text.slice(frontmatter?.raw.length ?? 0),
1049
- named ?? category,
1050
- ),
1051
- ]
1052
- }
1053
-
1054
- /**
1055
- * A `user` or `reference` entry is a single sentence by design, so the markers
1056
- * are checked only where a rule is being stated. The type is read off the
1057
- * prefix, falling back to the category so a misnamed file is still checked
1058
- * against the shape it claims.
1059
- */
1060
- function checkMemoryBody(
1061
- name: string,
1062
- text: string,
1063
- claimed: string | undefined,
1064
- ): Finding[] {
1065
- const type = claimed && memoryType(claimed.toLowerCase())
1066
- if (type !== 'feedback' && type !== 'project') return []
1067
-
1068
- const body = linesOutsideFences(text).filter((line) => line.trim().length > 0)
1069
-
1070
- const findings: Finding[] = []
1071
- const opening = body[0]
1072
-
1073
- if (!opening || MEMORY_MARKERS.some((marker) => opening.startsWith(marker))) {
1074
- findings.push(
1075
- finding(
1076
- 'section-missing',
1077
- name,
1078
- 'the rule line',
1079
- 'is absent, so the entry carries a rationale with no rule to apply.',
1080
- ),
1081
- )
1082
- }
1083
-
1084
- for (const marker of MEMORY_MARKERS) {
1085
- if (!body.some((line) => line.startsWith(marker))) {
1086
- findings.push(
1087
- finding(
1088
- 'section-missing',
1089
- name,
1090
- marker,
1091
- `is required on a ${type} entry and the body carries no such line.`,
1092
- ),
1093
- )
1094
- }
1095
- }
1096
-
1097
- return findings
1098
- }
1099
-
1100
923
  const STANDARD_INDEX = 'index.md'
1101
924
  const STANDARD_FIELDS = ['title', 'description'] as const
1102
925
 
@@ -1225,7 +1048,7 @@ export function checkStandard(name: string, text: string): Finding[] {
1225
1048
  */
1226
1049
  const FOLDER_CHECK: Readonly<
1227
1050
  Record<
1228
- Exclude<RecordKind, 'plans' | 'memory' | 'standards'>,
1051
+ Exclude<RecordKind, 'plans' | 'standards'>,
1229
1052
  (dir: string, slug: string) => Promise<Finding[]>
1230
1053
  >
1231
1054
  > = {
@@ -1279,15 +1102,6 @@ export async function validateRecords(
1279
1102
 
1280
1103
  if (kind === 'plans') return validateFiles(dir, kind, checkPlan)
1281
1104
 
1282
- if (kind === 'memory') {
1283
- return validateFiles(
1284
- dir,
1285
- kind,
1286
- checkMemory,
1287
- (file) => file === MEMORY_INDEX,
1288
- )
1289
- }
1290
-
1291
1105
  // The walk stays flat, matching install and the catalog.
1292
1106
  if (kind === 'standards') {
1293
1107
  return validateFiles(
@@ -71,7 +71,7 @@ export function listSkills(root: string): string[] {
71
71
  *
72
72
  * `<category>-<command>` is the rule `canon/context/sandbox/coverage/census.md` states, and it
73
73
  * alone pairs 29 of 54 skills. The bare `<command>` fallback is what reaches the
74
- * rest: `claude/target-setup.sh` drives the `target-setup` skill, not a `claude-target-setup` that
74
+ * rest: `claude/target-setup.ts` drives the `target-setup` skill, not a `claude-target-setup` that
75
75
  * does not exist. Stating one spelling and shipping two is what let the audit
76
76
  * report a paired skill as unpaired.
77
77
  *
@@ -44,13 +44,9 @@ function listScenarios(sandboxDir: string): ScenarioCoverage[] {
44
44
  const scenarios: ScenarioCoverage[] = []
45
45
 
46
46
  for (const category of listCategories(sandboxDir)) {
47
- // A stem in both forms counts once, and provisioning refuses it by name.
48
- const { scenarios: files, ambiguous } = listScenarioFiles(
49
- join(sandboxDir, category),
50
- )
51
- const commands = [...files.map((f) => f.command), ...ambiguous].sort()
47
+ const { scenarios: files } = listScenarioFiles(join(sandboxDir, category))
52
48
 
53
- for (const command of commands) {
49
+ for (const { command } of files) {
54
50
  scenarios.push({ category, command, armed: [] })
55
51
  }
56
52
  }
@@ -22,7 +22,6 @@ import {
22
22
  listCategories,
23
23
  listScenarioFiles,
24
24
  loadScenario,
25
- type ScenarioKind,
26
25
  } from '@/sandbox/scenario'
27
26
  import { createStubRemote, readStubManifest } from '@/sandbox/stub-remote'
28
27
 
@@ -153,28 +152,6 @@ export function armKey(arm: Arm): string {
153
152
  return arm.arm === undefined ? scenario : `${scenario}/${arm.arm}`
154
153
  }
155
154
 
156
- /**
157
- * Reads the quoted arguments of `select_or_route_scenario`, joining a call that
158
- * continues across backslash-newline pairs. The first argument is the prompt and
159
- * the rest are arm names. A call this cannot see returns no arms, which the
160
- * coverage cross-check in `enumerateArms` turns into an error rather than a
161
- * smaller list.
162
- */
163
- export function parseArmNames(source: string): string[] {
164
- const start = source.search(/select_or_route_scenario\b/)
165
- if (start === -1) return []
166
-
167
- const call = source
168
- .slice(start)
169
- .replace(/\\\n/g, ' ')
170
- .split('\n')[0]
171
- ?.replace(/^select_or_route_scenario/, '')
172
-
173
- return [...(call ?? '').matchAll(/"([^"]*)"/g)]
174
- .map((match) => match[1] ?? '')
175
- .slice(1)
176
- }
177
-
178
155
  function matchesTarget(
179
156
  category: string,
180
157
  command: string,
@@ -199,25 +176,14 @@ function matchesArm(arm: Arm, targets: readonly string[]): boolean {
199
176
  }
200
177
 
201
178
  /**
202
- * Reads a scenario's arm names and anchor flag: off a bash file's source, and
203
- * off a TypeScript module's own keys, which is what its stage routes on.
179
+ * Reads a scenario's arm names and anchor flag off its module's own keys, which
180
+ * is what its stage routes on.
204
181
  */
205
182
  function readArms(
206
183
  file: string,
207
- kind: ScenarioKind,
208
184
  ):
209
185
  | { ok: true; anchor: boolean; names: string[] }
210
186
  | { ok: false; error: string } {
211
- if (kind === 'sh') {
212
- const source = readFileSync(file, 'utf8')
213
-
214
- return {
215
- ok: true,
216
- anchor: /^use_anchor\s*\(\)/m.test(source),
217
- names: parseArmNames(source),
218
- }
219
- }
220
-
221
187
  try {
222
188
  const definition = loadScenario(file)
223
189
 
@@ -250,17 +216,12 @@ export function enumerateArms(
250
216
  const coverage = collectCoverage(root)
251
217
 
252
218
  for (const category of listCategories(sandboxDir)) {
253
- const { scenarios, ambiguous } = listScenarioFiles(
254
- join(sandboxDir, category),
255
- )
256
- for (const command of ambiguous)
257
- if (matchesTarget(category, command, targets))
258
- errors.push(`${category}:${command} exists as both .sh and .ts`)
219
+ const { scenarios } = listScenarioFiles(join(sandboxDir, category))
259
220
 
260
- for (const { command, file, kind } of scenarios) {
221
+ for (const { command, file } of scenarios) {
261
222
  if (!matchesTarget(category, command, targets)) continue
262
223
 
263
- const declared = readArms(file, kind)
224
+ const declared = readArms(file)
264
225
  if (!declared.ok) {
265
226
  errors.push(`${category}:${command} did not load: ${declared.error}`)
266
227
  continue
@@ -354,7 +354,7 @@ function checkWriteScope(
354
354
  // none, and without this the declaration vanishes from the verdict entirely:
355
355
  // no result, no skipped entry, and no contribution to the unchecked count that
356
356
  // exists to surface exactly this. The `undefined` branch above cannot stand in,
357
- // since `run.sh` always passes `--writes` and `readPathList` returns `[]` for an
357
+ // since `canon sandbox run` always passes `--writes` and `readPathList` returns `[]` for an
358
358
  // empty file. An arm whose output escaped the snapshot reads as a clean run,
359
359
  // which is the vacuous pass the harness exists to remove.
360
360
  if (writes.length === 0) {
@@ -377,7 +377,7 @@ function checkWriteScope(
377
377
  }
378
378
 
379
379
  /**
380
- * `run.sh` watches two toolkit roots for a write to shared session scratch
380
+ * `canon sandbox run` watches two toolkit roots for a write to shared session scratch
381
381
  * during a run, and reports every one it finds as an unattributed escape with
382
382
  * no arm able to fail on it. An arm whose skill legitimately reaches outside
383
383
  * the sandbox tree declares the destinations here, and the harness asserts
@@ -388,8 +388,8 @@ function checkWriteScope(
388
388
  * nothing skips rather than passing on a fabricated zero. An escape-scope
389
389
  * declaration exists to bound a side effect nothing requires, so zero escapes
390
390
  * is the outcome a correct run produces and reports as a pass outright,
391
- * provided a watched root held something to watch. `run.sh`'s `snapshot_root`
392
- * returns an empty manifest both when a watch ran clean and when none of the
391
+ * provided a watched root held something to watch. `snapshotRoot` in
392
+ * `src/sandbox/headless/watch.ts` returns an empty manifest both when a watch ran clean and when none of the
393
393
  * four watched directories existed under a root, and the two produce the same
394
394
  * empty `escapes` list. `watched` is what tells them apart: a run that had
395
395
  * nothing to watch reports unmeasured rather than passing on a diff it never
@@ -8,7 +8,6 @@ import {
8
8
  writeFileSync,
9
9
  } from 'node:fs'
10
10
  import { basename, dirname, join, relative } from 'node:path'
11
- import { probeScenario, stageScenario } from '@/sandbox/hooks'
12
11
  import {
13
12
  FIXTURE_SUFFIX,
14
13
  FIXTURES_DIR,
@@ -34,9 +33,8 @@ import {
34
33
 
35
34
  /**
36
35
  * Provisions one scenario into the sandbox tree, and resets or cleans that tree.
37
- * A bash scenario's hooks run through `sandbox-hook.sh` and a TypeScript one's
38
- * arm runs in process through `scenario.ts`, and everything between them runs
39
- * here, in the order the retired bash dispatcher held.
36
+ * A scenario's arm runs in process through `scenario.ts`, and everything
37
+ * around it runs here, in the order the retired bash dispatcher held.
40
38
  *
41
39
  * Narration is part of the contract. `canon sandbox equivalence` compares the
42
40
  * log line for line against the bash harness, so every message, its frame
@@ -403,36 +401,22 @@ interface Scenario {
403
401
 
404
402
  /**
405
403
  * Reads what a scenario declares ahead of provisioning: its config exports and
406
- * whether it stages from the anchor. A bash scenario answers through the probe,
407
- * and a TypeScript one from its module, which is also what its stage runs.
404
+ * whether it stages from the anchor, from its module, which is also what its
405
+ * stage runs.
408
406
  */
409
407
  function declareScenario(
410
408
  root: string,
411
409
  scenario: Scenario,
412
410
  env: NodeJS.ProcessEnv,
413
- ):
414
- | { kind: 'sh'; file: string; isAnchor: boolean }
415
- | { kind: 'ts'; definition: ScenarioDefinition; isAnchor: boolean } {
411
+ ): { definition: ScenarioDefinition; isAnchor: boolean } {
416
412
  const name = `${scenario.category}/${scenario.command}`
417
413
  const resolved = resolveScenarioFile(
418
414
  join(root, 'sandbox'),
419
415
  scenario.category,
420
416
  scenario.command,
421
417
  )
422
- if (!resolved.ok && resolved.reason === 'ambiguous')
423
- fail(
424
- `Sandbox script ${name} exists as both .sh and .ts. Delete the one the port replaced.`,
425
- )
426
418
  if (!resolved.ok) fail(`Sandbox script not found: ${name}`)
427
419
 
428
- if (resolved.kind === 'sh') {
429
- const probe = probeScenario(resolved.file, env)
430
- if (!probe.ok) throw new Halt(probe.status)
431
- Object.assign(env, probe.exports)
432
-
433
- return { kind: 'sh', file: resolved.file, isAnchor: probe.isAnchor }
434
- }
435
-
436
420
  let definition: ScenarioDefinition
437
421
  try {
438
422
  definition = loadScenario(resolved.file)
@@ -443,7 +427,7 @@ function declareScenario(
443
427
  }
444
428
  Object.assign(env, scenarioExports(definition, root, env))
445
429
 
446
- return { kind: 'ts', definition, isAnchor: definition.anchor === true }
430
+ return { definition, isAnchor: definition.anchor === true }
447
431
  }
448
432
 
449
433
  async function provisionScenario(
@@ -481,21 +465,14 @@ async function provisionScenario(
481
465
  // A scenario that execs a verb as its last step hands the run to that verb,
482
466
  // whose own frame is the last one written, so nothing follows it here. An
483
467
  // in-process arm writes its exports straight into `env`.
484
- const staged =
485
- declared.kind === 'sh'
486
- ? stageScenario(declared.file, env)
487
- : {
488
- ...(await stageScenarioInProcess(declared.definition, {
489
- root,
490
- dir: sandbox,
491
- env,
492
- arm: scenario.arm,
493
- })),
494
- exports: {},
495
- }
468
+ const staged = await stageScenarioInProcess(declared.definition, {
469
+ root,
470
+ dir: sandbox,
471
+ env,
472
+ arm: scenario.arm,
473
+ })
496
474
  if (staged.ending === 'replaced') throw new Halt(staged.status, false)
497
475
  if (staged.ending === 'exited') throw new Halt(staged.status)
498
- Object.assign(env, staged.exports)
499
476
 
500
477
  const isSkipAutoCommit = (env.SANDBOX_SKIP_AUTO_COMMIT ?? '') !== ''
501
478
  const isSkipInject = (env.SANDBOX_SKIP_SKILL_INJECT ?? '') !== ''
@@ -602,7 +579,7 @@ function parseTarget(target: string): { category: string; command: string } {
602
579
  }
603
580
 
604
581
  export interface SandboxRequest {
605
- /** False where a caller already opened the frame, as `run.sh` and the picker do. */
582
+ /** False where a caller already opened the frame, as `canon sandbox run` and the picker do. */
606
583
  readonly isHeader: boolean
607
584
  /** `<category>:<command>`, `reset`, or `clean`. */
608
585
  readonly target: string
@@ -42,15 +42,13 @@ export const FIXTURE_SUFFIX = '.fixture'
42
42
  /** A scenario whose only arm carries this name routes none, as a bash file with no picker did. */
43
43
  export const DEFAULT_ARM_NAME = 'default'
44
44
 
45
- /** What a bash scenario passed to `select_or_route_scenario` unless it named another. */
45
+ /** The picker's message unless a scenario names another. */
46
46
  const ROUTE_PROMPT = 'Which scenario?'
47
47
 
48
48
  /** Every scenario that sets the anchor pushes to this one throwaway repository. */
49
49
  export const SANDBOX_ANCHOR_REPO = 'canon-sandbox'
50
50
 
51
- const EXTENSIONS = ['.ts', '.sh'] as const
52
-
53
- export type ScenarioKind = 'ts' | 'sh'
51
+ const EXTENSION = '.ts'
54
52
 
55
53
  export type ArmSetup = (ctx: StageContext) => void | Promise<void>
56
54
 
@@ -60,12 +58,12 @@ export interface ScenarioDefinition {
60
58
  /** Stages the tree from the anchor fixture and names the throwaway remote. */
61
59
  readonly anchor?: boolean
62
60
  /**
63
- * Stages and narrates what the bash form did ahead of `select_or_route_scenario`,
64
- * so the picker's echo lands after it on the frame. Runs before the arm is
61
+ * Stages and narrates ahead of the picker, so the picker's echo lands after
62
+ * it on the frame. Runs before the arm is
65
63
  * routed, which leaves `ctx.arm` undefined.
66
64
  */
67
65
  readonly prepare?: ArmSetup
68
- /** The picker's message, for a bash scenario that passed something other than the default. */
66
+ /** The picker's message, for a scenario that asks something other than the default. */
69
67
  readonly prompt?: string
70
68
  /** In routing order, so the first arm is the one a headless caller gets. */
71
69
  readonly arms: Readonly<Record<string, ArmSetup>>
@@ -78,43 +76,27 @@ export function scenario(definition: ScenarioDefinition): ScenarioDefinition {
78
76
  export interface ScenarioFile {
79
77
  readonly command: string
80
78
  readonly file: string
81
- readonly kind: ScenarioKind
82
79
  }
83
80
 
84
81
  /**
85
- * Lists the scenarios in one category folder, one per stem. A stem present in
86
- * both forms is reported apart rather than listed twice, since picking one
87
- * without saying so is the failure the refusal exists to prevent.
82
+ * Lists the scenarios in one category folder, one per `.ts` stem. A file in any
83
+ * other form is ignored rather than refused, so a hand-written leftover does not
84
+ * break the listing.
88
85
  */
89
86
  export function listScenarioFiles(categoryDir: string): {
90
87
  scenarios: ScenarioFile[]
91
- ambiguous: string[]
92
88
  } {
93
- const byStem = new Map<string, ScenarioFile[]>()
89
+ const scenarios: ScenarioFile[] = []
94
90
  for (const entry of readdirSync(categoryDir, { withFileTypes: true })) {
95
- if (!entry.isFile()) continue
96
- const kind = EXTENSIONS.find((ext) => entry.name.endsWith(ext))
97
- if (kind === undefined) continue
98
- const command = entry.name.slice(0, -kind.length)
99
- const found = byStem.get(command) ?? []
100
- found.push({
101
- command,
91
+ if (!entry.isFile() || !entry.name.endsWith(EXTENSION)) continue
92
+ scenarios.push({
93
+ command: entry.name.slice(0, -EXTENSION.length),
102
94
  file: join(categoryDir, entry.name),
103
- kind: kind === '.ts' ? 'ts' : 'sh',
104
95
  })
105
- byStem.set(command, found)
106
96
  }
97
+ scenarios.sort((a, b) => a.command.localeCompare(b.command))
107
98
 
108
- const scenarios: ScenarioFile[] = []
109
- const ambiguous: string[] = []
110
- for (const [command, found] of [...byStem].sort(([a], [b]) =>
111
- a.localeCompare(b),
112
- )) {
113
- if (found.length > 1) ambiguous.push(command)
114
- else if (found[0] !== undefined) scenarios.push(found[0])
115
- }
116
-
117
- return { scenarios, ambiguous }
99
+ return { scenarios }
118
100
  }
119
101
 
120
102
  /** Every category folder under `sandbox/`, fixtures excluded. */
@@ -129,21 +111,18 @@ export function listCategories(sandboxDir: string): string[] {
129
111
 
130
112
  export type ResolvedScenario =
131
113
  | ({ readonly ok: true } & ScenarioFile)
132
- | { readonly ok: false; readonly reason: 'missing' | 'ambiguous' }
114
+ | { readonly ok: false; readonly reason: 'missing' }
133
115
 
134
116
  export function resolveScenarioFile(
135
117
  sandboxDir: string,
136
118
  category: string,
137
119
  command: string,
138
120
  ): ResolvedScenario {
139
- const found = EXTENSIONS.map((ext) =>
140
- join(sandboxDir, category, `${command}${ext}`),
141
- ).filter((path) => existsSync(path) && statSync(path).isFile())
142
- if (found.length > 1) return { ok: false, reason: 'ambiguous' }
143
- const file = found[0]
144
- if (file === undefined) return { ok: false, reason: 'missing' }
145
-
146
- return { ok: true, command, file, kind: file.endsWith('.ts') ? 'ts' : 'sh' }
121
+ const file = join(sandboxDir, category, `${command}${EXTENSION}`)
122
+ if (!existsSync(file) || !statSync(file).isFile())
123
+ return { ok: false, reason: 'missing' }
124
+
125
+ return { ok: true, command, file }
147
126
  }
148
127
 
149
128
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -190,7 +169,7 @@ export function armNames(definition: ScenarioDefinition): string[] {
190
169
  return names.length === 1 && names[0] === DEFAULT_ARM_NAME ? [] : names
191
170
  }
192
171
 
193
- /** Twin of `scripts/config.sh`, which derives the organization from the toolkit's own remote. */
172
+ /** Derives the organization from the toolkit's own remote unless `GITHUB_ORG` names one. */
194
173
  function deriveGithubOrg(root: string, env: NodeJS.ProcessEnv): string {
195
174
  if ((env.GITHUB_ORG ?? '') !== '') return env.GITHUB_ORG ?? ''
196
175
 
@@ -209,8 +188,8 @@ function deriveGithubOrg(root: string, env: NodeJS.ProcessEnv): string {
209
188
  }
210
189
 
211
190
  /**
212
- * What the bash probe reported for a scenario: its config, the organization
213
- * `config.sh` derives, and the anchor's repository name. These land in the one
191
+ * What provisioning reads off a scenario: its config, the derived organization,
192
+ * and the anchor's repository name. These land in the one
214
193
  * environment provisioning passes to every child, the seed and gov installs
215
194
  * included.
216
195
  */
@@ -447,8 +426,7 @@ export function createStageContext(options: ContextOptions): StageContext {
447
426
 
448
427
  /**
449
428
  * `gh` answers an absent repository and an unreachable host with one status,
450
- * so the 404 alone separates them. Creating is the opt-in, for the reason
451
- * `ensure_sandbox_anchor_repo` in `scripts/lib/sandbox-git.sh` records.
429
+ * so the 404 alone separates them. Creating is the opt-in.
452
430
  */
453
431
  const ensureAnchorRepo = (org: string, repo: string): void => {
454
432
  const probe = spawn(
@@ -503,7 +481,7 @@ export function createStageContext(options: ContextOptions): StageContext {
503
481
  git('remote', 'add', 'origin', `https://github.com/${org}/${repo}.git`)
504
482
  }
505
483
 
506
- /** Twin of `stage_fixtures` in `scripts/lib/sandbox-fixtures.sh`. */
484
+ /** Copies a fixture folder into the tree. */
507
485
  const fixtures = (
508
486
  category: string,
509
487
  command: string,
@@ -602,7 +580,7 @@ export function createStageContext(options: ContextOptions): StageContext {
602
580
  }
603
581
 
604
582
  /**
605
- * Picks the arm the way `select_or_route_scenario` did: a routed name first,
583
+ * Picks the arm: a routed name first,
606
584
  * echoed on the frame, then the first arm for a headless caller, then a prompt.
607
585
  */
608
586
  async function routeArm(
@@ -8,11 +8,9 @@ import { join } from 'node:path'
8
8
  * in `CANON_SANDBOX_RUN_ID` for the rest of this process, so a script that
9
9
  * spawns a child inheriting `process.env` — `canon sandbox run` calling
10
10
  * `canon sandbox` to provision and then `canon sandbox check` — resolves the same tree in every one
11
- * of them, as do the hook and installer children provisioning spawns. A process
11
+ * of them, as do the installer children provisioning spawns. A process
12
12
  * that already carries the variable, inherited from such a parent, reuses it
13
13
  * rather than minting a new one.
14
- *
15
- * Twin of `mint_sandbox_run_id` in `scripts/lib/sandbox-path.sh`.
16
14
  */
17
15
  export function mintSandboxRunId(): string {
18
16
  const existing = process.env.CANON_SANDBOX_RUN_ID
@@ -28,9 +26,7 @@ export function mintSandboxRunId(): string {
28
26
  * per-run default is unit-testable on its own rather than only through the
29
27
  * command's registration.
30
28
  *
31
- * Twin of `resolve_sandbox_dir` in `scripts/lib/sandbox-path.sh`. The exec
32
- * boundary rules out a shared constant, so a change to the default lands on
33
- * both sides. The fall-through used to be one path per machine, so two
29
+ * The fall-through used to be one path per machine, so two
34
30
  * sessions each resolving the default at once provisioned over each other
35
31
  * with neither told; `mintSandboxRunId` gives the path a per-run component
36
32
  * instead, which is what makes two such sessions land on two different trees.