@erclx/canon 5.44.0 → 5.46.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 (253) hide show
  1. package/README.md +1 -1
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/REQUIREMENT.md +15 -31
  4. package/claude/skills/auto-ship/REQUIREMENT.md +14 -25
  5. package/claude/skills/backlog-triage/REQUIREMENT.md +12 -23
  6. package/claude/skills/build-in-slices/REQUIREMENT.md +15 -25
  7. package/claude/skills/canon-cli/REQUIREMENT.md +15 -27
  8. package/claude/skills/canon-cli/SKILL.md +0 -1
  9. package/claude/skills/canon-feedback/REQUIREMENT.md +10 -18
  10. package/claude/skills/canon-feedback-triage/REQUIREMENT.md +10 -15
  11. package/claude/skills/canvas-board/REQUIREMENT.md +14 -28
  12. package/claude/skills/ci-workflow/REQUIREMENT.md +12 -21
  13. package/claude/skills/code-craft/REQUIREMENT.md +14 -28
  14. package/claude/skills/codebase-layout/REQUIREMENT.md +14 -31
  15. package/claude/skills/context-fold/REQUIREMENT.md +15 -39
  16. package/claude/skills/context-fold/references/context.md +3 -3
  17. package/claude/skills/create-rule/REQUIREMENT.md +10 -24
  18. package/claude/skills/create-rule/SKILL.md +3 -0
  19. package/claude/skills/create-skill/REQUIREMENT.md +10 -21
  20. package/claude/skills/decision-escalate/REQUIREMENT.md +12 -24
  21. package/claude/skills/deploy-app/REQUIREMENT.md +12 -22
  22. package/claude/skills/deprecation-migration/REQUIREMENT.md +14 -42
  23. package/claude/skills/design-identity/REQUIREMENT.md +14 -20
  24. package/claude/skills/design-taste/REQUIREMENT.md +16 -57
  25. package/claude/skills/docs-sync/REQUIREMENT.md +14 -23
  26. package/claude/skills/document-health/REQUIREMENT.md +15 -29
  27. package/claude/skills/document-health/SKILL.md +2 -2
  28. package/claude/skills/document-repair/REQUIREMENT.md +32 -0
  29. package/claude/skills/document-repair/SKILL.md +76 -0
  30. package/claude/skills/document-repair/references/batches.md +44 -0
  31. package/claude/skills/draft-and-pick/REQUIREMENT.md +4 -3
  32. package/claude/skills/draft-doc/REQUIREMENT.md +4 -3
  33. package/claude/skills/draft-figure/REQUIREMENT.md +4 -3
  34. package/claude/skills/draft-identity/REQUIREMENT.md +4 -3
  35. package/claude/skills/draft-ready/REQUIREMENT.md +4 -3
  36. package/claude/skills/draft-screencast/REQUIREMENT.md +4 -3
  37. package/claude/skills/draft-slides/REQUIREMENT.md +4 -3
  38. package/claude/skills/draw-figure/REQUIREMENT.md +15 -39
  39. package/claude/skills/git-branch/REQUIREMENT.md +12 -26
  40. package/claude/skills/git-commit/REQUIREMENT.md +11 -15
  41. package/claude/skills/git-followup/REQUIREMENT.md +11 -22
  42. package/claude/skills/git-issue/REQUIREMENT.md +9 -15
  43. package/claude/skills/git-pr/REQUIREMENT.md +13 -32
  44. package/claude/skills/git-pr/SKILL.md +10 -2
  45. package/claude/skills/git-pr/references/closing-issues.md +47 -0
  46. package/claude/skills/git-pr/references/pr.md +4 -0
  47. package/claude/skills/git-ship/REQUIREMENT.md +13 -26
  48. package/claude/skills/git-split/REQUIREMENT.md +10 -17
  49. package/claude/skills/git-stage/REQUIREMENT.md +12 -18
  50. package/claude/skills/git-worktree/REQUIREMENT.md +15 -19
  51. package/claude/skills/make-deck/REQUIREMENT.md +13 -21
  52. package/claude/skills/markdown-craft/REQUIREMENT.md +9 -3
  53. package/claude/skills/pick-by-eye/REQUIREMENT.md +17 -67
  54. package/claude/skills/plan-feature/REQUIREMENT.md +15 -24
  55. package/claude/skills/plan-groundwork/REQUIREMENT.md +12 -28
  56. package/claude/skills/plan-groundwork/SKILL.md +5 -2
  57. package/claude/skills/plan-groundwork/references/output.md +47 -1
  58. package/claude/skills/plan-intake/REQUIREMENT.md +11 -26
  59. package/claude/skills/plan-intake/references/intake.md +2 -2
  60. package/claude/skills/plan-intake-answer/REQUIREMENT.md +14 -29
  61. package/claude/skills/plan-intake-answer/SKILL.md +7 -1
  62. package/claude/skills/plan-ready/REQUIREMENT.md +17 -36
  63. package/claude/skills/pr-evidence/REQUIREMENT.md +11 -17
  64. package/claude/skills/read-frames/REQUIREMENT.md +3 -8
  65. package/claude/skills/record-screencast/REQUIREMENT.md +14 -17
  66. package/claude/skills/review-address/REQUIREMENT.md +14 -38
  67. package/claude/skills/review-craft/REQUIREMENT.md +14 -32
  68. package/claude/skills/review-pr/REQUIREMENT.md +15 -34
  69. package/claude/skills/review-ui/REQUIREMENT.md +14 -39
  70. package/claude/skills/role-orchestrator/REQUIREMENT.md +16 -99
  71. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +5 -0
  72. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +9 -0
  73. package/claude/skills/role-planner/REQUIREMENT.md +15 -73
  74. package/claude/skills/role-reviewer/REQUIREMENT.md +15 -65
  75. package/claude/skills/role-reviewer/SKILL.md +5 -1
  76. package/claude/skills/role-worker/REQUIREMENT.md +13 -41
  77. package/claude/skills/role-worker/SKILL.md +2 -0
  78. package/claude/skills/script-screencast/REQUIREMENT.md +10 -16
  79. package/claude/skills/search-craft/REQUIREMENT.md +12 -36
  80. package/claude/skills/search-craft/SKILL.md +1 -1
  81. package/claude/skills/search-craft/references/adopted.md +2 -2
  82. package/claude/skills/session-compact/REQUIREMENT.md +13 -23
  83. package/claude/skills/session-map/REQUIREMENT.md +13 -29
  84. package/claude/skills/session-relay/REQUIREMENT.md +14 -58
  85. package/claude/skills/session-relay/SKILL.md +6 -0
  86. package/claude/skills/session-resume/REQUIREMENT.md +13 -27
  87. package/claude/skills/session-worktree/REQUIREMENT.md +14 -43
  88. package/claude/skills/systematic-debugging/REQUIREMENT.md +13 -34
  89. package/claude/skills/target-check/REQUIREMENT.md +16 -23
  90. package/claude/skills/target-setup/REQUIREMENT.md +11 -34
  91. package/claude/skills/target-setup/SKILL.md +2 -2
  92. package/claude/skills/target-setup/references/detect.md +0 -1
  93. package/claude/skills/target-setup/references/verify.md +2 -2
  94. package/claude/skills/task-board/REQUIREMENT.md +13 -22
  95. package/claude/skills/task-board/SKILL.md +7 -0
  96. package/claude/skills/task-board/references/tasks.md +1 -1
  97. package/claude/skills/task-complete/REQUIREMENT.md +11 -24
  98. package/claude/skills/task-complete/references/archive.md +1 -0
  99. package/claude/skills/teach-workspace/REQUIREMENT.md +14 -45
  100. package/claude/skills/teach-workspace/references/promotion.md +5 -5
  101. package/claude/skills/test-craft/REQUIREMENT.md +15 -46
  102. package/claude/skills/test-first/REQUIREMENT.md +14 -20
  103. package/claude/skills/ui-checklist/REQUIREMENT.md +14 -29
  104. package/claude/skills/ux-audit/REQUIREMENT.md +3 -7
  105. package/claude/skills/ux-measure/REQUIREMENT.md +14 -28
  106. package/claude/skills/video-craft/REQUIREMENT.md +14 -27
  107. package/claude/skills/write-doc/REQUIREMENT.md +14 -24
  108. package/claude/skills/write-human/REQUIREMENT.md +15 -27
  109. package/claude/skills/youtube-transcripts/REQUIREMENT.md +9 -15
  110. package/docs/agents/context-audit.md +19 -12
  111. package/docs/agents/install-and-sync.md +2 -2
  112. package/docs/agents/markdown-audit.md +15 -10
  113. package/docs/agents/output-shape.md +1 -2
  114. package/docs/target/migrations.md +18 -0
  115. package/docs/target/projects.md +3 -2
  116. package/docs/target/scenarios.md +4 -1
  117. package/docs/workflow/skill-map-on-request.md +1 -1
  118. package/docs/workflow/skill-map.md +6 -5
  119. package/governance/detect.toml +4 -0
  120. package/governance/ledger/lang/go.md +61 -0
  121. package/governance/ledger/lang/php.md +50 -0
  122. package/governance/ledger/lang/python.md +68 -0
  123. package/governance/ledger/lang/typescript.md +64 -0
  124. package/governance/ledger/lib/database.md +65 -0
  125. package/governance/ledger/lib/e2e-reliability.md +57 -0
  126. package/governance/ledger/lib/pydantic.md +61 -0
  127. package/governance/ledger/lib/security-server.md +62 -0
  128. package/governance/ledger/lib/security-web.md +51 -0
  129. package/governance/ledger/lib/tanstack-query.md +57 -0
  130. package/governance/ledger/lib/test-scope.md +43 -0
  131. package/governance/ledger/lib/testing-go.md +57 -0
  132. package/governance/ledger/lib/testing-php.md +51 -0
  133. package/governance/ledger/lib/testing-py.md +67 -0
  134. package/governance/ledger/lib/testing-ts.md +63 -0
  135. package/governance/ledger/lib/zod.md +47 -0
  136. package/governance/ledger/tooling/ci-workflow.md +33 -0
  137. package/governance/ledger/tooling/code-comments.md +48 -0
  138. package/governance/ledger/tooling/config-comments.md +27 -0
  139. package/governance/ledger/tooling/dependencies.md +38 -0
  140. package/governance/ledger/ui/a11y.md +55 -0
  141. package/governance/ledger/ui/copy.md +49 -0
  142. package/governance/ledger/ui/design-reference.md +42 -0
  143. package/governance/ledger/ui/design-taste.md +19 -0
  144. package/governance/ledger/ui/forms.md +71 -0
  145. package/governance/ledger/ui/interface-casing.md +47 -0
  146. package/governance/ledger/ui/keyboard.md +68 -0
  147. package/governance/ledger/ui/link-behavior.md +29 -0
  148. package/governance/ledger/ui/motion.md +33 -0
  149. package/governance/ledger/ui/surface-capture.md +69 -0
  150. package/governance/ledger/ui/ux-completeness.md +79 -0
  151. package/governance/renames.toml +0 -2
  152. package/governance/rules/claude/output.md +5 -1
  153. package/governance/rules/code/routing.md +5 -1
  154. package/governance/rules/lang/go.md +7 -16
  155. package/governance/rules/lang/php.md +3 -3
  156. package/governance/rules/lang/python.md +9 -18
  157. package/governance/rules/lang/typescript.md +9 -19
  158. package/governance/rules/lib/database.md +5 -5
  159. package/governance/rules/lib/e2e-reliability.md +4 -6
  160. package/governance/rules/lib/pydantic.md +14 -16
  161. package/governance/rules/lib/security-server.md +4 -4
  162. package/governance/rules/lib/security-web.md +6 -10
  163. package/governance/rules/lib/tanstack-query.md +10 -12
  164. package/governance/rules/lib/test-scope.md +4 -4
  165. package/governance/rules/lib/testing-go.md +1 -2
  166. package/governance/rules/lib/testing-php.md +4 -5
  167. package/governance/rules/lib/testing-py.md +11 -19
  168. package/governance/rules/lib/testing-ts.md +7 -16
  169. package/governance/rules/lib/zod.md +6 -8
  170. package/governance/rules/tooling/code-comments.md +2 -4
  171. package/governance/rules/tooling/config-comments.md +1 -4
  172. package/governance/rules/tooling/dependencies.md +1 -1
  173. package/governance/rules/ui/a11y.md +16 -45
  174. package/governance/rules/ui/copy.md +6 -9
  175. package/governance/rules/ui/forms.md +13 -19
  176. package/governance/rules/ui/interface-casing.md +5 -7
  177. package/governance/rules/ui/keyboard.md +38 -0
  178. package/governance/rules/ui/link-behavior.md +3 -3
  179. package/governance/rules/ui/motion.md +3 -3
  180. package/governance/rules/ui/surface-capture.md +15 -27
  181. package/governance/rules/ui/ux-completeness.md +16 -39
  182. package/governance/stacks/base.toml +0 -1
  183. package/package.json +1 -1
  184. package/scripts/core/install-check.sh +1 -1
  185. package/src/claude/cases/workflow.ts +12 -0
  186. package/src/cli/help.ts +0 -1
  187. package/src/cli.ts +0 -2
  188. package/src/commands/context.ts +57 -3
  189. package/src/commands/gov.ts +4 -0
  190. package/src/commands/init.ts +2 -2
  191. package/src/commands/labels.ts +83 -3
  192. package/src/commands/markdown.ts +188 -21
  193. package/src/commands/sessions.ts +36 -3
  194. package/src/commands/tasks.ts +180 -0
  195. package/src/commands/tooling.ts +18 -5
  196. package/src/commands/worktrees.ts +2 -0
  197. package/src/context/coverage.ts +210 -0
  198. package/src/context/gate.ts +12 -3
  199. package/src/design/components.ts +1 -1
  200. package/src/gate/stages.ts +1 -1
  201. package/src/gov/audit.ts +22 -2
  202. package/src/init/plan.ts +1 -8
  203. package/src/init/steps.ts +44 -7
  204. package/src/intake/items.ts +14 -0
  205. package/src/inventory/subjects.ts +1 -1
  206. package/src/labels/closing.ts +46 -0
  207. package/src/markdown/gated.ts +250 -0
  208. package/src/markdown/scope.ts +119 -2
  209. package/src/migrate/rename.ts +6 -0
  210. package/src/migrate/scratch-evidence.ts +12 -4
  211. package/src/records/backup.ts +3 -3
  212. package/src/records/validate.ts +3 -3
  213. package/src/roots/record.ts +17 -8
  214. package/src/sessions/claim.ts +7 -0
  215. package/src/sessions/live.ts +53 -6
  216. package/src/sessions/registry.ts +11 -0
  217. package/src/sessions/resolve.ts +32 -5
  218. package/src/tasks/closes.ts +136 -0
  219. package/src/tasks/record.ts +1 -1
  220. package/src/tooling/inject.ts +26 -7
  221. package/src/tooling/package.ts +32 -0
  222. package/src/tooling/read.ts +46 -3
  223. package/src/tooling/scan.ts +8 -2
  224. package/src/worktrees/reclaim.ts +16 -1
  225. package/tooling/astro/reference.md +64 -93
  226. package/tooling/base/configs/.github/workflows/phase-label-gate.yml +1 -1
  227. package/tooling/base/configs/scripts/verify.ts +2 -2
  228. package/tooling/base/reference.md +1 -9
  229. package/tooling/base/seeds/canon/context/{ci.md → development/ci.md} +1 -1
  230. package/tooling/base/seeds/canon/context/development/index.md +11 -0
  231. package/tooling/base/seeds/canon/context/{development.md → development/setup.md} +8 -12
  232. package/tooling/base/seeds/canon/context/index.md +1 -2
  233. package/tooling/claude/manifest.toml +1 -5
  234. package/tooling/claude/reference.md +36 -59
  235. package/tooling/claude/seeds/.claude/hooks/board-validate.sh +1 -1
  236. package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +2 -7
  237. package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +2 -7
  238. package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +3 -10
  239. package/tooling/claude/seeds/.claude/hooks/search-reminder.sh +2 -7
  240. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +2 -4
  241. package/tooling/claude/seeds/CLAUDE.md +2 -2
  242. package/tooling/nextjs/reference.md +0 -6
  243. package/tooling/php/reference.md +0 -4
  244. package/tooling/web/configs/.github/workflows/verify.yml +28 -0
  245. package/tooling/web/configs/.oxlintrc.json +7 -0
  246. package/tooling/web/configs/e2e/capture-types.ts +35 -0
  247. package/tooling/web/configs/e2e/screenshot.ts +23 -72
  248. package/tooling/web/manifest.toml +1 -1
  249. package/tooling/web/reference.md +77 -124
  250. package/tooling/web/seeds/e2e/capture/cases.ts +47 -0
  251. package/governance/rules/canon/task-scope.md +0 -12
  252. package/src/commands/wiki.ts +0 -100
  253. package/src/wiki/init.ts +0 -85
@@ -107,9 +107,9 @@ export const EXCLUDED_ENTRIES = [
107
107
  /**
108
108
  * Whether a top-level name stays out of every push, at either root.
109
109
  *
110
- * `SCRATCH` is the legacy `.tmp` spelling, which a records index opened before
111
- * the move can still carry and which `EXCLUDED_ENTRIES` names at its `.canon`
112
- * spelling only.
110
+ * `SCRATCH` is the retired `.tmp` spelling. It is a guard rather than a lookup,
111
+ * keeping a stray `.tmp` out of a push, and `EXCLUDED_ENTRIES` names the
112
+ * `.canon` spelling only.
113
113
  */
114
114
  function isExcluded(name: string): boolean {
115
115
  return (
@@ -228,9 +228,9 @@ const PLAN_REQUIRED: readonly PlanSection[] = [
228
228
  ]
229
229
 
230
230
  /**
231
- * The files whose edit changes what a person sees, the set the `ui/a11y` rule
232
- * globs plus the other markup and style extensions a plan names. A plan touching
233
- * one owes a `Design references` section, so a set too wide forces it on every
231
+ * The files whose edit changes what a person sees, the set the `ui/a11y` and
232
+ * `ui/keyboard` rules glob plus the other markup and style extensions a plan
233
+ * names. A plan touching one owes a `Design references` section, so a set too wide forces it on every
234
234
  * plan and one too narrow lets a UI plan skip it.
235
235
  */
236
236
  const RENDERED_SURFACE = /\.(?:tsx|jsx|astro|vue|svelte|html|css|scss)$/
@@ -29,17 +29,22 @@ export type RecordRoot = (typeof RECORD_ROOTS)[number]
29
29
  export const CREATION_ROOT: RecordRoot = '.canon'
30
30
 
31
31
  /**
32
- * The deletable scratch folder, named at the spelling `.claude/` gives it.
32
+ * The deletable scratch folder, named at the spelling `.claude/` gave it.
33
33
  *
34
- * It is the one folder whose name differs by root, and callers name it at the
35
- * old spelling because `spell` is the only place the variant is decided. Inside
36
- * a dotted root the leading dot hides nothing already hidden and costs a bare
37
- * `ls` that omits the folder, so the move dropped it. Every other record folder
38
- * keeps its name, `.records.git` included, where the dot marks the mechanism
39
- * apart from a payload rather than hiding it.
34
+ * Scratch resolves at `.canon/tmp` only. The `.claude/.tmp` spelling is a
35
+ * migration source, named through `LEGACY_SCRATCH_REL` by the verbs that move
36
+ * it, and no lookup falls back to it. Callers still name the folder at this
37
+ * spelling because `spell` is the only place the variant is decided. Inside a
38
+ * dotted root the leading dot hides nothing already hidden and costs a bare
39
+ * `ls` that omits the folder, so the move dropped it. Every other record
40
+ * folder keeps its name, `.records.git` included, where the dot marks the
41
+ * mechanism apart from a payload rather than hiding it.
40
42
  */
41
43
  export const SCRATCH = '.tmp'
42
44
 
45
+ /** The retired scratch location, read only as the source of a migration. */
46
+ export const LEGACY_SCRATCH_REL = '.claude/.tmp' // canon-keep-record-root
47
+
43
48
  /** The scratch folder's name under `.canon/`. */
44
49
  const CANON_SCRATCH = 'tmp'
45
50
 
@@ -109,6 +114,8 @@ export function spell(root: RecordRoot, folder: string): string {
109
114
  * records it belongs to rather than at the creation default.
110
115
  */
111
116
  function rootOf(root: string, folder: string): RecordRoot {
117
+ if (folder === SCRATCH) return CREATION_ROOT
118
+
112
119
  return (
113
120
  RECORD_ROOTS.find((candidate) =>
114
121
  existsSync(join(root, candidate, spell(candidate, folder))),
@@ -147,7 +154,9 @@ export function recordDirs(
147
154
  folder: string,
148
155
  ...rest: string[]
149
156
  ): string[] {
150
- return RECORD_ROOTS.map((candidate) =>
157
+ const candidates = folder === SCRATCH ? [CREATION_ROOT] : RECORD_ROOTS
158
+
159
+ return candidates.map((candidate) =>
151
160
  join(root, candidate, spell(candidate, folder), ...rest),
152
161
  )
153
162
  }
@@ -19,6 +19,12 @@ export interface ClaimReport {
19
19
  readonly refs: readonly string[]
20
20
  /** False when the session roster could not be read, so `claimed` covers the readings around it alone and cannot be trusted as a clean "unclaimed". */
21
21
  readonly sessionsReadable: boolean
22
+ /**
23
+ * Foreign-namespace records whose socket is gone. They read dead, so a branch
24
+ * one of them holds can read unclaimed, which a caller with a count above
25
+ * zero should treat as unverified.
26
+ */
27
+ readonly foreignDead: number
22
28
  /** False when the ref read failed, which is the same false clean under a different reading. */
23
29
  readonly refsReadable: boolean
24
30
  }
@@ -80,6 +86,7 @@ export async function checkClaim(
80
86
  sessions,
81
87
  refs: refs.refs,
82
88
  sessionsReadable: report.kind === 'resolved',
89
+ foreignDead: report.kind === 'resolved' ? report.foreignDead : 0,
83
90
  refsReadable: refs.readable,
84
91
  }
85
92
  }
@@ -1,4 +1,4 @@
1
- import { readFileSync } from 'node:fs'
1
+ import { existsSync, readFileSync, readlinkSync } from 'node:fs'
2
2
  import type { SessionRecord } from '@/sessions/registry'
3
3
 
4
4
  /**
@@ -7,13 +7,19 @@ import type { SessionRecord } from '@/sessions/registry'
7
7
  * `confirmed` means the running process was matched against the start time the
8
8
  * record stamped, so the pid belongs to the session that wrote the file.
9
9
  * `unverified` means the pid answers a signal probe and nothing more, which
10
- * cannot separate the original session from whatever inherited its pid.
10
+ * cannot separate the original session from whatever inherited its pid, or,
11
+ * for a `foreign` row, that its messaging socket still exists.
11
12
  */
12
13
  export type Confidence = 'confirmed' | 'unverified'
13
14
 
14
15
  export interface Liveness {
15
16
  readonly alive: boolean
16
17
  readonly confidence: Confidence
18
+ /**
19
+ * Set only when the record belongs to another pid namespace, so the pid was
20
+ * never probed. Absent rather than false on every other path.
21
+ */
22
+ readonly foreign?: true
17
23
  }
18
24
 
19
25
  export interface LivenessProbes {
@@ -21,6 +27,14 @@ export interface LivenessProbes {
21
27
  readonly procStartOf: (pid: number) => string | null
22
28
  /** Whether the pid answers at all, ignoring which process holds it. */
23
29
  readonly responds: (pid: number) => boolean
30
+ /**
31
+ * The reader's own pid namespace in `pid:[inode]` form, or null where it
32
+ * cannot be read. Optional so a caller with no use for the foreign branch,
33
+ * such as the gate's lock, states nothing and takes the pid path.
34
+ */
35
+ readonly ownDomain?: () => string | null
36
+ /** Whether the messaging socket a foreign record names still exists. */
37
+ readonly socketExists?: (path: string) => boolean
24
38
  }
25
39
 
26
40
  /**
@@ -51,7 +65,21 @@ function responds(pid: number): boolean {
51
65
  }
52
66
  }
53
67
 
54
- export const SYSTEM_PROBES: LivenessProbes = { procStartOf, responds }
68
+ /** The link target reads `pid:[inode]`, which a record's `pidDomain` ends with. */
69
+ function ownDomain(): string | null {
70
+ try {
71
+ return readlinkSync('/proc/self/ns/pid')
72
+ } catch {
73
+ return null
74
+ }
75
+ }
76
+
77
+ export const SYSTEM_PROBES: LivenessProbes = {
78
+ procStartOf,
79
+ responds,
80
+ ownDomain,
81
+ socketExists: existsSync,
82
+ }
55
83
 
56
84
  /**
57
85
  * Decides whether a record describes a session that is still running.
@@ -62,13 +90,32 @@ export const SYSTEM_PROBES: LivenessProbes = { procStartOf, responds }
62
90
  * probe alone stands, and every surviving row is marked so a caller never reads
63
91
  * a recycled pid as a confirmed identity.
64
92
  *
65
- * It reads two fields and asks for no more, so a record that is not a session,
66
- * such as the gate's machine lock, carries the same proof.
93
+ * A record from another pid namespace is judged first, on its messaging
94
+ * socket, because its pid names nothing in this namespace and either probe
95
+ * would read an unrelated process or none. That answer is always `unverified`,
96
+ * since a crashed session leaves its socket behind.
97
+ *
98
+ * The pid and start time are the only fields the rest needs, so a record that
99
+ * is not a session, such as the gate's machine lock, carries the same proof and
100
+ * takes the pid path for want of a `pidDomain`.
67
101
  */
68
102
  export function liveness(
69
- record: Pick<SessionRecord, 'pid' | 'procStart'>,
103
+ record: Pick<SessionRecord, 'pid' | 'procStart'> &
104
+ Partial<Pick<SessionRecord, 'pidDomain' | 'messagingSocketPath'>>,
70
105
  probes: LivenessProbes = SYSTEM_PROBES,
71
106
  ): Liveness {
107
+ const own = probes.ownDomain?.() ?? null
108
+ if (
109
+ own !== null &&
110
+ record.pidDomain !== undefined &&
111
+ !record.pidDomain.endsWith(own)
112
+ ) {
113
+ const socket = record.messagingSocketPath
114
+ const alive =
115
+ socket !== undefined && (probes.socketExists?.(socket) ?? false)
116
+ return { alive, confidence: 'unverified', foreign: true }
117
+ }
118
+
72
119
  const started = record.procStart
73
120
 
74
121
  if (started !== undefined && started.length > 0) {
@@ -45,6 +45,17 @@ export interface SessionRecord {
45
45
  * been handed to something else.
46
46
  */
47
47
  readonly procStart: string | undefined
48
+ /**
49
+ * The pid namespace the client stamped, ending in the `pid:[inode]` form that
50
+ * `/proc/self/ns/pid` reads as. A reader in another namespace cannot see the
51
+ * record's pid, so this is what tells a foreign row from a dead one.
52
+ */
53
+ readonly pidDomain: string | undefined
54
+ /**
55
+ * The socket the session listens on for messages. It is the only evidence of
56
+ * life a foreign-namespace reader can reach, since the pid is out of sight.
57
+ */
58
+ readonly messagingSocketPath: string | undefined
48
59
  }
49
60
 
50
61
  /**
@@ -22,6 +22,12 @@ export interface ResolvedSession {
22
22
  readonly cwd: string
23
23
  readonly kind: string
24
24
  readonly status: string
25
+ /**
26
+ * What decided the row was live. `socket` means the record belongs to another
27
+ * pid namespace and only its messaging socket answered, so the row can be a
28
+ * crashed session's leftover and no start time backs it.
29
+ */
30
+ readonly via: 'pid' | 'socket'
25
31
  readonly startedAt: string | null
26
32
  /**
27
33
  * The stamp the record carries beside `status`, kept alongside the dwell it
@@ -64,6 +70,13 @@ export type SessionReport =
64
70
  * tells a caller whether the roster can be trusted as an identity.
65
71
  */
66
72
  readonly confidence: Confidence
73
+ /**
74
+ * Foreign-namespace records whose socket is gone. They read dead, but the
75
+ * reader could not check their pid, so an empty roster beside a count
76
+ * above zero is a roster that could not be checked rather than one that
77
+ * ended.
78
+ */
79
+ readonly foreignDead: number
67
80
  readonly sessions: readonly ResolvedSession[]
68
81
  }
69
82
 
@@ -167,6 +180,7 @@ function present(
167
180
  record: SessionRecord,
168
181
  located: Located,
169
182
  now: number,
183
+ via: ResolvedSession['via'],
170
184
  ): ResolvedSession {
171
185
  return {
172
186
  name: record.name,
@@ -175,6 +189,7 @@ function present(
175
189
  cwd: record.cwd,
176
190
  kind: record.kind ?? 'unknown',
177
191
  status: record.status ?? 'unknown',
192
+ via,
178
193
  startedAt:
179
194
  record.startedAt === undefined
180
195
  ? null
@@ -213,21 +228,33 @@ export async function resolveSessions(
213
228
 
214
229
  if (registry.kind === 'absent') return { kind: 'absent', dir: registry.dir }
215
230
 
216
- const live: SessionRecord[] = []
231
+ const live: { record: SessionRecord; via: ResolvedSession['via'] }[] = []
217
232
  let confidence: Confidence = 'confirmed'
233
+ let foreignDead = 0
218
234
 
219
235
  for (const record of registry.records) {
220
236
  const state = liveness(record, probes)
221
- if (!state.alive) continue
237
+ if (!state.alive) {
238
+ if (state.foreign) foreignDead += 1
239
+ continue
240
+ }
222
241
  if (state.confidence === 'unverified') confidence = 'unverified'
223
- live.push(record)
242
+ live.push({ record, via: state.foreign ? 'socket' : 'pid' })
224
243
  }
225
244
 
226
245
  const sessions = await Promise.all(
227
- live.map(async (record) => present(record, await find(record.cwd), now)),
246
+ live.map(async ({ record, via }) =>
247
+ present(record, await find(record.cwd), now, via),
248
+ ),
228
249
  )
229
250
 
230
- return { kind: 'resolved', dir: registry.dir, confidence, sessions }
251
+ return {
252
+ kind: 'resolved',
253
+ dir: registry.dir,
254
+ confidence,
255
+ foreignDead,
256
+ sessions,
257
+ }
231
258
  }
232
259
 
233
260
  /** The environment slice the caller's own identity is read from. */
@@ -0,0 +1,136 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import {
3
+ fenceMask,
4
+ readOutcomes,
5
+ readPendingBranches,
6
+ readPullRequest,
7
+ } from '@/tasks/archive'
8
+ import {
9
+ openTask,
10
+ type RecordRefused,
11
+ type RecordSelector,
12
+ } from '@/tasks/record'
13
+
14
+ /**
15
+ * Why a task does or does not close its issues. `last-slice` is the only
16
+ * reason that closes. The rest mirror the conditions `archiveTask` applies at
17
+ * merge time, so an issue closes exactly when the task archives.
18
+ */
19
+ export type ClosingReason =
20
+ | 'earlier-slice'
21
+ | 'last-slice'
22
+ | 'lookup-failed'
23
+ | 'no-issues'
24
+ | 'no-outcomes'
25
+ | 'open-outcomes'
26
+ | 'pending-branch'
27
+
28
+ export interface Closure {
29
+ readonly issues: readonly number[]
30
+ readonly closing: boolean
31
+ readonly reason: ClosingReason
32
+ }
33
+
34
+ export interface TaskClosure extends Closure {
35
+ readonly ok: true
36
+ readonly stem: string
37
+ readonly path: string
38
+ }
39
+
40
+ export type ClosureOutcome = TaskClosure | RecordRefused
41
+
42
+ const ISSUE_LINE = /^Issue:[ \t]*(#\d+(?:[ \t]*,[ \t]*#\d+)*)[ \t]*$/
43
+
44
+ /**
45
+ * Reads every `Issue:` line outside a fence, each a bare `#NNN` or a comma
46
+ * list of them. A line carrying anything else, such as the older bare-path
47
+ * form, names no issue rather than the numbers that happened to parse, since a
48
+ * wrong number would close an unrelated issue.
49
+ */
50
+ export function readIssues(text: string): readonly number[] {
51
+ const lines = text.split('\n')
52
+ const fenced = fenceMask(lines)
53
+ const issues = new Set<number>()
54
+
55
+ for (const [index, line] of lines.entries()) {
56
+ if (fenced[index]) continue
57
+
58
+ const match = ISSUE_LINE.exec(line)
59
+ if (!match) continue
60
+
61
+ for (const entry of match[1].split(',')) {
62
+ issues.add(Number(entry.trim().slice(1)))
63
+ }
64
+ }
65
+
66
+ return [...issues]
67
+ }
68
+
69
+ /**
70
+ * Decides whether the pull request for `branches` closes the task's issues.
71
+ * `branches` is the working branch and every name it carried, since a rename
72
+ * leaves the `Pending branch:` marker under the old one. Any other pending
73
+ * branch is a slice still to ship, so the task stays open behind it.
74
+ */
75
+ export function decideClosing(
76
+ text: string,
77
+ branches: readonly string[],
78
+ pulls: readonly number[] | null = [],
79
+ ): Closure {
80
+ const issues = readIssues(text)
81
+ if (issues.length === 0) {
82
+ return { issues, closing: false, reason: 'no-issues' }
83
+ }
84
+
85
+ const listed = readPullRequest(text)
86
+ const last = listed.at(-1)
87
+
88
+ // A failed lookup cannot rule out that this branch is an earlier slice, and
89
+ // closing early is the unsafe direction. With nothing recorded yet there is
90
+ // no earlier slice to confuse it with, so that case still closes.
91
+ if (pulls === null && listed.length > 0) {
92
+ return { issues, closing: false, reason: 'lookup-failed' }
93
+ }
94
+
95
+ if (
96
+ last !== undefined &&
97
+ pulls !== null &&
98
+ !pulls.includes(last) &&
99
+ pulls.some((number) => listed.includes(number))
100
+ ) {
101
+ return { issues, closing: false, reason: 'earlier-slice' }
102
+ }
103
+
104
+ const others = readPendingBranches(text).filter(
105
+ (branch) => !branches.includes(branch),
106
+ )
107
+ if (others.length > 0) {
108
+ return { issues, closing: false, reason: 'pending-branch' }
109
+ }
110
+
111
+ const { open, closed, cut } = readOutcomes(text)
112
+ if (open.length > 0) {
113
+ return { issues, closing: false, reason: 'open-outcomes' }
114
+ }
115
+
116
+ if (closed.length === 0 && cut.length === 0) {
117
+ return { issues, closing: false, reason: 'no-outcomes' }
118
+ }
119
+
120
+ return { issues, closing: true, reason: 'last-slice' }
121
+ }
122
+
123
+ /** Resolves the task through the same selector `pull-request` takes. */
124
+ export async function readClosure(
125
+ root: string,
126
+ selector: RecordSelector,
127
+ branches: readonly string[],
128
+ pulls: readonly number[] | null = [],
129
+ ): Promise<ClosureOutcome> {
130
+ const opened = await openTask(root, selector)
131
+ if ('ok' in opened) return opened
132
+
133
+ const text = await readFile(opened.path, 'utf8')
134
+
135
+ return { ok: true, ...opened, ...decideClosing(text, branches, pulls) }
136
+ }
@@ -400,7 +400,7 @@ async function resolveStem(
400
400
  return matched[0]
401
401
  }
402
402
 
403
- async function openTask(
403
+ export async function openTask(
404
404
  root: string,
405
405
  selector: RecordSelector,
406
406
  ): Promise<{ readonly stem: string; readonly path: string } | RecordRefused> {
@@ -16,6 +16,8 @@ import {
16
16
  import {
17
17
  applyScripts,
18
18
  collectDeps,
19
+ declaresInstalls,
20
+ minimalPackage,
19
21
  readPackage,
20
22
  serializePackage,
21
23
  } from '@/tooling/package'
@@ -156,15 +158,22 @@ export async function pruneGitignore(
156
158
 
157
159
  /**
158
160
  * Installs missing dev dependencies, fills package scripts, and merges
159
- * gitignore entries. A target with no package.json gets the gitignore alone,
160
- * plus a warning naming every install it skipped, since seeding a package.json
161
- * would pick a package manager and a project name on the target's behalf.
161
+ * gitignore entries. A target with no package.json gets a minimal one first
162
+ * when the chain installs anything. A package.json that exists but cannot be
163
+ * parsed is never rewritten, and the install is skipped with a warning.
162
164
  */
163
165
  export async function injectManifest(
164
166
  chain: readonly Manifest[],
165
167
  target: string,
166
168
  ): Promise<void> {
167
169
  const packagePath = join(target, 'package.json')
170
+
171
+ if (!existsSync(packagePath) && declaresInstalls(chain)) {
172
+ logStep('Writing package.json')
173
+ await writeFile(packagePath, serializePackage(minimalPackage(target)))
174
+ logAdd('package.json')
175
+ }
176
+
168
177
  const pkg = readPackage(packagePath)
169
178
 
170
179
  if (pkg) {
@@ -192,14 +201,22 @@ export async function injectManifest(
192
201
 
193
202
  await injectGitignore(chain, target)
194
203
 
195
- if (!pkg) warnSkippedInstalls(chain, target)
204
+ if (!pkg) warnUnreadablePackage(chain, target)
196
205
  }
197
206
 
198
- function warnSkippedInstalls(chain: readonly Manifest[], target: string): void {
207
+ /**
208
+ * Only an existing package.json that fails to parse reaches here with installs
209
+ * pending, since an absent one was written above. It is left untouched.
210
+ */
211
+ function warnUnreadablePackage(
212
+ chain: readonly Manifest[],
213
+ target: string,
214
+ ): void {
215
+ if (!declaresInstalls(chain)) return
216
+
199
217
  const deps = collectDeps(chain, {}).map((dep) => dep.spec)
200
218
  const skipped = applyScripts({}, chain)
201
219
  const scripts = [...new Set([...skipped.added, ...skipped.overridden])]
202
- if (deps.length === 0 && scripts.length === 0) return
203
220
 
204
221
  logStep('Skipped installs')
205
222
  if (deps.length > 0) {
@@ -208,5 +225,7 @@ function warnSkippedInstalls(chain: readonly Manifest[], target: string): void {
208
225
  if (scripts.length > 0) {
209
226
  logWarn(`Scripts not added: ${scripts.join(', ')}`)
210
227
  }
211
- logWarn(`No package.json: run 'bun init' in ${target}, then sync again`)
228
+ logWarn(
229
+ `package.json is not valid JSON: fix it in ${target}, then sync again`,
230
+ )
212
231
  }
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
+ import { basename } from 'node:path'
2
3
  import type { Manifest } from '@/tooling/manifest'
3
4
 
4
5
  export interface PackageJson {
@@ -28,6 +29,37 @@ export function readPackage(path: string): PackageJson | undefined {
28
29
  }
29
30
  }
30
31
 
32
+ /**
33
+ * Whether the chain asks for any dev dependency or script, which is what makes
34
+ * a missing package.json worth writing. A stack that installs nothing, such as
35
+ * the one `canon claude` drives, leaves a project without a manifest alone.
36
+ */
37
+ export function declaresInstalls(chain: readonly Manifest[]): boolean {
38
+ if (collectDeps(chain, {}).length > 0) return true
39
+
40
+ const scripts = applyScripts({}, chain)
41
+ return scripts.added.length > 0 || scripts.overridden.length > 0
42
+ }
43
+
44
+ /**
45
+ * The manifest a sync writes into a folder that has none. The name is the
46
+ * folder's, reduced to the characters npm accepts, so it is never empty or
47
+ * invalid.
48
+ */
49
+ export function minimalPackage(target: string): PackageJson {
50
+ const name = basename(target)
51
+ .toLowerCase()
52
+ .replace(/[^a-z0-9-._]/g, '-')
53
+ .replace(/^[._]+/, '')
54
+ .slice(0, 214)
55
+
56
+ return {
57
+ name: name === '' ? 'project' : name,
58
+ version: '0.0.0',
59
+ type: 'module',
60
+ }
61
+ }
62
+
31
63
  export function serializePackage(pkg: PackageJson): string {
32
64
  return `${JSON.stringify(pkg, null, 2)}\n`
33
65
  }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { PROJECT_ROOT } from '@/roots/project'
4
+ import { configFiles, type Manifest, seedFiles } from '@/tooling/manifest'
4
5
 
5
6
  /**
6
7
  * Spells the package root in a report where every other root spells a
@@ -11,6 +12,8 @@ import { PROJECT_ROOT } from '@/roots/project'
11
12
  const PACKAGE_LABEL = '<canon>'
12
13
 
13
14
  export interface ReferenceRoot {
15
+ /** The root whose `tooling/` this searches, as the manifest loader takes it. */
16
+ readonly root: string
14
17
  /** Absolute `tooling/` directory to search. */
15
18
  readonly dir: string
16
19
  /** How a report spells a copy found under this root. */
@@ -19,6 +22,8 @@ export interface ReferenceRoot {
19
22
 
20
23
  export interface ResolvedReference {
21
24
  readonly path: string
25
+ /** The root that won, so the manifest is read from the same copy. */
26
+ readonly root: string
22
27
  /** The label of the root that won, joined to the stack and filename. */
23
28
  readonly source: string
24
29
  }
@@ -36,8 +41,9 @@ function toolingSourceDir(root: string): string {
36
41
  */
37
42
  export function referenceRoots(root: string): ReferenceRoot[] {
38
43
  return [
39
- { dir: toolingSourceDir(root), label: 'tooling' },
44
+ { root, dir: toolingSourceDir(root), label: 'tooling' },
40
45
  {
46
+ root: PROJECT_ROOT,
41
47
  dir: toolingSourceDir(PROJECT_ROOT),
42
48
  label: join(PACKAGE_LABEL, 'tooling'),
43
49
  },
@@ -49,10 +55,10 @@ export function resolveReference(
49
55
  root: string,
50
56
  stack: string,
51
57
  ): ResolvedReference | undefined {
52
- for (const { dir, label } of referenceRoots(root)) {
58
+ for (const { root: owner, dir, label } of referenceRoots(root)) {
53
59
  const path = join(dir, stack, 'reference.md')
54
60
  if (existsSync(path)) {
55
- return { path, source: join(label, stack, 'reference.md') }
61
+ return { path, root: owner, source: join(label, stack, 'reference.md') }
56
62
  }
57
63
  }
58
64
 
@@ -62,3 +68,40 @@ export function resolveReference(
62
68
  export function readReference(reference: ResolvedReference): string {
63
69
  return readFileSync(reference.path, 'utf8')
64
70
  }
71
+
72
+ function codeList(items: readonly string[]): string {
73
+ return items.map((item) => `- \`${item}\``).join('\n')
74
+ }
75
+
76
+ /**
77
+ * The sections the verb prints after a stack's reference, read from its
78
+ * manifest so no reference keeps a hand list that drifts from it. A stack lists
79
+ * its own files and groups alone, since a parent's print under the parent's
80
+ * reference, and a section with nothing to list prints no heading.
81
+ */
82
+ export function renderStackSections(manifest: Manifest): string {
83
+ const sections: string[] = []
84
+ const configs = configFiles(manifest)
85
+ const seeds = seedFiles(manifest)
86
+
87
+ if (configs.length > 0 || seeds.length > 0) {
88
+ const sync = ['## Sync']
89
+ if (configs.length > 0) {
90
+ sync.push('Sync keeps these current, from `configs/`:', codeList(configs))
91
+ }
92
+ if (seeds.length > 0) {
93
+ sync.push('Sync copies these once, from `seeds/`:', codeList(seeds))
94
+ }
95
+ sections.push(sync.join('\n\n'))
96
+ }
97
+
98
+ if (manifest.gitignore.length > 0) {
99
+ const groups = manifest.gitignore.map(
100
+ ({ header, entries }) =>
101
+ `- \`${header}\`: ${entries.map((entry) => `\`${entry}\``).join(', ')}`,
102
+ )
103
+ sections.push(`## Gitignore\n\n${groups.join('\n')}`)
104
+ }
105
+
106
+ return sections.map((section) => `\n${section}\n`).join('')
107
+ }