@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
@@ -1,78 +0,0 @@
1
- import { parseFrontmatter, readField } from '@/indexes/frontmatter'
2
- import {
3
- CATEGORY_BY_TYPE,
4
- type FindingRemedy,
5
- MEMORY_NAME,
6
- memoryType,
7
- } from '@/records/validate'
8
-
9
- export const MIGRATE_REFUSALS = ['field-unrecoverable'] as const
10
-
11
- export type MigrateRefusal = (typeof MIGRATE_REFUSALS)[number]
12
-
13
- export interface MigrateRepair {
14
- readonly ok: true
15
- readonly text: string
16
- }
17
-
18
- export interface MigrateRefused {
19
- readonly ok: false
20
- readonly reason: MigrateRefusal
21
- readonly message: string
22
- }
23
-
24
- export type MigrateOutcome = MigrateRepair | MigrateRefused
25
-
26
- function refuse(message: string): MigrateRefused {
27
- return { ok: false, reason: 'field-unrecoverable', message }
28
- }
29
-
30
- /**
31
- * Repairs the one recoverable shape of `frontmatter-incomplete`: a memory
32
- * record missing `category` alone. The value sits in the filename's type
33
- * prefix already, which is the same fact `checkMemory` derives it from, so a
34
- * repair here writes back what the record already states rather than a value
35
- * a transform invented. It re-reads the file rather than trusting a value
36
- * carried on the `Finding`, so a transform and the check it repairs cannot
37
- * drift apart.
38
- */
39
- function categoryFromName(name: string, text: string): MigrateOutcome {
40
- const frontmatter = parseFrontmatter(text)
41
- if (!frontmatter) {
42
- return refuse(`${name} carries no frontmatter block to add category to.`)
43
- }
44
-
45
- if (readField(frontmatter, 'category')) {
46
- return refuse(`${name} already carries a category.`)
47
- }
48
-
49
- const match = MEMORY_NAME.exec(name)
50
- const type = match ? memoryType(match[1]) : undefined
51
- if (!type) {
52
- return refuse(
53
- `${name} is not named <type>-<slug>.md, so category has no type to derive from.`,
54
- )
55
- }
56
-
57
- const rewritten = frontmatter.raw.replace(
58
- /\n---$/,
59
- `\ncategory: ${CATEGORY_BY_TYPE[type]}\n---`,
60
- )
61
-
62
- return { ok: true, text: rewritten + text.slice(frontmatter.raw.length) }
63
- }
64
-
65
- const TRANSFORMS: Readonly<
66
- Record<FindingRemedy, (name: string, text: string) => MigrateOutcome>
67
- > = {
68
- 'category-from-name': categoryFromName,
69
- }
70
-
71
- /** Applies the transform a finding's `remedy` names against its own text. */
72
- export function migrateRecord(
73
- remedy: FindingRemedy,
74
- name: string,
75
- text: string,
76
- ): MigrateOutcome {
77
- return TRANSFORMS[remedy](name, text)
78
- }
@@ -1,243 +0,0 @@
1
- import { existsSync } from 'node:fs'
2
- import { readdir, readFile } from 'node:fs/promises'
3
- import { join, relative } from 'node:path'
4
- import { parseFrontmatter, readField } from '@/indexes/frontmatter'
5
- import {
6
- CREATION_ROOT,
7
- RECORD_ROOTS,
8
- recordDir,
9
- SCRATCH,
10
- spell,
11
- } from '@/roots/record'
12
-
13
- /** The window after which a reviewed entry is due again, in days. */
14
- export const DEFAULT_REVIEW_DAYS = 30
15
-
16
- const DAY_MS = 24 * 60 * 60 * 1000
17
-
18
- const MEMORY_INDEX = 'index.md'
19
-
20
- export interface StaleEntry {
21
- /** The filename stem, which is what a review names an entry by. */
22
- readonly name: string
23
- readonly category: string | null
24
- /** `YYYY-MM-DD`, or null when the entry carries no readable review date. */
25
- readonly reviewed: string | null
26
- /** The raw value of a `reviewed` field that is not a date, present only then. */
27
- readonly invalidReviewed?: string
28
- readonly due: boolean
29
- /** Backticked paths the entry cites that the project root does not hold. */
30
- readonly unresolved: readonly string[]
31
- }
32
-
33
- export interface StaleReport {
34
- readonly ok: true
35
- readonly root: string
36
- /** The pen read, relative to the root. */
37
- readonly folder: string
38
- readonly days: number
39
- readonly total: number
40
- readonly due: number
41
- readonly entries: readonly StaleEntry[]
42
- }
43
-
44
- export const STALE_REFUSALS = ['no-folder'] as const
45
-
46
- export type StaleRefusal = (typeof STALE_REFUSALS)[number]
47
-
48
- export interface StaleRefused {
49
- readonly ok: false
50
- readonly reason: StaleRefusal
51
- readonly message: string
52
- }
53
-
54
- export type StaleOutcome = StaleReport | StaleRefused
55
-
56
- const REVIEWED_FIELD = /^reviewed:[ \t]*(.*?)[ \t]*$/m
57
- const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
58
- const RAW_FRONTMATTER = /^---\n([\s\S]*?)\n---/
59
-
60
- /**
61
- * Reads `reviewed` off the raw block rather than the parsed fields, for the
62
- * reason `hasOpeningDate` in the validator gives: a YAML parser resolves a
63
- * bare `YYYY-MM-DD` to a date on one schema and a string on another.
64
- *
65
- * A value that is not a calendar date reads as never reviewed and is carried
66
- * back so the review can see it, rather than throwing over one hand edit.
67
- */
68
- export function readReviewed(
69
- text: string,
70
- ): Pick<StaleEntry, 'reviewed' | 'invalidReviewed'> {
71
- const block = RAW_FRONTMATTER.exec(text)?.[1]
72
- const match = block ? REVIEWED_FIELD.exec(block) : null
73
- if (!match) return { reviewed: null }
74
-
75
- const value = match[1].replace(/^(['"])(.*)\1$/, '$2')
76
- const parsed = Date.parse(`${value}T00:00:00Z`)
77
- const isDate =
78
- ISO_DATE.test(value) &&
79
- Number.isFinite(parsed) &&
80
- new Date(parsed).toISOString().slice(0, 10) === value
81
-
82
- return isDate
83
- ? { reviewed: value }
84
- : { reviewed: null, invalidReviewed: value }
85
- }
86
-
87
- /**
88
- * Tokens that name a session record, scratch at either root, or a checkout,
89
- * none of which a tree commits. The scratch spellings come from `spell` so the
90
- * legacy root's variant is stated in one place.
91
- */
92
- const UNTRACKED_PREFIXES = [
93
- ...RECORD_ROOTS.map((at) => `${at}/${spell(at, SCRATCH)}/`),
94
- `${CREATION_ROOT}/`,
95
- '.claude/worktrees/',
96
- ]
97
-
98
- const INLINE_CODE = /`([^`\n]+)`/g
99
- const LINE_ANCHOR = /:\d+(?:[-:]\d+)*$/
100
- const FILE_EXTENSION = /\.[A-Za-z0-9]+$/
101
-
102
- /**
103
- * Reduces one backticked token to the repository path it cites, or undefined
104
- * when it cites none.
105
- *
106
- * A path is a token carrying a `/` and ending in a file extension once any
107
- * line or heading anchor is stripped. A placeholder, an elided path, a glob, a
108
- * variable, a URL, and a path outside the project all look like paths and
109
- * resolve against nothing in the tree, so each is dropped rather than reported.
110
- */
111
- function toCitedPath(token: string): string | undefined {
112
- if (/\s/.test(token) || token.includes('://')) return undefined
113
- if (/[<*{$]/.test(token) || token.includes('...')) return undefined
114
- if (/^(?:\/|~|\.\.\/)/.test(token)) return undefined
115
-
116
- const path = token
117
- .replace(/#.*$/, '')
118
- .replace(LINE_ANCHOR, '')
119
- .replace(/^\.\//, '')
120
-
121
- if (!path.includes('/') || !FILE_EXTENSION.test(path)) return undefined
122
- if (UNTRACKED_PREFIXES.some((prefix) => path.startsWith(prefix)))
123
- return undefined
124
-
125
- return path
126
- }
127
-
128
- /** Every distinct repository path a body cites in inline code, in order. */
129
- export function citedPaths(body: string): string[] {
130
- const paths = new Set<string>()
131
-
132
- for (const match of body.matchAll(INLINE_CODE)) {
133
- const path = toCitedPath(match[1])
134
- if (path) paths.add(path)
135
- }
136
-
137
- return [...paths]
138
- }
139
-
140
- function isDue(reviewed: string | null, days: number, now: number): boolean {
141
- if (reviewed === null) return true
142
- return now - Date.parse(`${reviewed}T00:00:00`) > days * DAY_MS
143
- }
144
-
145
- async function readEntry(
146
- root: string,
147
- dir: string,
148
- file: string,
149
- days: number,
150
- now: number,
151
- ): Promise<StaleEntry> {
152
- const text = (await readFile(join(dir, file), 'utf8')).replaceAll(
153
- '\r\n',
154
- '\n',
155
- )
156
- const frontmatter = parseFrontmatter(text)
157
- const review = readReviewed(text)
158
- const body = text.slice(RAW_FRONTMATTER.exec(text)?.[0].length ?? 0)
159
-
160
- return {
161
- name: file.replace(/\.md$/, ''),
162
- category: readField(frontmatter, 'category') ?? null,
163
- ...review,
164
- due: isDue(review.reviewed, days, now),
165
- unresolved: citedPaths(body).filter(
166
- (path) => !existsSync(join(root, path)),
167
- ),
168
- }
169
- }
170
-
171
- /**
172
- * The order a review takes a batch in: due entries first, and among them the
173
- * ones citing a tree that moved, then the longest unreviewed, then by name so
174
- * two runs over one pen agree.
175
- */
176
- function compareEntries(left: StaleEntry, right: StaleEntry): number {
177
- if (left.due !== right.due) return left.due ? -1 : 1
178
-
179
- const leftMoved = left.unresolved.length > 0
180
- const rightMoved = right.unresolved.length > 0
181
- if (leftMoved !== rightMoved) return leftMoved ? -1 : 1
182
-
183
- if (left.reviewed !== right.reviewed) {
184
- if (left.reviewed === null) return -1
185
- if (right.reviewed === null) return 1
186
- return left.reviewed < right.reviewed ? -1 : 1
187
- }
188
-
189
- return left.name.localeCompare(right.name)
190
- }
191
-
192
- /**
193
- * Reports each memory entry's review state and the paths it cites that the
194
- * project no longer holds, ordered so a caller takes the first N as a batch.
195
- *
196
- * It reads the top level of the pen alone. `review/` holds receipts and
197
- * `archive/` holds retirements, and neither is an entry a review would queue.
198
- * Paths resolve against the project root rather than the entry's folder, since
199
- * an entry cites the tree it was written about. It reports and never writes.
200
- *
201
- * `now` is a parameter so a test can pin the window against fixture dates.
202
- */
203
- export async function staleMemory(
204
- root: string,
205
- days: number = DEFAULT_REVIEW_DAYS,
206
- now: number = Date.now(),
207
- ): Promise<StaleOutcome> {
208
- const dir = recordDir(root, 'memory')
209
-
210
- if (!existsSync(dir)) {
211
- return {
212
- ok: false,
213
- reason: 'no-folder',
214
- message: `No memory folder at ${dir}, so there are no entries to read.`,
215
- }
216
- }
217
-
218
- const listed = await readdir(dir, { withFileTypes: true })
219
- const files = listed
220
- .filter(
221
- (entry) =>
222
- entry.isFile() &&
223
- entry.name.endsWith('.md') &&
224
- entry.name !== MEMORY_INDEX,
225
- )
226
- .map((entry) => entry.name)
227
-
228
- const entries = (
229
- await Promise.all(
230
- files.map((file) => readEntry(root, dir, file, days, now)),
231
- )
232
- ).toSorted(compareEntries)
233
-
234
- return {
235
- ok: true,
236
- root,
237
- folder: relative(root, dir),
238
- days,
239
- total: entries.length,
240
- due: entries.filter((entry) => entry.due).length,
241
- entries,
242
- }
243
- }
@@ -1,128 +0,0 @@
1
- import { spawnSync } from 'node:child_process'
2
- import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
3
- import { tmpdir } from 'node:os'
4
- import { join } from 'node:path'
5
- import { PROJECT_ROOT } from '@/roots/project'
6
-
7
- /**
8
- * The bash entry a scenario's hooks run through. Scenarios and the library they
9
- * call stay bash, so this contract is the whole of what the TypeScript harness
10
- * knows about one: which hooks it declares and what `use_config` exports.
11
- */
12
- const HOOK_SCRIPT = join(PROJECT_ROOT, 'scripts', 'sandbox-hook.sh')
13
-
14
- const ANCHOR_RECORD = '@anchor='
15
-
16
- export interface Probe {
17
- readonly exports: Readonly<Record<string, string>>
18
- readonly isAnchor: boolean
19
- }
20
-
21
- export type ProbeResult =
22
- | ({ readonly ok: true } & Probe)
23
- | { readonly ok: false; readonly status: number }
24
-
25
- /**
26
- * Reads the NUL-separated `NAME=value` pairs the probe prints. NUL rather than a
27
- * newline, since an exported value can hold one. The anchor record closes every
28
- * well-formed probe, so output missing it is a probe that did not finish.
29
- */
30
- export function parseProbe(stdout: string): Probe {
31
- const { exports, anchor } = parsePairs(stdout)
32
- if (anchor === undefined)
33
- throw new Error('the probe printed no anchor record')
34
-
35
- return { exports, isAnchor: anchor === '1' }
36
- }
37
-
38
- function parsePairs(text: string): {
39
- exports: Record<string, string>
40
- anchor: string | undefined
41
- } {
42
- const exports: Record<string, string> = {}
43
- let anchor: string | undefined
44
-
45
- for (const record of text.split('\0')) {
46
- if (record.startsWith(ANCHOR_RECORD)) {
47
- anchor = record.slice(ANCHOR_RECORD.length)
48
- continue
49
- }
50
- const split = record.indexOf('=')
51
- if (split > 0) exports[record.slice(0, split)] = record.slice(split + 1)
52
- }
53
-
54
- return { exports, anchor }
55
- }
56
-
57
- /**
58
- * Runs `use_config` and `use_anchor` in a child and returns what they exported.
59
- * Stderr is inherited by default, so a hook that fails narrates into the same
60
- * frame the harness writes. A non-zero status passes through rather than being
61
- * flattened, since the harness exits with it.
62
- */
63
- export function probeScenario(
64
- file: string,
65
- env: NodeJS.ProcessEnv,
66
- stderr: 'inherit' | 'pipe' = 'inherit',
67
- ): ProbeResult {
68
- const result = spawnSync('bash', [HOOK_SCRIPT, 'probe', file], {
69
- env,
70
- encoding: 'utf8',
71
- stdio: ['ignore', 'pipe', stderr],
72
- })
73
- const status = result.status ?? 1
74
- if (status !== 0) return { ok: false, status }
75
-
76
- return { ok: true, ...parseProbe(result.stdout) }
77
- }
78
-
79
- /**
80
- * How `stage_setup` ended. The retired dispatcher ran it in its own shell, so
81
- * an `exit` there ended provisioning through the frame-closing trap, and an
82
- * `exec` replaced the harness outright, leaving its program's frame as the last
83
- * one written. The harness reproduces both from this.
84
- */
85
- export type StageEnding = 'returned' | 'exited' | 'replaced'
86
-
87
- export interface StageResult {
88
- readonly status: number
89
- readonly ending: StageEnding
90
- readonly exports: Readonly<Record<string, string>>
91
- }
92
-
93
- const EXITED_RECORD = '@exited'
94
-
95
- /**
96
- * Runs `stage_setup` inside the tree with every stream inherited, so a scenario
97
- * prompting through `select_or_route_scenario` still reaches a terminal and its
98
- * narration lands in order with the harness's own.
99
- *
100
- * The exports `stage_setup` changed come back through a file rather than a
101
- * pipe. Stdout belongs to the scenario, and a background process a scenario
102
- * starts would hold an extra pipe open past the child's exit.
103
- */
104
- export function stageScenario(
105
- file: string,
106
- env: NodeJS.ProcessEnv,
107
- stdio: 'inherit' | 'ignore' = 'inherit',
108
- ): StageResult {
109
- const scratch = mkdtempSync(join(tmpdir(), 'canon-sandbox-hook-'))
110
- const report = join(scratch, 'exports')
111
-
112
- try {
113
- const result = spawnSync('bash', [HOOK_SCRIPT, 'stage', file, report], {
114
- env,
115
- stdio,
116
- })
117
- const status = result.status ?? 1
118
- if (!existsSync(report)) return { status, ending: 'replaced', exports: {} }
119
-
120
- const text = readFileSync(report, 'utf8')
121
- if (text.split('\0').includes(EXITED_RECORD))
122
- return { status, ending: 'exited', exports: {} }
123
-
124
- return { status, ending: 'returned', exports: parsePairs(text).exports }
125
- } finally {
126
- rmSync(scratch, { recursive: true, force: true })
127
- }
128
- }