@ersbeth/codelife 0.0.0-stage → 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 +65 -2
  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 +38 -4
  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,179 @@
1
+ // The one reader of the project contract, `.claude/codelife.md` (K-19 — The project contract): its
2
+ // frontmatter and its prose sections. Every script that needs a field calls it (#1077). It reads the
3
+ // block form /setup writes, and a flow mapping or a flow list where one stands for a value —
4
+ // `tracker: { kind: glab }`, `- { path: dist/, when: [src/**] }` — the subset of YAML a contract
5
+ // uses, never a document YAML at large. `check` is what /setup and /overview hold a contract to.
6
+ import './node-floor.mjs'
7
+ import { existsSync, readFileSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+
10
+ export const SCHEMA = 2
11
+ export const FILE = '.claude/codelife.md'
12
+ const KEYS = ['codelife', 'kind', 'tracker', 'app', 'guards', 'generated', 'release']
13
+ const PROSE = ['Setup', 'Never', 'Raise before doing', 'Tracker notes']
14
+
15
+ // scan <text> <fn> — calls fn(c, i) on each character outside quotes, with the bracket depth.
16
+ const scan = (text, fn) => {
17
+ let quote = '', depth = 0
18
+ for (let i = 0; i < text.length; i++) {
19
+ const c = text[i]
20
+ if (quote) { if (c === quote) quote = ''; continue }
21
+ if (c === '"' || c === "'") { quote = c; continue }
22
+ if (fn(c, i, depth) === false) return
23
+ if (c === '[' || c === '{') depth++
24
+ else if (c === ']' || c === '}') depth--
25
+ }
26
+ return depth
27
+ }
28
+ // uncomment <line> — the line without a `#` comment: one at its start or after a space, outside quotes.
29
+ const uncomment = (line) => {
30
+ let end = line.length
31
+ scan(line, (c, i) => { if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) { end = i; return false } })
32
+ return line.slice(0, end).trimEnd()
33
+ }
34
+ // split <text> — the items of a flow collection's inside, at its top-level commas.
35
+ const split = (text) => {
36
+ const out = []
37
+ let from = 0
38
+ scan(text, (c, i, depth) => { if (c === ',' && !depth) { out.push(text.slice(from, i)); from = i + 1 } })
39
+ return [...out, text.slice(from)].map((s) => s.trim()).filter(Boolean)
40
+ }
41
+ const pair = (text) => { const m = /^([\w.-]+):(?:\s+(.*))?$/.exec(text); return m && [m[1], m[2] ?? ''] }
42
+ // value <text> — a scalar, a flow list as an array, a flow mapping as an object.
43
+ const value = (text) => {
44
+ const t = text.trim()
45
+ if (t.startsWith('[')) return split(t.slice(1, t.lastIndexOf(']'))).map(value)
46
+ if (t.startsWith('{')) return Object.fromEntries(split(t.slice(1, t.lastIndexOf('}'))).map((p) => pair(p) ?? [p, '']).map(([k, v]) => [k, value(v)]))
47
+ return t.replace(/^(['"])(.*)\1$/, '$2')
48
+ }
49
+
50
+ // lines <front> — the frontmatter's logical lines, { indent, text }: comments and blank lines gone,
51
+ // a flow collection open across several lines joined into one.
52
+ const logical = (front) => {
53
+ const out = []
54
+ for (const raw of front.split('\n')) {
55
+ const text = uncomment(raw)
56
+ const open = out.at(-1)
57
+ if (open?.open) { open.text += ` ${text.trim()}`; open.open = scan(open.text, () => {}) > 0; continue }
58
+ if (!text.trim()) continue
59
+ out.push({ indent: text.length - text.trimStart().length, text: text.trim(), open: scan(text, () => {}) > 0 })
60
+ }
61
+ return out
62
+ }
63
+
64
+ // block <lines> <at> <indent> — the node whose lines start at `at`, indented deeper than `indent`:
65
+ // a list when its first line opens on `- `, a mapping otherwise. Returns [node, next index].
66
+ const block = (ls, at, indent) => {
67
+ const first = ls[at]
68
+ if (!first || first.indent <= indent) return ['', at]
69
+ const deep = first.indent
70
+ if (first.text.startsWith('- ') || first.text === '-') {
71
+ const list = []
72
+ while (ls[at]?.indent === deep && /^-( |$)/.test(ls[at].text)) {
73
+ const item = ls[at].text.slice(2).trim()
74
+ const kv = !/^[[{'"]/.test(item) && pair(item)
75
+ if (!kv) { list.push(value(item)); at++; continue }
76
+ // a mapping item: its first pair on the dash's line, the rest indented past the dash
77
+ const inner = [{ indent: deep + 2, text: item }]
78
+ for (at++; ls[at] && ls[at].indent > deep; at++) inner.push(ls[at])
79
+ list.push(block(inner, 0, deep)[0])
80
+ }
81
+ return [list, at]
82
+ }
83
+ const map = {}
84
+ while (ls[at]?.indent === deep) {
85
+ const kv = pair(ls[at].text)
86
+ if (!kv) { at++; continue }
87
+ const [k, v] = kv
88
+ if (/^[>|][+-]?$/.test(v)) { // a folded or literal scalar: the deeper lines
89
+ const body = []
90
+ for (at++; ls[at] && ls[at].indent > deep; at++) body.push(ls[at].text)
91
+ map[k] = body.join(v.startsWith('>') ? ' ' : '\n')
92
+ } else if (v) { map[k] = value(v); at++ }
93
+ else [map[k], at] = block(ls, at + 1, deep)
94
+ }
95
+ return [map, at]
96
+ }
97
+
98
+ // parse <text> — { front, prose, data, sections } of a contract's text, read past one leading
99
+ // `generated by codelife` marker line; `data` is {} when it opens on no frontmatter. A `## ` line
100
+ // inside a fenced block is no heading.
101
+ export const parse = (text) => {
102
+ const body = text.replace(/^<!-- generated by codelife\b.*-->[ \t]*\r?\n/, '')
103
+ const parts = body.split(/^---[ \t]*$/m)
104
+ const front = body.startsWith('---') ? parts[1] ?? '' : ''
105
+ const prose = body.startsWith('---') ? parts.slice(2).join('---') : body
106
+ const unfenced = prose.replace(/^(`{3,}|~{3,}).*\n[\s\S]*?^\1.*$/gm, (m) => m.replace(/[^\n]/g, ' '))
107
+ const sections = new Map([...unfenced.matchAll(/^## (.*?)[ \t]*$/gm)].map((m, i, all) =>
108
+ [m[1], prose.slice(m.index + m[0].length, all[i + 1]?.index ?? prose.length).trim()]))
109
+ return { front, prose, data: block(logical(front), 0, -1)[0] || {}, sections }
110
+ }
111
+
112
+ // contract [root] — the parsed contract of the repository at root, or null when it has none.
113
+ export const contract = (root = '.') => {
114
+ const p = join(root, FILE)
115
+ if (!existsSync(p)) return null
116
+ const text = readFileSync(p, 'utf8')
117
+ return { text, ...parse(text) }
118
+ }
119
+
120
+ // valid [root] — the parsed contract, or the one line that stops a run (R-8): `no contract …` when
121
+ // the file is absent, its first invalid line when it is invalid. A stale field stops nothing (R-9).
122
+ export const valid = (root = '.') => {
123
+ const c = contract(root)
124
+ if (!c) return { stop: `no contract at ${FILE} — \`/setup\` writes one` }
125
+ const [first] = check(c, root).invalid
126
+ return first ? { stop: `the contract is invalid — ${first}; \`/setup\` rewrites it` } : { contract: c }
127
+ }
128
+
129
+ // field <path> [root] — one value by its dotted path, `tracker.kind`; undefined when absent, the
130
+ // contract too. An invalid contract stops the run on valid()'s line (R-8), whichever field is read.
131
+ export const field = (path, root = '.') => {
132
+ const v = valid(root)
133
+ if (v.stop && existsSync(join(root, FILE))) { console.error(`- stop: ${v.stop}`); process.exit(1) }
134
+ return path.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), v.contract?.data)
135
+ }
136
+
137
+ // entries <key> [root] — guards[] or generated[], each entry's values as lists: a scalar is a list of one.
138
+ const list = (v) => (v === undefined || v === '' ? [] : Array.isArray(v) ? v : [v])
139
+ export const entries = (key, root) => list(field(key, root)).filter((e) => e && typeof e === 'object')
140
+ .map((e) => Object.fromEntries(Object.entries(e).map(([k, v]) => [k, list(v)])))
141
+
142
+ // check <parsed> [root] — { invalid, stale }: the lines that make a contract invalid, the order
143
+ // R-8 holds them in, then the fields whose referent moved (R-9).
144
+ export const check = (c, root = '.') => {
145
+ const invalid = [], stale = []
146
+ const d = c.data
147
+ const schema = d.codelife
148
+ for (const k of Object.keys(d).filter((k) => !KEYS.includes(k))) (Number(schema) < SCHEMA ? stale : invalid).push(`${k}: a key schema ${SCHEMA} does not know`)
149
+ for (const [k, v] of [['codelife', schema], ['kind', d.kind], ['tracker.kind', d.tracker?.kind], ['release.version_file', d.release?.version_file]]) if (!v) invalid.push(`${k}: missing — the contract was not written by /setup`)
150
+ if (schema && !/^\d+$/.test(schema)) invalid.push(`codelife: '${schema}' is no integer`)
151
+ else if (Number(schema) > SCHEMA) invalid.push(`codelife: written for schema ${schema}, the plugin reads ${SCHEMA} — the plugin is too old to read it`)
152
+ else if (schema && Number(schema) < SCHEMA) stale.push(`codelife: written for schema ${schema}, the plugin reads ${SCHEMA}`)
153
+ if (d.kind && !['lib', 'app', 'none'].includes(d.kind)) invalid.push(`kind: '${d.kind}' is none of lib, app, none`)
154
+ if (d.tracker?.kind && !['glab', 'gh', 'none'].includes(d.tracker.kind)) invalid.push(`tracker.kind: '${d.tracker.kind}' is none of glab, gh, none`)
155
+ if ('app' in d && d.kind !== 'app') invalid.push(`app: under kind ${d.kind}, which runs nothing`)
156
+ // The shape of each block: the keys it may hold, and the lists an entry of guards[] or generated[] needs.
157
+ for (const [k, allowed] of [['tracker', ['kind']], ['release', ['version_file']], ['app', ['url', 'start']]])
158
+ if (k in d) for (const x of typeof d[k] === 'object' && d[k] ? Object.keys(d[k]).filter((x) => !allowed.includes(x)) : ['']) invalid.push(`${k}${x && `.${x}`}: ${x ? `not a key of ${k}, which holds ${allowed.join(' and ')}` : `holds ${allowed.join(' and ')}`}`)
159
+ for (const [k, needed, optional] of [['guards', ['when', 'run'], ['note']], ['generated', ['path', 'command', 'when'], []]])
160
+ list(d[k]).forEach((e, i) => {
161
+ if (!e || typeof e !== 'object') return invalid.push(`${k}[${i}]: an entry holds ${needed.join(', ')}`)
162
+ for (const x of needed.filter((x) => !(x in e))) invalid.push(`${k}[${i}].${x}: missing`)
163
+ for (const x of Object.keys(e).filter((x) => ![...needed, ...optional].includes(x))) invalid.push(`${k}[${i}].${x}: not a key of an entry`)
164
+ })
165
+ for (const h of c.sections.keys()) if (!PROSE.includes(h)) invalid.push(`## ${h}: none of the four prose sections`)
166
+ const at = (p) => existsSync(join(root, p))
167
+ if (d.release?.version_file && !at(d.release.version_file)) stale.push(`release.version_file: ${d.release.version_file} is gone`)
168
+ const each = (key) => list(d[key]).filter((e) => e && typeof e === 'object').map((e) => Object.fromEntries(Object.entries(e).map(([k, v]) => [k, list(v)])))
169
+ for (const p of each('generated').flatMap((g) => g.path ?? [])) if (!at(p)) stale.push(`generated: ${p} is gone`)
170
+ let pj = null
171
+ try { pj = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) } catch { /* none */ }
172
+ for (const cmd of each('guards').flatMap((g) => g.run ?? [])) {
173
+ const words = cmd.split(/\s+/)
174
+ const script = /^(pnpm|npm|yarn)$/.test(words[0]) ? (words[1] === 'run' ? words[2] : words[1]) : null
175
+ if (script && !['install', 'exec', 'dlx', 'test'].includes(script) && !pj?.scripts?.[script]) stale.push(`guards: \`${cmd}\` — no script ${script} in package.json`)
176
+ for (const w of words.slice(1).filter((w) => /^[\w.][\w./-]*\.(mjs|cjs|js|sh|ts)$|\//.test(w) && !w.startsWith('-') && !w.includes('*'))) if (!at(w)) stale.push(`guards: \`${cmd}\` — ${w} is gone`)
177
+ }
178
+ return { invalid, stale }
179
+ }
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ // The one place that knows the FORMER form of a repository set up by codelife: copies of the
3
+ // scripts, the whole hooks, a `postinstall`, a marketplace declared in `.claude/settings.json`.
4
+ // `/setup` migrates such a repository to the package by calling this file, and nothing else names
5
+ // the former form, so retiring the recognition is deleting this file once picoflow, codelife and
6
+ // three-js-mesh-colors are migrated (K-1 — setup, R-153).
7
+ //
8
+ // node former-form.mjs detect [repo] what the repository still carries, as JSON
9
+ // node former-form.mjs settings [repo] the marketplace lines of .claude/settings.json
10
+ // node former-form.mjs migrate <group> [repo] group: hooks | changelog
11
+ //
12
+ // Every function takes the repository as a path and touches only it; the one exception is the
13
+ // uninstall of the former copy on the machine, which hooks does through `claude`. The package
14
+ // itself is never migrated: its scripts/ are the originals the others are copies of.
15
+ import './node-floor.mjs'
16
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
17
+ import { spawnSync } from 'node:child_process'
18
+ import { join } from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+ import { field } from './contract.mjs'
21
+
22
+ const PKG = '@ersbeth/codelife'
23
+ const FROM_PACKAGE = `node_modules/${PKG}/scripts/`
24
+ const HOOKS = ['pre-commit', 'pre-push', 'commit-msg']
25
+ const PREPARE = `node ${FROM_PACKAGE}install.mjs`
26
+ const KEY = 'codelife@codelife'
27
+
28
+ const read = (p) => { try { return readFileSync(p, 'utf8') } catch { return null } }
29
+
30
+ // A JSON file is rewritten only when printing it back reproduces it byte for byte, so every key
31
+ // that is not ours stays as it was; a file that does not round-trip is reported, never reformatted.
32
+ function readJson(p) {
33
+ const text = read(p)
34
+ if (text === null) return null
35
+ let data
36
+ try { data = JSON.parse(text) } catch { return { text, data: null } }
37
+ const indent = /^[ \t]+(?=")/m.exec(text)?.[0] ?? ' '
38
+ const eol = text.endsWith('\n') ? '\n' : ''
39
+ const print = (d) => JSON.stringify(d, null, indent) + eol
40
+ return { text, data, print, exact: print(data) === text }
41
+ }
42
+
43
+ const isPackage = (repo) => readJson(join(repo, 'package.json'))?.data?.name === PKG
44
+ const kindOf = (repo) => field('kind', repo)
45
+ const providesMarketplace = (repo) => readJson(join(repo, '.claude-plugin/marketplace.json'))?.data?.name === 'codelife'
46
+
47
+ // Replaces the lines that declared the marketplace and enabled the plugin by one that turns a
48
+ // former copy off: project settings outrank a person's, so the copy is off for everyone who clones.
49
+ // A repository that never had the former form gets nothing, and one that provides the marketplace
50
+ // itself keeps its settings: the published copy is never the one under edit.
51
+ export function mergeSettings(repo) {
52
+ const p = join(repo, '.claude/settings.json')
53
+ const file = readJson(p)
54
+ if (!file?.data) return { status: file ? 'unreadable' : 'absent' }
55
+ const s = file.data
56
+ if (providesMarketplace(repo)) return { status: 'provides-marketplace' }
57
+ const declared = s.extraKnownMarketplaces?.codelife !== undefined
58
+ const enabled = s.enabledPlugins?.[KEY] !== undefined && s.enabledPlugins[KEY] !== false
59
+ if (!declared && !enabled) return { status: 'unchanged' }
60
+ if (!file.exact) return { status: 'not-round-trip', note: `${p} would be reformatted by an automatic edit: replace the marketplace and plugin lines by "${KEY}": false under enabledPlugins by hand` }
61
+ if (declared) {
62
+ delete s.extraKnownMarketplaces.codelife
63
+ if (!Object.keys(s.extraKnownMarketplaces).length) delete s.extraKnownMarketplaces
64
+ }
65
+ s.enabledPlugins = { ...s.enabledPlugins, [KEY]: false }
66
+ writeFileSync(p, file.print(s))
67
+ return { status: 'migrated' }
68
+ }
69
+
70
+ // What each group of the former form is made of; a group is the /setup item that deletes it. The
71
+ // `scripts` item runs before the package is installed and cannot call this file, so the
72
+ // `.mcp.json` it might have owned goes with `hooks`, the item that installs the package.
73
+ function copies(repo) {
74
+ const has = (f) => existsSync(join(repo, f))
75
+ const full = HOOKS.map((h) => `.githooks/${h}`).filter((f) => has(f) && !read(join(repo, f)).includes(FROM_PACKAGE))
76
+ const own = !has('scripts/changelog.mjs') ? [] : ['scripts/changelog.mjs', 'scripts/node-floor.mjs'].filter(has)
77
+ return {
78
+ hooks: [...full, ...['scripts/spec-form.mjs', ...(kindOf(repo) === 'app' ? [] : ['.mcp.json'])].filter(has)],
79
+ changelog: [...['scripts/changelog.sh', 'cliff.toml'].filter(has), ...own],
80
+ }
81
+ }
82
+
83
+ export function detect(repo) {
84
+ if (isPackage(repo)) return { package: true, hooks: [], changelog: [], postinstall: false, marketplace: false }
85
+ const pkg = readJson(join(repo, 'package.json'))?.data
86
+ const s = readJson(join(repo, '.claude/settings.json'))?.data
87
+ return {
88
+ ...copies(repo),
89
+ postinstall: typeof pkg?.scripts?.postinstall === 'string' && /hooksPath|\.githooks|install\.mjs|codelife/.test(pkg.scripts.postinstall),
90
+ marketplace: s?.extraKnownMarketplaces?.codelife !== undefined || (s?.enabledPlugins?.[KEY] !== undefined && s.enabledPlugins[KEY] !== false),
91
+ }
92
+ }
93
+
94
+ // The contract's guards[] and generated[] name the commands the copies were run by; they name the
95
+ // package's now. Only the frontmatter's command lines are touched, in the commit of the copy gone.
96
+ export function rewriteContract(repo, group) {
97
+ const p = join(repo, '.claude/codelife.md')
98
+ const text = read(p)
99
+ if (text === null) return false
100
+ const rules = {
101
+ hooks: [/(?<![\w/.-])(?:\.\/)?scripts\/spec-form\.mjs/g, `${FROM_PACKAGE}spec-form.mjs`],
102
+ changelog: [/(?:\b(?:bash|sh|node) +)?(?<![\w/.-])(?:\.\/)?scripts\/changelog\.(?:sh|mjs)/g, `node ${FROM_PACKAGE}changelog.mjs`],
103
+ }[group]
104
+ if (!rules) return false
105
+ const end = text.indexOf('\n---', 4)
106
+ const out = text.slice(0, end).replace(...rules) + text.slice(end)
107
+ if (out !== text) writeFileSync(p, out)
108
+ return out !== text
109
+ }
110
+
111
+ // `postinstall` runs in whoever installs a dependency; the package's install belongs to `prepare`.
112
+ export function rewritePrepare(repo) {
113
+ const p = join(repo, 'package.json')
114
+ const file = readJson(p)
115
+ if (!detect(repo).postinstall) return 'unchanged'
116
+ if (!file.exact) return 'not-round-trip'
117
+ const scripts = file.data.scripts
118
+ delete scripts.postinstall
119
+ scripts.prepare = !scripts.prepare ? PREPARE : scripts.prepare.includes(FROM_PACKAGE) ? scripts.prepare : `${scripts.prepare} && ${PREPARE}`
120
+ writeFileSync(p, file.print(file.data))
121
+ return 'migrated'
122
+ }
123
+
124
+ function ignoreLink(repo) {
125
+ const p = join(repo, '.gitignore')
126
+ const text = read(p) ?? ''
127
+ if (/^\/?\.claude\/skills\/codelife\/?$/m.test(text)) return false
128
+ writeFileSync(p, `${text}${text === '' || text.endsWith('\n') ? '' : '\n'}.claude/skills/codelife\n`)
129
+ return true
130
+ }
131
+
132
+ // Deletes one group's copies and rewrites what named them. `hooks` also leaves the repository the
133
+ // package's way: its `prepare`, the ignored link, the settings line and the former copy on this
134
+ // machine, which `claude` removes. The uninstall's exact form is unverified, so its failure is a
135
+ // note, not a stop.
136
+ export function migrate(repo, group, { claude = 'claude' } = {}) {
137
+ const report = { removed: [], rewritten: [], notes: [] }
138
+ if (isPackage(repo)) return report
139
+ const found = detect(repo)
140
+ for (const f of found[group] ?? []) { rmSync(join(repo, f)); report.removed.push(f) }
141
+ if (rewriteContract(repo, group)) report.rewritten.push('.claude/codelife.md')
142
+ if (group === 'hooks') {
143
+ const prepare = rewritePrepare(repo)
144
+ if (prepare === 'migrated') report.rewritten.push('package.json')
145
+ if (prepare === 'not-round-trip') report.notes.push('package.json would be reformatted by an automatic edit: replace postinstall by the prepare line by hand')
146
+ if (ignoreLink(repo)) report.rewritten.push('.gitignore')
147
+ const settings = mergeSettings(repo)
148
+ if (settings.status === 'migrated') report.rewritten.push('.claude/settings.json')
149
+ if (settings.note) report.notes.push(settings.note)
150
+ if (found.marketplace) {
151
+ const r = spawnSync(claude, ['plugin', 'uninstall', KEY], { cwd: repo, encoding: 'utf8', stdio: 'pipe' })
152
+ report.notes.push(r.status === 0 ? `uninstalled ${KEY} from this machine` : `could not uninstall ${KEY} (${r.error?.code ?? `exit ${r.status}`}): run \`claude plugin uninstall ${KEY}\` yourself — unverified form`)
153
+ }
154
+ }
155
+ return report
156
+ }
157
+
158
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
159
+ const [cmd, a, b] = process.argv.slice(2)
160
+ const out = {
161
+ detect: () => detect(a ?? '.'),
162
+ settings: () => mergeSettings(a ?? '.'),
163
+ migrate: () => migrate(b ?? '.', a),
164
+ }[cmd]
165
+ if (!out || (cmd === 'migrate' && !['hooks', 'changelog'].includes(a))) {
166
+ console.error('usage: former-form.mjs detect [repo] | settings [repo] | migrate <hooks|changelog> [repo]')
167
+ process.exit(2)
168
+ }
169
+ console.log(JSON.stringify(out(), null, 2))
170
+ }
@@ -0,0 +1,6 @@
1
+ // git.branches(glob): the local branches and origin's, as this clone last fetched them, whose name
2
+ // the glob matches — one per line, a remote one as `origin/<name>`. Reads only; the forge is not read.
3
+ import { arg, git } from './lib.mjs';
4
+
5
+ const glob = arg(0, '<glob>');
6
+ process.stdout.write(git('for-each-ref', '--format=%(refname:short)', `refs/heads/${glob}`, `refs/remotes/origin/${glob}`));
@@ -0,0 +1,40 @@
1
+ // git.commit(message file, paths): check, touching nothing; stage the paths; commit the index whole
2
+ // from the file, with no stdin for a hook to wait on; print what is left (R-84, C-142). The contract —
3
+ // what it refuses, what it prints, its exit codes — is `codelife help commit`'s.
4
+ import { spawnSync } from 'node:child_process';
5
+ import { existsSync } from 'node:fs';
6
+ import { arg, die, records } from './lib.mjs';
7
+
8
+ const msg = arg(0, '<message file> <path>...'), paths = process.argv.slice(3);
9
+ if (!paths.length) die('usage: node commit.mjs <message file> <path>...', 2);
10
+ if (!existsSync(msg)) die(`no message file ${msg}`, 2);
11
+ if (paths.some((p) => p.includes('\n'))) { console.log('unreadable'); process.exit(2); }
12
+
13
+ const git = (args, stdio = ['ignore', 'pipe', 'inherit']) =>
14
+ spawnSync('git', ['--literal-pathspecs', ...args], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, stdio });
15
+ const read = (...a) => { const r = git(a); if (r.status !== 0) die(`git ${a.join(' ')} failed`, 2); return r.stdout; };
16
+ const names = (rs) => { const out = []; for (let i = 0; i < rs.length; i++) { out.push(rs[i].slice(3)); if (/[RC]/.test(rs[i].slice(0, 2))) i++; } return out; };
17
+ const changed = (...p) => names(records(read('status', '--porcelain', '-z', '--untracked-files=all', ...p)));
18
+
19
+ // A merge, cherry-pick or revert stages what it brings: only outside one is a staged stray a mistake.
20
+ const midway = ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD'].some((h) => git(['rev-parse', '-q', '--verify', h], 'ignore').status === 0);
21
+ if (!midway) {
22
+ const staged = (...p) => records(read('diff', '--cached', '--no-renames', '--name-only', '-z', ...p));
23
+ const covered = new Set(staged('--', ...paths)), stray = staged().filter((f) => !covered.has(f));
24
+ if (stray.length) die(stray.map((f) => `staged beforehand: ${f}`).join('\n'), 2);
25
+ }
26
+ // A deletion `git rm` already staged is that path's change — a file's, or a folder's gone from the
27
+ // disk — and `git add` would find nothing to match.
28
+ const removed = records(read('diff', '--cached', '--no-renames', '--diff-filter=D', '--name-only', '-z', '--', ...paths));
29
+ const adds = paths.filter((p) => !removed.some((f) => f === p || (!existsSync(p) && f.startsWith(`${p.replace(/\/+$/, '')}/`))));
30
+ if (adds.length && git(['add', '--dry-run', '--', ...adds], ['ignore', 'ignore', 'inherit']).status !== 0) process.exit(2);
31
+
32
+ if (adds.length && git(['add', '--', ...adds], ['ignore', 'inherit', 'inherit']).status !== 0) process.exit(2);
33
+ const c = git(['commit', '-q', '-F', msg], ['ignore', 'inherit', 'inherit']);
34
+ if (c.status !== 0) process.exit(c.status === null ? 2 : 1);
35
+
36
+ console.log(read('log', '-1', '--format=%h %s').trim());
37
+ const mine = changed('--', ...paths);
38
+ const say = (label, f) => console.log(f.includes('\n') ? `unreadable: ${label}` : `${label}: ${f}`);
39
+ for (const f of mine) say('left unstaged', f);
40
+ for (const f of changed()) if (!mine.includes(f)) say('not committed', f);
@@ -0,0 +1,17 @@
1
+ // git.diff(base, head): each file of `<base>...<head>` as JSON — `{ status, path, old?, added, removed }`,
2
+ // the status read from --name-status (`A`, `D`, `R100`…), the counts from --numstat (`-` for a binary).
3
+ import { arg, git, records } from './lib.mjs';
4
+
5
+ const range = `${arg(0, '<base> <head>')}...${arg(1, '<base> <head>')}`;
6
+ const files = [], counts = new Map();
7
+ const s = records(git('diff', '--name-status', '-z', range));
8
+ for (let i = 0; i < s.length; i++) {
9
+ const status = s[i];
10
+ files.push(/^[RC]/.test(status) ? { status, old: s[++i], path: s[++i] } : { status, path: s[++i] });
11
+ }
12
+ const c = records(git('diff', '--numstat', '-z', range));
13
+ for (let i = 0; i < c.length; i++) {
14
+ const [added, removed, path] = c[i].split('\t');
15
+ counts.set(path || (i += 2, c[i]), { added, removed });
16
+ }
17
+ console.log(JSON.stringify(files.map((f) => ({ ...f, ...counts.get(f.path) }))));
@@ -0,0 +1,5 @@
1
+ // git.diff_lines(base, head, file): `git diff -U0 <base>...<head>` of one file, as git prints it —
2
+ // its `@@` headers give the lines it may be commented on. The path on stdin, a rename's old one first.
3
+ import { arg, git, stdinPaths } from './lib.mjs';
4
+
5
+ process.stdout.write(git('--literal-pathspecs', 'diff', '-U0', `${arg(0, '<base> <head>')}...${arg(1, '<base> <head>')}`, '--', ...stdinPaths()));
@@ -0,0 +1,53 @@
1
+ // git.held(): the held commits that still wait — the commits of the range that no branch of
2
+ // `origin` holds, so a target merged in brings none, whose footer carries a `Held:` trailer and
3
+ // which no later commit of the range names in a `Replaces:` trailer, by its hash or a prefix of 7 or more. The range
4
+ // is the branch's upstream to HEAD — when it is the branch's own, `origin/<branch>` — or, with none, `origin/<target>` to HEAD, the
5
+ // target the optional argument names, else the parent's. Prints `range: <ref>`, then one
6
+ // `waits\t<hash>\t<issues cited>\t<Held: line>` per commit that waits and one `replaced\t<hash>\t<by>\t<Held: line>`
7
+ // per one a later commit redid, oldest first. `could not tell: <why>`, exit 1, when the range cannot be read.
8
+ import { spawnSync } from 'node:child_process';
9
+ import { dirname, join } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { git } from './lib.mjs';
12
+
13
+ const here = dirname(fileURLToPath(import.meta.url));
14
+ const tell = (why) => {
15
+ console.log(`could not tell: ${why}`);
16
+ process.exit(1);
17
+ };
18
+ const node = (script) => spawnSync(process.execPath, [join(here, script)], { encoding: 'utf8' });
19
+ const branch = git('branch', '--show-current').trim();
20
+ // An upstream that is not the branch's own on `origin` — a local line, another branch — is no range.
21
+ const own = branch && git('for-each-ref', '--format=%(upstream:short)', `refs/heads/${branch}`).trim() === `origin/${branch}`;
22
+ const upstream = own ? `origin/${branch}` : '';
23
+ let ref = upstream;
24
+ if (!ref) {
25
+ let name = process.argv[2];
26
+ if (!name) {
27
+ const p = node('parent.mjs');
28
+ [name] = p.stdout.trim().split(' ');
29
+ if (p.status !== 0 || name === 'finished') tell(p.stdout.trim() || p.stderr.trim() || 'the parent cannot be derived');
30
+ }
31
+ ref = `origin/${name}`;
32
+ }
33
+ const resolves = (r) => spawnSync('git', ['rev-parse', '--verify', '--quiet', `${r}^{commit}`]).status === 0;
34
+ if (!resolves(ref)) {
35
+ // An upstream the merge deleted: a branch HEAD of which `origin/develop` or `origin/main` holds has
36
+ // nothing left to wait. Deleted unmerged, its pushed commits would read as waiting: could not tell.
37
+ const merged = upstream && ['origin/develop', 'origin/main'].find((m) => resolves(m) && spawnSync('git', ['merge-base', '--is-ancestor', 'HEAD', m]).status === 0);
38
+ if (merged) {
39
+ console.log(`range: none — ${branch} is merged into ${merged}, its upstream gone`);
40
+ process.exit(0);
41
+ }
42
+ tell(`${ref} is no commit git can resolve${upstream ? ' — its upstream is gone' : ''}`);
43
+ }
44
+ const log = spawnSync(process.execPath, [join(here, 'log_since.mjs'), ref], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
45
+ if (log.status !== 0) tell(log.stderr.trim() || log.stdout.trim());
46
+ const unpushed = new Set(git('rev-list', 'HEAD', '--not', '--remotes=origin').split('\n'));
47
+ const range = JSON.parse(log.stdout).reverse().filter((c) => unpushed.has(c.sha));
48
+ const redone = (h) => range.find((c) => c.replaces.some((r) => /^[0-9a-f]{7,40}$/i.test(r) && h.sha.startsWith(r.toLowerCase())));
49
+ console.log(`range: ${ref}`);
50
+ for (const h of range.filter((c) => c.held.length)) {
51
+ const by = redone(h);
52
+ console.log(by ? `replaced\t${h.sha}\t${by.sha}\tHeld: ${h.held.join(' ')}` : `waits\t${h.sha}\t${h.closes.join(' ')}\tHeld: ${h.held.join(' ')}`);
53
+ }
@@ -0,0 +1,28 @@
1
+ // git.is_ancestor(a, b): `contained`, `not contained`, `unresolved: <why>`, `no origin` or
2
+ // `could not tell: <git's answer>`; exit 0 for the first two only. A deleted branch and a branch
3
+ // with its own commits are opposite answers, so an unresolvable ref is never `not contained`.
4
+ // A `b` naming a branch of `origin` is read from the forge first, never from the last fetch.
5
+ import { spawnSync } from 'node:child_process';
6
+ import { arg } from './lib.mjs';
7
+
8
+ const a = arg(0, '<a> <b>'), b = arg(1, '<a> <b>');
9
+ const g = (...args) => spawnSync('git', args, { encoding: 'utf8', input: '' });
10
+ const said = (r) => (r.stderr || r.stdout || r.error?.message || '').trim();
11
+ const end = (line, code = 1) => {
12
+ console.log(line);
13
+ process.exit(code);
14
+ };
15
+ if (b.startsWith('origin/')) {
16
+ const branch = b.slice('origin/'.length);
17
+ if (g('remote', 'get-url', 'origin').status !== 0) end('no origin');
18
+ const ls = g('ls-remote', '--exit-code', '--heads', 'origin', branch);
19
+ if (ls.status === 2) end(`unresolved: origin has no branch ${branch}`);
20
+ if (ls.status !== 0) end(`could not tell: ${said(ls)}`);
21
+ const f = g('fetch', 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`);
22
+ if (f.status !== 0) end(`could not tell: ${said(f)}`);
23
+ }
24
+ for (const ref of [a, b]) if (g('rev-parse', '--verify', '--quiet', `${ref}^{commit}`).status !== 0) end(`unresolved: ${ref} names no commit`);
25
+ const r = g('merge-base', '--is-ancestor', a, b);
26
+ if (r.status === 0) end('contained', 0);
27
+ if (r.status === 1) end('not contained', 0);
28
+ end(`could not tell: ${said(r)}`);
@@ -0,0 +1,20 @@
1
+ // What the scripts under scripts/git/ share — each runs one read or write of git, named after it.
2
+ // From the repository's root. A
3
+ // path a caller holds — a name the merge request chose — reaches a script on stdin, NUL-separated,
4
+ // never as an argument; `commit`'s reach it on its command line.
5
+ import '../node-floor.mjs';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { readFileSync } from 'node:fs';
8
+
9
+ export const die = (why, code = 1) => {
10
+ console.error(why);
11
+ process.exit(code);
12
+ };
13
+ export const git = (...args) => {
14
+ const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
15
+ if (r.status !== 0) die(`git ${args.join(' ')}: ${r.stderr.trim()}`);
16
+ return r.stdout;
17
+ };
18
+ export const records = (text) => text.split('\0').filter(Boolean);
19
+ export const stdinPaths = () => records(readFileSync(0, 'utf8'));
20
+ export const arg = (i, what) => process.argv[2 + i] || die(`usage: node ${process.argv[1].split('/').pop()} ${what}`, 2);
@@ -0,0 +1,27 @@
1
+ // git.log_since(ref, tip?): each commit of `<ref>..<tip>`, `HEAD` when no tip is given, as JSON,
2
+ // `{ sha, subject, type, scope, breaking, closes, refs, held, replaces, answers }`, `type` null for a subject that does not parse as a Conventional Commit, the trailers each a list (a `Held:` line stays whole, it is a sentence) — an
3
+ // `Answers: pass <k>: <n>, <m>` one row each, `pass <k> <n>`, so a re-run knows which pass's <n>.
4
+ // With an issue number in place of the tip — `codelife log since <ref> <n>` — one `<sha> <subject>`
5
+ // line per commit whose `Closes:` or `Refs:` footer cites #n, exit 1 when none does (R-201).
6
+ import { arg, git } from './lib.mjs';
7
+
8
+ const ref = arg(0, '<ref> [<tip> | <issue>]');
9
+ const cites = /^\d+$/.test(process.argv[3] ?? '') ? `#${process.argv[3]}` : null, range = [`${ref}..${cites ? 'HEAD' : process.argv[3] ?? 'HEAD'}`];
10
+ const trailer = (body, key) => [...body.matchAll(new RegExp(`^${key}:[ \\t]*(.+)$`, 'gim'))].flatMap((m) => {
11
+ if (key === 'Held') return [m[1].trim()];
12
+ const pass = m[1].match(/^pass (\d+):\s*/i);
13
+ return m[1].slice(pass ? pass[0].length : 0).split(/[ ,]+/).filter(Boolean).map((x) => (pass ? `pass ${pass[1]} ${x}` : x));
14
+ });
15
+ const commits = git('log', '--format=%H%x1f%s%x1f%b%x1e', ...range).split('\x1e').map((r) => r.trim()).filter(Boolean).map((r) => {
16
+ const [sha, subject, body = ''] = r.split('\x1f');
17
+ const cc = subject.match(/^(\w+)(?:\(([^)]+)\))?(!)?: (.+)$/);
18
+ return { sha, subject, type: cc?.[1] ?? null, scope: cc?.[2] ?? null, breaking: !!cc?.[3] || /^BREAKING CHANGE:/m.test(body),
19
+ closes: trailer(body, 'Closes'), refs: trailer(body, 'Refs'), held: trailer(body, 'Held'), replaces: trailer(body, 'Replaces'),
20
+ answers: trailer(body, 'Answers') };
21
+ });
22
+ if (!cites) console.log(JSON.stringify(commits));
23
+ else {
24
+ const mine = commits.filter((c) => [...c.closes, ...c.refs].includes(cites));
25
+ for (const c of mine) console.log(`${c.sha.slice(0, 8)} ${c.subject}`);
26
+ if (!mine.length) { console.error(`no commit of ${ref}..HEAD cites ${cites}`); process.exit(1); }
27
+ }
@@ -0,0 +1,51 @@
1
+ // git.parent(): the branch the current one was cut from, by merge base — `<branch> <commits ahead>`;
2
+ // `finished <line>` when its own commits are merged into a line; `tie <candidates…>` or `none`, exit 1.
3
+ // The candidates are the lines — `develop`, `main`, `support/*` — and the other work branches, each
4
+ // read local and on `origin`: a branch cut from `origin/develop` names `develop` while the local one is
5
+ // ahead, and a branch cut from another lot's branch names that branch.
6
+ import { spawnSync } from 'node:child_process';
7
+ import { git } from './lib.mjs';
8
+
9
+ const holds = (a, b) => spawnSync('git', ['merge-base', '--is-ancestor', a, b]).status === 0;
10
+ const sha = (ref) => git('rev-parse', `${ref}^{commit}`).trim();
11
+ const head = sha('HEAD');
12
+ const self = spawnSync('git', ['symbolic-ref', '-q', '--short', 'HEAD'], { encoding: 'utf8' }).stdout.trim();
13
+ const isLine = (n) => /^(develop|main|support\/.+)$/.test(n);
14
+ const refs = git('for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes/origin').split('\n')
15
+ .map((ref) => ({ ref, name: ref.replace(/^refs\/(heads|remotes\/origin)\//, '') }))
16
+ .filter(({ ref, name }) => ref && name !== self && name !== 'HEAD' && (isLine(name) || /^(feature|fix|hotfix|lot)\//.test(name)));
17
+ const lines = refs.filter((r) => isLine(r.name));
18
+
19
+ // Merge commits everywhere: merged work reaches a line as a merge's second parent, so its tip lies in
20
+ // the line off every line's first-parent history; a branch with no commit of its own lies on it.
21
+ const firstParents = new Set(lines.flatMap((r) => git('rev-list', '--first-parent', r.ref).split('\n')));
22
+ const mergedInto = (c) => (firstParents.has(c) ? undefined : lines.find((r) => holds(c, r.ref))?.name);
23
+ const done = mergedInto(head);
24
+ if (done) {
25
+ console.log(`finished ${done}`);
26
+ process.exit(0);
27
+ }
28
+
29
+ // Each candidate stands at its merge base with HEAD, so a line that moved on since the cut is still
30
+ // at the commit the branch left it. A work branch whose merge base is HEAD holds HEAD already: it was
31
+ // cut from this one, not this one's base. One already merged stands for the line that holds it.
32
+ const found = refs.flatMap((r) => {
33
+ const base = git('merge-base', r.ref, 'HEAD').trim();
34
+ if (base === head && !isLine(r.name)) return [];
35
+ const name = isLine(r.name) ? r.name : mergedInto(sha(r.ref)) ?? r.name;
36
+ return [{ name, tip: base, at: sha(r.ref) === base, ahead: +git('rev-list', '--count', `${base}..HEAD`) }];
37
+ });
38
+ const fewest = Math.min(...found.map((c) => c.ahead));
39
+ let best = [...new Map(found.filter((c) => c.ahead === fewest).map((c) => [c.name, c])).values()];
40
+ // Candidates at one merge base are not a tie: a line before a work branch, then the branch's own prefix,
41
+ // then one still at that base, as a support line is at its tag while `main` moved past it.
42
+ if (new Set(best.map((c) => c.tip)).size === 1) {
43
+ for (const keep of [(c) => isLine(c.name), (c) => (c.name === 'develop') !== self.startsWith('hotfix/'), (c) => c.at]) {
44
+ if (best.some(keep)) best = best.filter(keep);
45
+ }
46
+ }
47
+ if (best.length !== 1) {
48
+ console.log(best.length ? `tie ${best.map((c) => c.name).join(' ')}` : 'none');
49
+ process.exit(1);
50
+ }
51
+ console.log(`${best[0].name} ${best[0].ahead}`);
@@ -0,0 +1,4 @@
1
+ // git.show(ref, path): the file at `ref`, not the tree's — `git show '<ref>:<path>'`. The path on stdin.
2
+ import { arg, git, stdinPaths } from './lib.mjs';
3
+
4
+ process.stdout.write(git('show', `${arg(0, '<ref> < <path>')}:${stdinPaths()[0]}`));