@ersbeth/codelife 0.0.0-bootstrap.0 → 0.11.1

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 (214) hide show
  1. package/.claude-plugin/plugin.json +19 -0
  2. package/LICENSE +21 -0
  3. package/README.md +66 -0
  4. package/agents/evaluate.md +97 -0
  5. package/agents/ground.md +49 -0
  6. package/agents/implementer.md +49 -0
  7. package/agents/judge.md +128 -0
  8. package/agents/planner.md +45 -0
  9. package/agents/prerequisites.md +62 -0
  10. package/agents/reader.md +40 -0
  11. package/agents/reproduce.md +65 -0
  12. package/bin/codelife +4 -0
  13. package/package.json +40 -1
  14. package/presets/tsconfig.json +14 -0
  15. package/presets/vite.config.d.mts +3 -0
  16. package/presets/vite.config.mjs +49 -0
  17. package/scripts/audit/cadence.mjs +24 -0
  18. package/scripts/audit/close.mjs +57 -0
  19. package/scripts/audit/file.mjs +70 -0
  20. package/scripts/audit/fold.mjs +48 -0
  21. package/scripts/audit/lib.mjs +93 -0
  22. package/scripts/audit/prepare.mjs +180 -0
  23. package/scripts/audit/table.mjs +65 -0
  24. package/scripts/brainstorm/decided.mjs +6 -0
  25. package/scripts/brainstorm/lib.mjs +33 -0
  26. package/scripts/brainstorm/named.mjs +9 -0
  27. package/scripts/changelog.mjs +100 -0
  28. package/scripts/check-tag.mjs +19 -0
  29. package/scripts/checks.mjs +53 -0
  30. package/scripts/codelife.mjs +42 -0
  31. package/scripts/commit-msg.mjs +43 -0
  32. package/scripts/contract.mjs +179 -0
  33. package/scripts/former-form.mjs +170 -0
  34. package/scripts/git/branches.mjs +6 -0
  35. package/scripts/git/commit.mjs +40 -0
  36. package/scripts/git/diff.mjs +17 -0
  37. package/scripts/git/diff_lines.mjs +5 -0
  38. package/scripts/git/held.mjs +53 -0
  39. package/scripts/git/is_ancestor.mjs +28 -0
  40. package/scripts/git/lib.mjs +20 -0
  41. package/scripts/git/log_since.mjs +27 -0
  42. package/scripts/git/parent.mjs +51 -0
  43. package/scripts/git/show.mjs +4 -0
  44. package/scripts/git/status.mjs +25 -0
  45. package/scripts/git/submodules.mjs +5 -0
  46. package/scripts/init.mjs +21 -0
  47. package/scripts/install.mjs +32 -0
  48. package/scripts/lenses/A11Y.md +91 -0
  49. package/scripts/lenses/ARCH.md +90 -0
  50. package/scripts/lenses/BUG.md +52 -0
  51. package/scripts/lenses/BUILD.md +40 -0
  52. package/scripts/lenses/COMPAT.md +67 -0
  53. package/scripts/lenses/CONF.md +49 -0
  54. package/scripts/lenses/DEPS.md +43 -0
  55. package/scripts/lenses/DOC.md +73 -0
  56. package/scripts/lenses/FAIL.md +57 -0
  57. package/scripts/lenses/GOV.md +56 -0
  58. package/scripts/lenses/GUARD.md +47 -0
  59. package/scripts/lenses/INTENT.md +52 -0
  60. package/scripts/lenses/LEAN.md +49 -0
  61. package/scripts/lenses/LINES.md +48 -0
  62. package/scripts/lenses/PERF.md +61 -0
  63. package/scripts/lenses/PKG.md +58 -0
  64. package/scripts/lenses/SEC.md +49 -0
  65. package/scripts/lenses/STANDARDS.md +62 -0
  66. package/scripts/lenses/TYPE.md +56 -0
  67. package/scripts/lenses/VERIF.md +81 -0
  68. package/scripts/link.mjs +40 -0
  69. package/scripts/methods/cause.md +21 -0
  70. package/scripts/methods/comments.md +157 -0
  71. package/scripts/methods/description.md +67 -0
  72. package/scripts/methods/message.md +24 -0
  73. package/scripts/methods/red-green.md +28 -0
  74. package/scripts/methods/refusal.md +22 -0
  75. package/scripts/mr/open.mjs +219 -0
  76. package/scripts/node-floor.mjs +19 -0
  77. package/scripts/onboard/prep.mjs +93 -0
  78. package/scripts/overview/codelife-line.mjs +33 -0
  79. package/scripts/overview/next.mjs +76 -0
  80. package/scripts/overview/overview.mjs +308 -0
  81. package/scripts/package-manager.mjs +20 -0
  82. package/scripts/port/item.mjs +94 -0
  83. package/scripts/port/lib.mjs +194 -0
  84. package/scripts/port/prep.mjs +33 -0
  85. package/scripts/port/version.mjs +50 -0
  86. package/scripts/pre-commit.mjs +43 -0
  87. package/scripts/pre-push.mjs +53 -0
  88. package/scripts/prepare-lib.mjs +81 -0
  89. package/scripts/read-roadmap/lib.mjs +116 -0
  90. package/scripts/read-roadmap/moved.mjs +70 -0
  91. package/scripts/read-roadmap/range.mjs +15 -0
  92. package/scripts/release/cut.mjs +120 -0
  93. package/scripts/release/entry.mjs +52 -0
  94. package/scripts/release/lib.mjs +136 -0
  95. package/scripts/release/prep.mjs +58 -0
  96. package/scripts/review-mr/prepare.mjs +163 -0
  97. package/scripts/roadmap/assemble.mjs +269 -0
  98. package/scripts/roadmap/cut.mjs +88 -0
  99. package/scripts/roadmap/prepare.mjs +249 -0
  100. package/scripts/roadmap/roadmap.md +30 -0
  101. package/scripts/roadmap/write.mjs +53 -0
  102. package/scripts/search.mjs +29 -0
  103. package/scripts/setup/contract.mjs +25 -0
  104. package/scripts/setup/item.mjs +166 -0
  105. package/scripts/setup/lib.mjs +205 -0
  106. package/scripts/setup/prep.mjs +106 -0
  107. package/scripts/spec-form.mjs +444 -0
  108. package/scripts/spec.mjs +56 -0
  109. package/scripts/tags.mjs +27 -0
  110. package/scripts/target/target-app.md +134 -0
  111. package/scripts/target/target-base.md +127 -0
  112. package/scripts/target/target-lib.md +143 -0
  113. package/scripts/target/templates/audit/README.md +21 -0
  114. package/scripts/target/templates/characterisation/bundle.test.ts +43 -0
  115. package/scripts/target/templates/characterisation/declarations.test.ts +44 -0
  116. package/scripts/target/templates/characterisation/entries.ts +50 -0
  117. package/scripts/target/templates/characterisation/examples.test.ts +18 -0
  118. package/scripts/target/templates/characterisation/import.test.ts +22 -0
  119. package/scripts/target/templates/characterisation/surface.test.ts +68 -0
  120. package/scripts/target/templates/cliff.toml +61 -0
  121. package/scripts/target/templates/githooks/commit-msg +9 -0
  122. package/scripts/target/templates/githooks/pre-commit +9 -0
  123. package/scripts/target/templates/githooks/pre-push +9 -0
  124. package/scripts/target/templates/playground/index.html +11 -0
  125. package/scripts/target/templates/playground/main.ts +9 -0
  126. package/scripts/target/templates/spec/README.md +35 -0
  127. package/scripts/target/templates/spec/capability.md +36 -0
  128. package/scripts/target/templates/spec/glossary.md +5 -0
  129. package/scripts/target/templates/spec/journey.md +29 -0
  130. package/scripts/target/templates/spec/principles.md +15 -0
  131. package/scripts/target/templates/spec/users.md +15 -0
  132. package/scripts/target/templates/tsconfig.json +9 -0
  133. package/scripts/target/templates/vite.config.ts +4 -0
  134. package/scripts/text.mjs +6 -0
  135. package/scripts/tracker/account.mjs +5 -0
  136. package/scripts/tracker/auth.mjs +9 -0
  137. package/scripts/tracker/cli.version.mjs +5 -0
  138. package/scripts/tracker/close.mjs +20 -0
  139. package/scripts/tracker/create.mjs +47 -0
  140. package/scripts/tracker/issue.section.mjs +25 -0
  141. package/scripts/tracker/issue.update.mjs +13 -0
  142. package/scripts/tracker/issue.view.mjs +16 -0
  143. package/scripts/tracker/lib.mjs +130 -0
  144. package/scripts/tracker/list.mjs +14 -0
  145. package/scripts/tracker/list.whole.mjs +20 -0
  146. package/scripts/tracker/lot.row.mjs +42 -0
  147. package/scripts/tracker/mr.checkout_command.mjs +5 -0
  148. package/scripts/tracker/mr.comments.mjs +50 -0
  149. package/scripts/tracker/mr.create.mjs +12 -0
  150. package/scripts/tracker/mr.description.mjs +8 -0
  151. package/scripts/tracker/mr.fetch_head.mjs +5 -0
  152. package/scripts/tracker/mr.find_for_branch.mjs +9 -0
  153. package/scripts/tracker/mr.post.mjs +40 -0
  154. package/scripts/tracker/mr.update_description.mjs +8 -0
  155. package/scripts/tracker/mr.verdict.mjs +34 -0
  156. package/scripts/tracker/mr.view.mjs +20 -0
  157. package/scripts/tracker/none.mjs +79 -0
  158. package/scripts/tracker/note.mjs +12 -0
  159. package/scripts/tracker/reopen.mjs +8 -0
  160. package/scripts/tracker/search.mjs +8 -0
  161. package/scripts/tracker/search_id.mjs +18 -0
  162. package/scripts/tracker/templates/bug.md +35 -0
  163. package/scripts/tracker/templates/feature.md +40 -0
  164. package/scripts/tracker/templates/lot.md +46 -0
  165. package/scripts/tracker/title.join.mjs +6 -0
  166. package/scripts/tracker/title.leave.mjs +7 -0
  167. package/scripts/tracker/title.lot_of.mjs +8 -0
  168. package/scripts/tracker/title.search_id_filter.mjs +17 -0
  169. package/scripts/tracker/title.sentence_of.mjs +5 -0
  170. package/scripts/tracker/view.mjs +25 -0
  171. package/scripts/verbs.mjs +579 -0
  172. package/scripts/verify/BUILD.mjs +33 -0
  173. package/scripts/verify/DEPS.mjs +15 -0
  174. package/scripts/verify/GOV.mjs +34 -0
  175. package/scripts/verify/GUARD.mjs +34 -0
  176. package/scripts/verify/LINES.mjs +22 -0
  177. package/scripts/verify/PKG.mjs +56 -0
  178. package/scripts/verify/TYPE.mjs +34 -0
  179. package/scripts/verify/cut.mjs +73 -0
  180. package/scripts/verify/history.mjs +32 -0
  181. package/scripts/verify/lib.mjs +129 -0
  182. package/scripts/verify/report.mjs +104 -0
  183. package/scripts/verify/timing.mjs +19 -0
  184. package/scripts/verify.mjs +29 -0
  185. package/scripts/work/branch.mjs +81 -0
  186. package/scripts/work/close.mjs +74 -0
  187. package/scripts/work/member.mjs +73 -0
  188. package/scripts/work/prepare.mjs +116 -0
  189. package/scripts/work/reopen.mjs +40 -0
  190. package/scripts/work-feedback/prepare.mjs +96 -0
  191. package/skills/audit/SKILL.md +110 -0
  192. package/skills/audit/templates/index.md +36 -0
  193. package/skills/audit/templates/lens.md +44 -0
  194. package/skills/brainstorm/SKILL.md +144 -0
  195. package/skills/brainstorm/references/exit.md +57 -0
  196. package/skills/brainstorm/templates/note.md +79 -0
  197. package/skills/commit/SKILL.md +68 -0
  198. package/skills/mr/SKILL.md +82 -0
  199. package/skills/new-issue/SKILL.md +87 -0
  200. package/skills/onboard/SKILL.md +103 -0
  201. package/skills/onboard/templates/CLAUDE.md +31 -0
  202. package/skills/onboard/templates/CONTRIBUTING.md +34 -0
  203. package/skills/onboard/templates/README.md +30 -0
  204. package/skills/onboard/templates/SKILL.md +24 -0
  205. package/skills/onboard/templates/package-CLAUDE.md +15 -0
  206. package/skills/overview/SKILL.md +59 -0
  207. package/skills/port-toolchain/SKILL.md +127 -0
  208. package/skills/release/SKILL.md +78 -0
  209. package/skills/review-mr/SKILL.md +106 -0
  210. package/skills/review-mr/templates/pass.md +29 -0
  211. package/skills/roadmap/SKILL.md +137 -0
  212. package/skills/setup/SKILL.md +112 -0
  213. package/skills/work/SKILL.md +139 -0
  214. package/skills/work-feedback/SKILL.md +113 -0
@@ -0,0 +1,444 @@
1
+ #!/usr/bin/env node
2
+ // Holds the form of docs/spec/ and of the notes folded into it, run from the repository's root:
3
+ // `node node_modules/@ersbeth/codelife/scripts/spec-form.mjs spec`, `note <note>` before its writing, or
4
+ // `fold <note> [--base <rev>]` after it; `cases` prints what a plan is cut from and holds nothing.
5
+ // A blocking rule prints `<file>:<line>: <rule>: <what is wrong>` and the exit code is 1; a report
6
+ // prints the same way and changes nothing. No dependency, no build: built-in modules only.
7
+ import './node-floor.mjs'
8
+ import fs from 'node:fs'
9
+ import path from 'node:path'
10
+ import { execFileSync } from 'node:child_process'
11
+ import { lf } from './text.mjs'
12
+
13
+ const SPEC = 'docs/spec'
14
+ const VERBS = ['creates', 'changes', 'retires', 'renames']
15
+ const ENTRIES = ['README.md', 'glossary.md', 'journeys', 'principles.md', 'product', 'system', 'users.md']
16
+ const CLOSING = ['What stays open', 'Rejected']
17
+ const LETTER = { persona: 'P', journey: 'J', capability: 'K', rule: 'R', case: 'C' }
18
+ const SECTIONS = {
19
+ capability: ['What it is for, and for whom', 'Behaviour', 'Internal design', ...CLOSING],
20
+ journey: ['Steps', 'Success criteria', 'Out of this journey', ...CLOSING],
21
+ }
22
+
23
+ let red = false
24
+ const asGiven = new Set() // a note is named by its own path; a document by its path under docs/spec/
25
+ const say = (file, line, rule, what) => console.log(`${asGiven.has(file) ? file : `${SPEC}/${file}`}:${line}: ${rule}: ${what}`)
26
+ const block = (...a) => { red = true; say(...a) }
27
+
28
+ // An address, `<path> § <heading>` in a code span — between double backticks when the heading
29
+ // holds code, as CommonMark writes such a span. `addressed` reads either form from a match whose
30
+ // first group is the path.
31
+ const ADDRESS = '``([^`§]*?\\.md) §\\s+((?:[^`]|`(?!`))+?)``|`([^`§]*?\\.md) §\\s+([^`]+)`'
32
+ const addressed = (m, at = 1) => ({
33
+ file: path.posix.normalize((m[at] || m[at + 2]).trim().replace(/^docs\/spec\//, '')),
34
+ heading: (m[at + 1] || m[at + 3]).replace(/\s+/g, ' ').trim(),
35
+ })
36
+
37
+ const git = (...args) => execFileSync('git', args, { encoding: 'utf8', maxBuffer: 1 << 28, stdio: ['ignore', 'pipe', 'ignore'] })
38
+
39
+ // Every file under a directory, as paths relative to it; a `.gitkeep` is not a document.
40
+ function walk(dir, under = '') {
41
+ return fs.readdirSync(path.join(dir, under), { withFileTypes: true }).flatMap((e) => {
42
+ const rel = path.posix.join(under, e.name)
43
+ return e.isDirectory() ? walk(dir, rel) : e.name === '.gitkeep' ? [] : [rel]
44
+ })
45
+ }
46
+
47
+ // A document: its text, its lines, and its headings outside fenced blocks — each with its level,
48
+ // its full text, the identifier that opens it when there is one, and the heading after it.
49
+ const read = (file, dir = SPEC) => parse(file, fs.readFileSync(path.join(dir, file), 'utf8'))
50
+
51
+ function parse(file, crlf) {
52
+ const text = lf(crlf), lines = text.split('\n')
53
+ const headings = []
54
+ let fenced = false
55
+ lines.forEach((l, i) => {
56
+ if (/^\s*```/.test(l)) fenced = !fenced
57
+ const h = !fenced && /^(#{1,6}) +(.+?) *$/.exec(l)
58
+ if (!h) return
59
+ const id = /^([PJKRC]-\d+) — (.+)$/.exec(h[2])
60
+ headings.push({ level: h[1].length, text: h[2], id: id && id[1], title: id ? id[2] : h[2], line: i + 1 })
61
+ })
62
+ return { file, text, lines, headings }
63
+ }
64
+
65
+ const kind = (file) =>
66
+ /^(product|system)\//.test(file) ? 'capability' : file.startsWith('journeys/') ? 'journey' : 'entry'
67
+
68
+ // The headings of one `##` section, and the line after its last one.
69
+ function section(doc, name) {
70
+ const from = doc.headings.findIndex((h) => h.level === 2 && h.text === name)
71
+ if (from < 0) return { headings: [], start: 0, end: 0 }
72
+ const next = doc.headings.findIndex((h, i) => i > from && h.level <= 2)
73
+ const inside = doc.headings.slice(from + 1, next < 0 ? undefined : next)
74
+ return { headings: inside, start: doc.headings[from].line, end: next < 0 ? doc.lines.length : doc.headings[next].line - 1 }
75
+ }
76
+
77
+ // The list items between two lines, a wrapped item joined into one string.
78
+ function items(doc, start, end) {
79
+ const out = []
80
+ for (let i = start; i < end; i++) {
81
+ const l = doc.lines[i]
82
+ if (/^- /.test(l)) out.push({ text: l.slice(2), line: i + 1 })
83
+ else if (/^\s+\S/.test(l) && out.length) out[out.length - 1].text += ' ' + l.trim()
84
+ }
85
+ return out
86
+ }
87
+
88
+ // A specification is in the form once its seven entries all exist. Until then it is being brought
89
+ // to it — or /setup just gave it its README.md — and nothing blocks, or a migration could not be
90
+ // committed; the commit that completes the seven is the first one held.
91
+ const formed = () => ENTRIES.every((e) => fs.existsSync(path.join(SPEC, e)))
92
+
93
+ // No entry beside the seven.
94
+ function sevenEntries() {
95
+ for (const n of fs.readdirSync(SPEC).filter((n) => !n.startsWith('.'))) {
96
+ if (!ENTRIES.includes(n)) block(n, 1, 'seven-entries', 'not one of the seven entries')
97
+ }
98
+ }
99
+
100
+ // A capability sits at <branch>/<domain>/<capability>.md and a journey at journeys/<journey>.md:
101
+ // the domain level is mandatory and never nested.
102
+ function domainLevel(files) {
103
+ for (const f of files) {
104
+ const depth = f.split('/').length
105
+ if (/^(product|system)\//.test(f) && depth !== 3) block(f, 1, 'domain-level', 'a capability is <branch>/<domain>/<capability>.md')
106
+ if (f.startsWith('journeys/') && depth !== 2) block(f, 1, 'domain-level', 'a journey is journeys/<journey>.md')
107
+ }
108
+ }
109
+
110
+ // The entry index links every document, and links nothing that is not there.
111
+ function indexTree(files, docs) {
112
+ const index = docs.get('README.md')
113
+ if (!index) return
114
+ const linked = new Map()
115
+ index.lines.forEach((l, i) => {
116
+ for (const m of l.matchAll(/\]\(([^)#\s]+\.md)(#[^)]*)?\)/g)) if (!/^[a-z]+:/.test(m[1])) linked.set(path.posix.normalize(m[1]), i + 1)
117
+ })
118
+ for (const f of files) if (f !== 'README.md' && !linked.has(f)) block('README.md', 1, 'index-tree', `${f} is in the tree and not in the index`)
119
+ for (const [f, line] of linked) if (!files.includes(f)) block('README.md', line, 'index-tree', `${f} is in the index and not in the tree`)
120
+ }
121
+
122
+ // A capability and a journey have exactly their `##` sections, in order; users.md and
123
+ // principles.md end on the two closing ones.
124
+ function sections(doc) {
125
+ const have = doc.headings.filter((h) => h.level === 2).map((h) => h.text)
126
+ const want = SECTIONS[kind(doc.file)]
127
+ const okay = want
128
+ ? have.join('\n') === want.join('\n')
129
+ : !['users.md', 'principles.md'].includes(doc.file) || have.slice(-2).join('\n') === CLOSING.join('\n')
130
+ if (!okay) block(doc.file, 1, 'sections', `expected ${(want || CLOSING).join(' · ')}${want ? '' : ' to close the document'}, found ${have.join(' · ') || 'none'}`)
131
+ }
132
+
133
+ function givenWhenThen(doc, h, end) {
134
+ const steps = doc.lines.slice(h.line, end).map((l) => /^(Given|When|Then) /.exec(l)).filter(Boolean).map((m) => m[1])
135
+ if (steps.join(' ') !== 'Given When Then') block(doc.file, h.line, 'given-when-then', `"${h.text}" reads ${steps.join(', ') || 'none of them'}`)
136
+ }
137
+
138
+ // Behaviour is rules: each `###` has at least one `####` case, and each case reads Given, When, Then
139
+ // in that order.
140
+ function behaviour(doc) {
141
+ const b = section(doc, 'Behaviour')
142
+ b.headings.forEach((h, i) => {
143
+ const next = b.headings[i + 1]
144
+ const end = next ? next.line - 1 : b.end
145
+ if (h.level === 3) {
146
+ if (!next || next.level !== 4) block(doc.file, h.line, 'rule-without-case', `"${h.text}" has no case`)
147
+ } else if (h.level === 4) givenWhenThen(doc, h, end)
148
+ })
149
+ }
150
+
151
+ // A heading is unique in its file, being the address of what carries no identifier.
152
+ function duplicateHeading(doc) {
153
+ const seen = new Set()
154
+ for (const h of doc.headings) {
155
+ if (seen.has(h.text)) block(doc.file, h.line, 'duplicate-heading', `"${h.text}" is written twice`)
156
+ seen.add(h.text)
157
+ }
158
+ }
159
+
160
+ // Every `<path> § <heading>` address resolves, and every `- § <heading> — …` line of a
161
+ // `## Rejected` names a heading of its own file.
162
+ function addresses(doc, docs) {
163
+ const has = heading
164
+ for (const m of doc.text.matchAll(new RegExp(ADDRESS, 'g'))) {
165
+ const { file, heading } = addressed(m)
166
+ const line = doc.text.slice(0, m.index).split('\n').length
167
+ if (!docs.has(file)) block(doc.file, line, 'address', `${file} is not in the tree`)
168
+ else if (!has(docs.get(file), heading)) block(doc.file, line, 'address', `${file} has no heading "${heading}"`)
169
+ }
170
+ const r = section(doc, 'Rejected')
171
+ for (const it of items(doc, r.start, r.end)) {
172
+ if (!it.text.startsWith('§ ')) continue
173
+ const parts = it.text.slice(2).split(' — ')
174
+ const found = parts.some((_, n) => has(doc, parts.slice(0, n + 1).join(' — ')))
175
+ if (!found) block(doc.file, it.line, 'address', `no heading here is "${parts[0]}"`)
176
+ }
177
+ }
178
+
179
+ // What each heading of a document defines: a persona, a journey, a capability, a rule or a case.
180
+ function defined(doc) {
181
+ const k = kind(doc.file)
182
+ const out = []
183
+ if (doc.file === 'users.md') for (const h of doc.headings) if (h.level === 2 && !CLOSING.includes(h.text)) out.push({ h, what: 'persona' })
184
+ if (k !== 'entry' && doc.headings[0] && doc.headings[0].level === 1) out.push({ h: doc.headings[0], what: k })
185
+ if (k === 'capability') for (const h of section(doc, 'Behaviour').headings) {
186
+ if (h.level === 3) out.push({ h, what: 'rule' })
187
+ if (h.level === 4) out.push({ h, what: 'case' })
188
+ }
189
+ return out
190
+ }
191
+
192
+ // Where each identifier is defined; `report` hears of a heading that carries none of its letter,
193
+ // or one already taken, and of a rule or a case outside a capability's Behaviour, which nothing
194
+ // reads.
195
+ function definitions(docs, report = () => {}) {
196
+ const at = new Map()
197
+ for (const doc of docs.values()) {
198
+ const own = defined(doc)
199
+ for (const h of doc.headings) if (/^[RC]-/.test(h.id) && !own.some((d) => d.h === h)) report(doc.file, h.line, 'outside-behaviour', `${h.id} opens a heading outside Behaviour, where no rule or case is read`)
200
+ for (const { h, what } of own) {
201
+ if (!h.id || h.id[0] !== LETTER[what]) { report(doc.file, h.line, 'identifier', `a ${what} opens on ${LETTER[what]}-<number> — <heading>`); continue }
202
+ if (at.has(h.id)) report(doc.file, h.line, 'identifier', `${h.id} is also ${SPEC}/${at.get(h.id).file}:${at.get(h.id).line}`)
203
+ else at.set(h.id, { file: doc.file, line: h.line, title: h.title })
204
+ }
205
+ }
206
+ return at
207
+ }
208
+
209
+ // Everything that is cited opens its heading on an identifier of its letter, unique under
210
+ // docs/spec/. Every identifier cited has such a heading, except one written `retired <id>` that
211
+ // stands in a line of a `## Rejected` — a retired one bare, or `retired` beside a living one, is
212
+ // refused — and where ` — ` follows a cited identifier, its real heading follows, except in a
213
+ // `## Rejected`, whose lines open on the identifier alone.
214
+ function identifiers(docs) {
215
+ const at = definitions(docs, block)
216
+ const gone = new Set()
217
+ const ranges = new Map([...docs.values()].map((d) => [d, section(d, 'Rejected')]))
218
+ for (const [doc, r] of ranges) for (const it of items(doc, r.start, r.end)) { const m = it.text.match(/^([PJKRC]-\d+) — /); if (m) gone.add(m[1]) }
219
+ for (const doc of docs.values()) {
220
+ const r = ranges.get(doc)
221
+ doc.lines.forEach((l, i) => {
222
+ if (/^#{1,6} /.test(l)) return
223
+ const rejected = i + 1 > r.start && i < r.end
224
+ for (const m of l.matchAll(/\b[PJKRC]-\d+\b/g)) {
225
+ const real = at.get(m[0])
226
+ const retired = gone.has(m[0])
227
+ const said = /\bretired\s+$/i.test(`${doc.lines[i - 1] ?? ''} ${l.slice(0, m.index)}`)
228
+ if (!rejected && (real ? said : !(said && retired))) block(doc.file, i + 1, 'identifier', real ? `${m[0]} is not retired, and "retired" stands beside it` : retired ? `${m[0]} is retired: write "retired ${m[0]}", or cite what replaced it` : `${m[0]} resolves to nothing`)
229
+ const after = (l.slice(m.index + m[0].length) + ' ' + doc.lines.slice(i + 1, i + 3).join(' ')).replace(/\s+/g, ' ')
230
+ if (!real || rejected || !after.startsWith(' — ')) continue
231
+ const beside = after.slice(3)
232
+ if (!beside.startsWith(real.title) || /^\w/.test(beside.slice(real.title.length))) block(doc.file, i + 1, 'identifier', `${m[0]} is "${real.title}", which is not what is written beside it`)
233
+ }
234
+ })
235
+ }
236
+ }
237
+
238
+ // A journey without a persona, and a product capability no journey walks unless its *What it is for*
239
+ // says so on a `**No journey.**` line, block (#1035). A system capability that no product
240
+ // capability, no other system capability and no principle cites is reported only, since a note may
241
+ // rightly add it before the product one that uses it.
242
+ function orphans(docs) {
243
+ const all = [...docs.values()]
244
+ const cited = (from, id) => from.some((d) => new RegExp(`\\b${id}\\b`).test(d.text))
245
+ const journeys = all.filter((d) => kind(d.file) === 'journey')
246
+ const product = all.filter((d) => d.file.startsWith('product/'))
247
+ for (const j of journeys) {
248
+ if (!/^\*\*Persona\.\*\*.*\bP-\d+\b/m.test(j.text)) block(j.file, 1, 'orphan', 'a journey without a persona')
249
+ }
250
+ for (const d of product) {
251
+ const cases = d.headings.filter((h) => h.level === 4 && h.id)
252
+ if (!cases.some((c) => cited(journeys, c.id)) && !/^\*\*No journey\.\*\* \S/m.test(d.text)) block(d.file, 1, 'orphan', 'a product capability no journey walks, and no `**No journey.**` line saying why')
253
+ }
254
+ for (const d of all.filter((x) => x.file.startsWith('system/'))) {
255
+ const id = d.headings[0] && d.headings[0].id
256
+ const users = all.filter((x) => x !== d && (kind(x.file) === 'capability' || x.file === 'principles.md'))
257
+ if (!id || !cited(users, id)) say(d.file, 1, 'orphan', 'a system capability nothing uses')
258
+ }
259
+ }
260
+
261
+ const tree = () => new Map(walk(SPEC).filter((f) => f.endsWith('.md')).map((f) => [f, read(f)]))
262
+ const heading = (doc, text) => doc.headings.some((h) => h.text === text || h.title === text)
263
+
264
+ function spec() {
265
+ if (!formed()) return console.log(`${SPEC}/ is not in the form — nothing is held`)
266
+ sevenEntries()
267
+ const docs = tree()
268
+ const files = [...docs.keys()]
269
+ domainLevel(files)
270
+ indexTree(files, docs)
271
+ for (const doc of docs.values()) {
272
+ sections(doc)
273
+ if (kind(doc.file) === 'capability') behaviour(doc)
274
+ duplicateHeading(doc)
275
+ addresses(doc, docs)
276
+ }
277
+ identifiers(docs)
278
+ orphans(docs)
279
+ }
280
+
281
+ // The lines from a `**<Name>.**` marker to the next marker or heading, inside [from, to).
282
+ function part(doc, name, from, to) {
283
+ const start = doc.lines.findIndex((l, i) => i >= from && i < to && l.startsWith(`**${name}.**`))
284
+ if (start < 0) return null
285
+ let end = start + 1
286
+ while (end < to && !/^(\*\*[A-Z][a-z ]*\.\*\*|#{1,6} )/.test(doc.lines[end])) end++
287
+ return { start, end }
288
+ }
289
+
290
+ // One address of a **Target.**: what is changed, retired or renamed exists today, by identifier or
291
+ // by `<path> § <heading>`; what is created does not.
292
+ function target(doc, it, docs, at) {
293
+ const m = new RegExp(`^(${VERBS.join('|')}) (.+)$`).exec(it.text)
294
+ if (!m) return block(doc.file, it.line, 'note-target', `opens on none of ${VERBS.join(', ')}`)
295
+ if (!docs) return m
296
+ const address = m[2].split(' → ')[0]
297
+ const id = /^([PJKRC]-\d+) — (.+)$/.exec(address)
298
+ const by = new RegExp(`^(?:${ADDRESS})$`).exec(address)
299
+ const { file, heading: title } = by ? addressed(by) : {}
300
+ const exists = id ? at.has(id[1]) && id[2] === at.get(id[1]).title : by && docs.has(file) && heading(docs.get(file), title)
301
+ if (!id && !by) block(doc.file, it.line, 'note-target', 'names neither an identifier with its heading nor a `<path> § <heading>`')
302
+ else if (m[1] === 'creates' && exists) block(doc.file, it.line, 'note-target', 'creates what already exists')
303
+ else if (m[1] !== 'creates' && !exists) block(doc.file, it.line, 'note-target', `${m[1]} what does not exist today`)
304
+ return m
305
+ }
306
+
307
+ // A note's own shape, read against the specification before its writing: every decision has its
308
+ // Target, its Decision and its Rejected with an `<option> — <cost>` line; every target resolves; a
309
+ // decision on a rule, or with **Cases.**, has a Given / When / Then case, the case headings unique,
310
+ // and one with cases names exactly one rule to write them under; a note creates a heading once;
311
+ // every term its definition.
312
+ function shape(doc, docs, at) {
313
+ const terms = part(doc, 'Terms', 0, doc.lines.length)
314
+ for (const it of terms ? items(doc, terms.start, terms.end) : []) if (!/^\*\*[^*]+\*\* — \S/.test(it.text)) block(doc.file, it.line, 'note-terms', 'a term with no definition')
315
+ const d = section(doc, 'Decisions')
316
+ const decisions = d.headings.filter((h) => h.level === 3)
317
+ const seen = new Set()
318
+ const created = new Map()
319
+ decisions.forEach((h, n) => {
320
+ const end = decisions[n + 1] ? decisions[n + 1].line - 1 : d.end
321
+ const [t, dec, rej] = ['Target', 'Decision', 'Rejected'].map((name) => part(doc, name, h.line, end))
322
+ for (const [name, p] of [['Target', t], ['Decision', dec], ['Rejected', rej]]) if (!p) block(doc.file, h.line, 'note-decision', `"${h.text}" has no ${name}`)
323
+ if (rej && !items(doc, rej.start, rej.end).some((it) => / — \S/.test(it.text))) block(doc.file, rej.start + 1, 'note-decision', 'Rejected holds no `<option> — <cost>` line')
324
+ const targets = t ? items(doc, t.start, t.end).map((it) => target(doc, it, docs, at)).filter(Boolean) : []
325
+ if (t && !targets.length) block(doc.file, t.start + 1, 'note-target', 'Target names no address')
326
+ const cases = d.headings.filter((c) => c.level === 4 && c.line > h.line && c.line <= end)
327
+ if (!cases.length && (part(doc, 'Cases', h.line, end) || targets.some((m) => /^R-\d+ — /.test(m[2])))) block(doc.file, h.line, 'note-cases', `"${h.text}" changes a behaviour and carries no case`)
328
+ const rules = new Set(targets.filter((m) => m[1] === 'creates' || (m[1] !== 'retires' && /^R-\d+ — /.test(m[2])))
329
+ .map((m) => (/^R-\d+(?= — )/.exec(m[2]) || [m[2]])[0]))
330
+ if (cases.length && rules.size !== 1) block(doc.file, h.line, 'note-cases', `"${h.text}" carries cases and names ${rules.size || 'no'} rule${rules.size === 1 ? '' : 's'} to write them under`)
331
+ for (const m of targets.filter((m) => m[1] === 'creates')) {
332
+ if (created.has(m[2])) block(doc.file, h.line, 'note-target', `"${h.text}" creates ${m[2]}, which "${created.get(m[2])}" already creates`)
333
+ else created.set(m[2], h.text)
334
+ }
335
+ cases.forEach((c, i) => {
336
+ givenWhenThen(doc, c, cases[i + 1] ? cases[i + 1].line - 1 : end)
337
+ if (seen.has(c.text)) block(doc.file, c.line, 'note-cases', `"${c.text}" is written twice`)
338
+ seen.add(c.text)
339
+ })
340
+ })
341
+ }
342
+
343
+ // docs/spec/ as a revision holds it, read relative to where the script runs.
344
+ function committed(base) {
345
+ const files = git('ls-tree', '-r', '-z', '--name-only', base, '--', SPEC).split('\0').filter((f) => f.endsWith('.md'))
346
+ return new Map(files.map((f) => [f.slice(SPEC.length + 1), parse(f.slice(SPEC.length + 1), git('show', `${base}:./${f}`))]))
347
+ }
348
+
349
+ // With the note still on disk, its shape holds against the base, and the fold lost nothing: every
350
+ // case heading of the note is under docs/spec/, the identifier the fold assigned set aside, and
351
+ // every refusal line in a `## Rejected`
352
+ // as written — of a former state, which the fold rewrites "in force <from> → <to>", the option;
353
+ // no heading of the base revision is gone or reworded unless the note retires or renames it; and an
354
+ // identifier gone stands in a `- <id> — …` line of a `## Rejected`.
355
+ function fold(args) {
356
+ const base = args.includes('--base') ? args[args.indexOf('--base') + 1] : 'HEAD'
357
+ if (!args[0] || args[0].startsWith('--')) { console.error('fold: which note?'); process.exit(2) }
358
+ asGiven.add(args[0])
359
+ const doc = read(args[0], '')
360
+ if (!formed()) return block(args[0], 1, 'fold', `${SPEC}/ is not in the form — nothing is folded there`)
361
+ const after = [...tree().values()]
362
+ const now = definitions(new Map(after.map((d) => [d.file, d])))
363
+ const squeeze = (s) => s.replace(/\s+/g, ' ').trim()
364
+ const rejected = after.flatMap((d) => { const r = section(d, 'Rejected'); return items(d, r.start, r.end).map((it) => squeeze(it.text)) })
365
+ const d = section(doc, 'Decisions')
366
+ const released = new Set()
367
+ for (const c of d.headings.filter((h) => h.level === 4)) {
368
+ if (!after.some((a) => a.headings.some((h) => h.level === 4 && h.title === c.text))) block(args[0], c.line, 'fold-case', `"${c.text}" is not under ${SPEC}/`)
369
+ }
370
+ d.headings.filter((h) => h.level === 3).forEach((h, n, all) => {
371
+ const end = all[n + 1] ? all[n + 1].line - 1 : d.end
372
+ const [t, r] = ['Target', 'Rejected'].map((name) => part(doc, name, h.line, end))
373
+ for (const it of t ? items(doc, t.start, t.end) : []) {
374
+ const m = new RegExp(`^(retires|renames) (?:([PJKRC]-\\d+) — |${ADDRESS})`).exec(it.text)
375
+ const by = m && !m[2] && addressed(m, 3)
376
+ if (m) released.add(m[2] || `${by.file} § ${by.heading}`)
377
+ }
378
+ for (const it of r ? items(doc, r.start, r.end) : []) {
379
+ const line = squeeze(it.text)
380
+ const needle = / — in force since /.test(line) ? line.split(' — ')[0] : line
381
+ if (!rejected.some((x) => x.includes(needle))) block(args[0], it.line, 'fold-refusal', `"${needle}" is in no Rejected under ${SPEC}/`)
382
+ }
383
+ })
384
+ const then = committed(base)
385
+ const before = definitions(then)
386
+ shape(doc, then, before)
387
+ for (const [id, was] of before) {
388
+ const is = now.get(id)
389
+ if (!is && !rejected.some((x) => x.startsWith(`${id} — `))) block(was.file, was.line, 'fold-identifier', `${id} is gone and stands in no line of Rejected`)
390
+ if ((!is || is.title !== was.title) && !released.has(id)) block(was.file, was.line, 'fold-heading', `"${id} — ${was.title}" is gone, and the note neither retires nor renames it`)
391
+ }
392
+ for (const was of then.values()) {
393
+ const is = after.find((a) => a.file === was.file)
394
+ for (const h of is ? was.headings.filter((x) => !x.id) : []) {
395
+ if (!heading(is, h.text) && !released.has(`${was.file} § ${h.text}`)) block(was.file, h.line, 'fold-heading', `"${h.text}" is gone, and the note neither retires nor renames it`)
396
+ }
397
+ }
398
+ }
399
+
400
+ // Before the writing, a note's own shape against docs/spec/ as it stands: what `fold` holds it to
401
+ // after, read while its author can still answer.
402
+ function note(args) {
403
+ if (!args[0]) { console.error('note: which note?'); process.exit(2) }
404
+ asGiven.add(args[0])
405
+ const doc = read(args[0], '')
406
+ if (!formed()) return block(args[0], 1, 'note', `${SPEC}/ is not in the form — no note is written there`)
407
+ const docs = tree()
408
+ shape(doc, docs, definitions(docs))
409
+ }
410
+
411
+ // What a plan is cut from, one line per fact, its kind first and tab-separated: every rule and
412
+ // case with its capability, a case with its rule; the cases each journey's steps cite; and the
413
+ // index of every rule and case heading, the one place a heading is written. Blocks nothing,
414
+ // writes nothing.
415
+ function cases() {
416
+ if (!formed()) return console.log(`${SPEC}/ is not in the form — nothing is held`)
417
+ const docs = [...tree().values()].sort((a, b) => (a.file < b.file ? -1 : 1))
418
+ const out = { facts: [], journey: [], index: [] }
419
+ const cited = (doc) => {
420
+ const s = section(doc, 'Steps')
421
+ return [...new Set(doc.lines.slice(s.start, s.end).join('\n').match(/\bC-\d+\b/g))]
422
+ }
423
+ for (const doc of docs) {
424
+ const own = doc.headings[0] && doc.headings[0].id
425
+ if (kind(doc.file) === 'journey') out.journey.push(['journey', own, cited(doc).join(' ')])
426
+ if (kind(doc.file) !== 'capability') continue
427
+ let rule
428
+ for (const { h, what } of defined(doc).filter((d) => d.h.id && (d.what === 'rule' || d.what === 'case'))) {
429
+ if (what === 'rule') rule = h.id
430
+ out.facts.push(what === 'rule' ? ['rule', h.id, own] : ['case', h.id, own, rule])
431
+ out.index.push(['index', `${h.id} — ${h.title}`])
432
+ }
433
+ }
434
+ for (const l of Object.values(out).flat()) console.log(l.filter(Boolean).join('\t'))
435
+ }
436
+
437
+ const modes = { spec, note, fold, cases }
438
+ const mode = modes[process.argv[2]]
439
+ if (!mode) {
440
+ console.error(`usage: node ${path.basename(process.argv[1])} ${Object.keys(modes).join(' | ')}`)
441
+ process.exit(2)
442
+ }
443
+ mode(process.argv.slice(3))
444
+ process.exitCode = red ? 1 : 0
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ // `codelife spec <identifier>` (K-43, § The specification is read by identifier): one entry of
3
+ // docs/spec/ by its identifier, never a document whole. Run from the repository's root.
4
+ // A heading below the title — a rule, a case, a persona — prints its text down to the next heading
5
+ // of its level or above, a rule's cases included; a title — a capability, a journey — prints the
6
+ // outline of its headings. Then the lines of `## Rejected` opening on the identifier; a retired one
7
+ // prints those alone. Each part opens on `<file>:<line>`. Exit 1 when the identifier resolves to
8
+ // nothing, 2 on a malformed one or no docs/spec/.
9
+ import './node-floor.mjs'
10
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import { lf } from './text.mjs'
13
+
14
+ const SPEC = 'docs/spec'
15
+ const id = process.argv[2] ?? ''
16
+ if (!/^[PJKRC]-\d+$/.test(id)) {
17
+ console.error('usage: codelife spec <identifier>, as R-12 or C-340')
18
+ process.exit(2)
19
+ }
20
+ if (!existsSync(SPEC)) {
21
+ console.error(`codelife spec: no ${SPEC}/ here — run it from the repository's root`)
22
+ process.exit(2)
23
+ }
24
+
25
+ const files = (d) => readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))
26
+ .flatMap((e) => (e.isDirectory() ? files(join(d, e.name)) : e.name.endsWith('.md') ? [join(d, e.name)] : []))
27
+ // levels <lines> — each line's heading level, 0 for a line that is none: a `#` line inside a fenced
28
+ // block is the block's text.
29
+ const levels = (lines) => {
30
+ let fenced = false
31
+ return lines.map((l) => (/^\s*```/.test(l) ? ((fenced = !fenced), 0) : fenced ? 0 : (l.match(/^(#{1,6}) /) ?? [, ''])[1].length))
32
+ }
33
+ const out = []
34
+ for (const f of files(SPEC)) {
35
+ const lines = lf(readFileSync(f, 'utf8')).split('\n'), heads = levels(lines), level = (_, i) => heads[i]
36
+ const at = lines.findIndex((l, i) => level(l, i) && l.slice(level(l, i) + 1).startsWith(`${id} — `))
37
+ if (at >= 0) {
38
+ const own = level(lines[at], at)
39
+ const end = lines.findIndex((l, i) => i > at && level(l, i) && level(l, i) <= own)
40
+ const body = own === 1
41
+ ? lines.filter((l, i) => i >= at && level(l, i) > 1 && level(l, i) <= 4)
42
+ : lines.slice(at + 1, end < 0 ? undefined : end)
43
+ out.push(`${f}:${at + 1}`, lines[at], ...body)
44
+ while (out.at(-1) === '') out.pop()
45
+ }
46
+ let rejected = false
47
+ lines.forEach((l, i) => {
48
+ if (level(l, i)) rejected = l === '## Rejected'
49
+ if (rejected && l.startsWith(`- ${id} — `)) out.push(`${f}:${i + 1}: ${l}`)
50
+ })
51
+ }
52
+ if (!out.length) {
53
+ console.error(`codelife spec: ${id} resolves to nothing under ${SPEC}/`)
54
+ process.exit(1)
55
+ }
56
+ console.log(out.join('\n'))
@@ -0,0 +1,27 @@
1
+ // A release's tag, the one place its spelling is written (R-88): `codelife release cut` writes it,
2
+ // scripts/changelog.mjs and `codelife overview` read whether a version is tagged, and
3
+ // `codelife release prep` finds a line's last tag. Built-in modules only: changelog.mjs runs in a
4
+ // hook of the repository it was written into.
5
+ import './node-floor.mjs'
6
+
7
+ // The tag a release writes: an annotated `v<version>`, in every repository.
8
+ export const tagFor = (version) => `v${version}`
9
+
10
+ // The tags that say <version> is released: `v<version>`, or `<name>--v<version>` for a repository
11
+ // tagged that way before every tag was `v` (codelife up to 0.10.0), its name read without an npm
12
+ // scope; another name's tag at that version says nothing.
13
+ export const spellings = (version, name = '') => [tagFor(version), ...(name ? [`${name.split('/').pop()}--v${version}`] : [])]
14
+
15
+ // The patterns `git describe --match` takes for a line's last tag: both spellings above, and the
16
+ // `r<version>` tags older lines spelled (R-242), read as history and never written.
17
+ export const PATTERNS = ['v[0-9]*', '*--v[0-9]*', 'r[0-9]*']
18
+
19
+ // The version a release tag names, whichever spelling it has.
20
+ export const versionOf = (tag) => tag.replace(/^.*--v/, '').replace(/^[vr]/, '')
21
+
22
+ // semver's order, a prerelease before its release: 1.0.0-rc.1 < 1.0.0 (#1068).
23
+ const parse = (v) => { const [core, pre] = v.split(/-(.*)/); return { n: core.split('.').map(Number), pre } }
24
+ export const compare = (a, b) => {
25
+ const [x, y] = [parse(a), parse(b)]
26
+ return x.n[0] - y.n[0] || x.n[1] - y.n[1] || x.n[2] - y.n[2] || (x.pre === undefined) - (y.pre === undefined) || (x.pre ?? '').localeCompare(y.pre ?? '', 'en', { numeric: true })
27
+ }
@@ -0,0 +1,134 @@
1
+ # A conforming application — on top of the base
2
+
3
+ Everything in `target-base.md` applies here in full; this file adds the toolchain, and nothing it
4
+ says replaces a base row.
5
+
6
+ An application is a repository nothing installs. That is the only thing distinguishing it from a
7
+ library here, and it decides every row below: the build is a bundle rather than a `pack`, the
8
+ output is not versioned because no consumer resolves it, and the dev server is the application
9
+ itself rather than a page written to exercise something else.
10
+
11
+ **This file describes a toolchain, never a stack.**
12
+ The framework, the router, the CSS approach, the i18n and the deployment target are decided at each
13
+ application's first port and recorded in that repository's `CLAUDE.md`. A choice three applications
14
+ have made the same way may move here; until then, a row naming one would make CONF report a drift
15
+ no skill remedies.
16
+
17
+ ## Package manager
18
+
19
+ pnpm, pinned in `packageManager`, with `minimumReleaseAge` set explicitly in `pnpm-workspace.yaml`,
20
+ on the library's rule for both: an application with in-house dependencies
21
+ is a workspace — `pnpm-workspace.yaml` with `packages: [submodules/*]`, each in-house library a
22
+ **root** submodule initialised non-recursively, and `pnpm.overrides` sending every in-house package
23
+ to `workspace:*` for as long as those libraries are not published.
24
+
25
+ ## Toolchain
26
+
27
+ Vite+, invoked as `vp`: the `vite-plus` devDependency, installed with the repository's other
28
+ dependencies and run through its scripts — not a machine binary. One `vite.config.ts` at the root,
29
+ carrying the plugin of the framework the application uses — the one row of this file whose content
30
+ the repository supplies, which is why the package's vite preset serves a library and
31
+ none serves an application: a template naming a framework would be the stack this file refuses to name.
32
+
33
+ The target commits to the **configuration surface** and the `vp` scripts, not to Vite+ itself,
34
+ which is beta: if it stalls, the same configuration runs under Vite, Vitest, Oxlint and Oxfmt
35
+ installed separately, which changes this file and not the target.
36
+
37
+ ## Build
38
+
39
+ `vp build`, Rolldown behind it, and `vp preview` to serve what it produced. The output is **not
40
+ versioned** — nothing installs an application from the forge, so a build in the history would be a
41
+ generated artefact no consumer ever reads, going stale in every branch.
42
+
43
+ Correspondingly, the contract of a conforming application declares **no `generated`** entry. That
44
+ is a value, not an omission: `/audit`'s derived-file lens reads it and reports nothing rather than
45
+ reporting an unknown.
46
+
47
+ ## Types, lint, format
48
+
49
+ The library's rules, from the same source: the package's `presets/tsconfig.json`, which the
50
+ `tsconfig.json` template extends for both kinds, and Oxlint and Oxfmt through
51
+ `vp check`. They are stated here by reference and not restated, so that one flag never has two
52
+ descriptions to be at odds.
53
+
54
+ ## Tests
55
+
56
+ Vitest through `vp test` for the pure modules — computations, state, anything that runs without a
57
+ document — with `test/` mirroring `src/` as it does in a library.
58
+
59
+ **Browser tests are set up by default**, Vitest browser mode on Playwright Chromium, and the
60
+ start-up smoke written when the repository was ported is their first case: the application mounts
61
+ without a console error. An application's interesting failures are in the browser, so a repository
62
+ where a browser test is an afternoon's setup away is one where nobody writes the first one.
63
+
64
+ The characterisation tests of the port — that smoke, and the pure modules captured by snapshot —
65
+ are kept as the **floor**: they describe what the application already did, so they are extended,
66
+ never weakened to let a change pass.
67
+
68
+ ## Dev mode
69
+
70
+ `vp dev`, serving the application itself. There is no `playground/`: a library needs a page written
71
+ to exercise it, and an application is one.
72
+
73
+ ## Hooks
74
+
75
+ The base's three, unchanged, and that is the whole row: an application versions no build, so its
76
+ `pre-commit` builds nothing.
77
+
78
+ ## MCP server
79
+
80
+ A conforming application carries **no** `.mcp.json`: the browser server serves only the agent, so
81
+ the plugin declares it, in its manifest, pinned to an exact version, and it asks for the approval a
82
+ project's servers get when the folder is trusted. The demo credentials the server substitutes sit in
83
+ `.claude/codelife.secrets.env`, git-ignored, the path the plugin fixes.
84
+
85
+ `.claude/settings.local.json` is **not** part of the target: it is not versioned and it is the
86
+ user's, so per-tool permissions stay theirs to grant.
87
+
88
+ ## Application facts in the contract
89
+
90
+ A conforming application's contract carries an `app:` block — the facts about *this* application
91
+ that the plugin cannot decide and that differ in every repository:
92
+
93
+ ```markdown
94
+ app:
95
+ url: <where the running application is reached, port included>
96
+ start: <the command that starts it, and the directory it runs in when that is not the root>
97
+ ```
98
+
99
+ One block rather than two top-level fields: it is present exactly when `kind: app`, which is one
100
+ validation rule instead of two, and the two facts are meaningless apart — a URL nobody starts starts
101
+ nothing. The demo credentials are no fact of the contract: they sit in `.claude/codelife.secrets.env`,
102
+ the path the plugin's browser server reads.
103
+
104
+ The block is written once by `/setup` and read by whatever drives the running application.
105
+
106
+ ## Per application, at the first port
107
+
108
+ The framework, the router, the CSS approach, the i18n and the deployment target — decided in that
109
+ repository and recorded in its `CLAUDE.md`, not here. `codelife port prep` does not measure them, and
110
+ an application answering each of them differently is conforming.
111
+
112
+ ## Standard scripts
113
+
114
+ The base's five names, with an application's commands, verbatim — `test:one` takes the file as its
115
+ argument, and `verify:release` is the push tier itself, which the `pre-push` hook and `/release` run
116
+ alone on a version tag:
117
+
118
+ | Script | Runs |
119
+ |---|---|
120
+ | `verify:commit` | `vp check && vp test` |
121
+ | `verify:push` | `vp check && vp test && vp test --browser && vp build` |
122
+ | `verify:release` | `pnpm verify:push` |
123
+ | `test:all` | `vp test` |
124
+ | `test:one` | `vp test` |
125
+
126
+ **Five names, not six.** The browser suite is a command inside the push tier and never a sixth
127
+ standard script: the base fixes those five for every kind, so a sixth would be a base change
128
+ reaching `none` and `lib` too, for a suite neither of them has. A repository keeping a
129
+ `test:browser` of its own for convenience is neither required to nor forbidden from doing so — it
130
+ is simply not what a skill invokes.
131
+
132
+ `--browser` and `build` are in the push tier and not the commit tier, on the library's reasoning:
133
+ the commit tier runs on every commit, and a browser run plus a bundle there costs more than it
134
+ catches, while a push is what makes the result someone else's problem.