@erclx/canon 4.3.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/claude-markdown-propose/REQUIREMENT.md +0 -1
  3. package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +34 -9
  4. package/claude/skills/repo-metadata/REQUIREMENT.md +37 -0
  5. package/claude/skills/repo-metadata/SKILL.md +51 -0
  6. package/docs/agents/audits.md +6 -6
  7. package/docs/agents/commands.md +64 -62
  8. package/docs/agents/context-audit.md +2 -2
  9. package/docs/agents/index.md +1 -1
  10. package/docs/agents/records.md +17 -7
  11. package/docs/agents/sandbox.md +3 -1
  12. package/docs/agents/tasks.md +36 -1
  13. package/docs/operating-model.md +1 -1
  14. package/package.json +1 -1
  15. package/scripts/core/check-ignore-parity.sh +9 -3
  16. package/scripts/lib/sandbox-dispatch.sh +182 -0
  17. package/src/audits/baseline.ts +1 -1
  18. package/src/claude/cases/misc.ts +4 -0
  19. package/src/cli.ts +4 -0
  20. package/src/commands/claude.ts +7 -1
  21. package/src/commands/context.ts +3 -3
  22. package/src/commands/design.ts +6 -1
  23. package/src/commands/feedback.ts +5 -1
  24. package/src/commands/gov.ts +2 -1
  25. package/src/commands/repo.ts +393 -0
  26. package/src/commands/slides.ts +6 -1
  27. package/src/commands/tasks.ts +100 -0
  28. package/src/context/citations.ts +16 -5
  29. package/src/context/folders.ts +18 -8
  30. package/src/gate/measures.ts +1 -1
  31. package/src/intake/folder.ts +2 -1
  32. package/src/paths.ts +16 -0
  33. package/src/record-root.ts +134 -0
  34. package/src/records/backup.ts +40 -22
  35. package/src/records/size.ts +12 -7
  36. package/src/records/validate.ts +35 -17
  37. package/src/repo/metadata.ts +206 -0
  38. package/src/tasks/answers.ts +202 -0
  39. package/src/tasks/archive.ts +33 -30
  40. package/src/teach/workspace.ts +2 -1
  41. package/tooling/claude/manifest.toml +1 -1
  42. package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +8 -1
  43. package/tooling/claude/seeds/.claude/hooks/memory-index.sh +28 -11
  44. package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +12 -3
  45. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +4 -0
  46. package/tooling/claude/seeds/.claude/hooks/tasks-index.sh +28 -10
package/src/paths.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { sep } from 'node:path'
2
+
3
+ /**
4
+ * Whether a resolved path is the directory itself or sits inside it. The
5
+ * separator guard is the whole of it: a bare prefix test reads
6
+ * `.claude/plans-archive` as living under `.claude/plans`, which is a sibling
7
+ * rather than a child and is exactly the pair the plan folders spell.
8
+ *
9
+ * It lives at the root rather than beside either caller because both resolve
10
+ * plan paths and only one of them may reach `src/tasks/archive.ts`, whose
11
+ * index regeneration pulls in a Bun-only import that a test running under
12
+ * Vitest cannot load.
13
+ */
14
+ export function isUnder(path: string, dir: string): boolean {
15
+ return path === dir || path.startsWith(`${dir}${sep}`)
16
+ }
@@ -0,0 +1,134 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ /**
5
+ * The roots a session record folder is read at, in precedence order.
6
+ *
7
+ * `.canon/` wins because a tree that carries it has been migrated, and reading
8
+ * `.claude/` there would answer from the copy the move left behind. A tree that
9
+ * carries neither is every tree today, which is what keeps this branch a no-op
10
+ * until the move lands.
11
+ *
12
+ * The shape is `readStamp`'s: order the spellings, take the first that exists,
13
+ * and stand the creation default in when none does.
14
+ */
15
+ export const RECORD_ROOTS = ['.canon', '.claude'] as const
16
+
17
+ export type RecordRoot = (typeof RECORD_ROOTS)[number]
18
+
19
+ /**
20
+ * The root a record folder is created at.
21
+ *
22
+ * It disagrees with the read precedence above on purpose, and the disagreement
23
+ * is the whole of this branch. Creating under `.canon/` before the move would
24
+ * write records to a root whose ignore line may not have reached a target yet,
25
+ * and it would split one project's records across two roots with no verb able
26
+ * to reconcile them. The move flips this line and nothing else.
27
+ */
28
+ export const CREATION_ROOT: RecordRoot = '.claude'
29
+
30
+ /**
31
+ * The deletable scratch folder, named at the spelling `.claude/` gives it.
32
+ *
33
+ * It is the one folder whose name differs by root. Inside a dotted root the
34
+ * leading dot hides nothing already hidden and costs a bare `ls` that omits the
35
+ * folder, so the move drops it. Every other record folder keeps its name,
36
+ * `.records.git` included, where the dot marks the mechanism apart from a
37
+ * payload rather than hiding it.
38
+ */
39
+ export const SCRATCH = '.tmp'
40
+
41
+ /** The scratch folder's name under `.canon/`. */
42
+ const CANON_SCRATCH = 'tmp'
43
+
44
+ /** How a root spells a folder name. Only the scratch folder differs. */
45
+ function spell(root: RecordRoot, folder: string): string {
46
+ return root === '.canon' && folder === SCRATCH ? CANON_SCRATCH : folder
47
+ }
48
+
49
+ /**
50
+ * The root a folder resolves at: the first that carries it, and the creation
51
+ * default when neither does.
52
+ *
53
+ * Presence is read on the record folder itself rather than on the full path, so
54
+ * an archive or a payload that does not exist yet still resolves beside the
55
+ * records it belongs to rather than at the creation default.
56
+ */
57
+ function rootOf(root: string, folder: string): RecordRoot {
58
+ return (
59
+ RECORD_ROOTS.find((candidate) =>
60
+ existsSync(join(root, candidate, spell(candidate, folder))),
61
+ ) ?? CREATION_ROOT
62
+ )
63
+ }
64
+
65
+ /**
66
+ * Where a record folder is read.
67
+ *
68
+ * `folder` is the record folder itself and `rest` is whatever sits inside it,
69
+ * so a caller spells no root and no folder-name variant of its own. A caller
70
+ * that spells `.claude` by hand is the one thing the move has to find, and one
71
+ * that calls this is one the move never has to open again.
72
+ */
73
+ export function recordDir(
74
+ root: string,
75
+ folder: string,
76
+ ...rest: string[]
77
+ ): string {
78
+ const at = rootOf(root, folder)
79
+
80
+ return join(root, at, spell(at, folder), ...rest)
81
+ }
82
+
83
+ /**
84
+ * Every root a record folder would be read at, in precedence order, whether or
85
+ * not it is on disk.
86
+ *
87
+ * Containment tests take this rather than `recordDir`, since a path written
88
+ * against the root a tree no longer uses is still a path into that folder and
89
+ * reading it as outside would report a shipped plan as still live.
90
+ */
91
+ export function recordDirs(
92
+ root: string,
93
+ folder: string,
94
+ ...rest: string[]
95
+ ): string[] {
96
+ return RECORD_ROOTS.map((candidate) =>
97
+ join(root, candidate, spell(candidate, folder), ...rest),
98
+ )
99
+ }
100
+
101
+ /** Where a record folder is created, which is the creation default always. */
102
+ export function creationDir(
103
+ root: string,
104
+ folder: string,
105
+ ...rest: string[]
106
+ ): string {
107
+ return join(root, CREATION_ROOT, spell(CREATION_ROOT, folder), ...rest)
108
+ }
109
+
110
+ /**
111
+ * The creation destination relative to the project root, which is the form a
112
+ * message displays and an option default carries.
113
+ */
114
+ export function creationRel(folder: string, ...rest: string[]): string {
115
+ return join(CREATION_ROOT, spell(CREATION_ROOT, folder), ...rest)
116
+ }
117
+
118
+ /**
119
+ * The record root itself, for a caller whose subject is the root rather than a
120
+ * folder inside it.
121
+ *
122
+ * A half-migrated tree resolves here on the root that exists rather than on the
123
+ * folders under it, which is what makes the backup history and its work tree
124
+ * one answer. Splitting them would let a push resolve a history at one root and
125
+ * stage a work tree at the other, which stages the deletion of every folder the
126
+ * move relocated.
127
+ */
128
+ export function recordRoot(root: string): string {
129
+ return join(
130
+ root,
131
+ RECORD_ROOTS.find((candidate) => existsSync(join(root, candidate))) ??
132
+ CREATION_ROOT,
133
+ )
134
+ }
@@ -1,16 +1,18 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { join, resolve } from 'node:path'
2
+ import { join, relative, resolve } from 'node:path'
3
3
  import { $ } from 'bun'
4
4
  import { gitEnv } from '@/git-env'
5
+ import { recordRoot } from '@/record-root'
5
6
 
6
7
  /**
7
- * The folders a backup carries, relative to `.claude/`. Most of them are the
8
- * `# Claude` group the claude manifest ships, minus three: `.claude/.tmp`,
9
- * which is defined as deletable without loss, `.claude/worktrees/`, whose
10
- * contents belong to the enclosing repository already, and
11
- * `.claude/.records.git/`, which is the history the rest are pushed into. The
12
- * list is spelled out rather than read off that group so adding an ignore entry
13
- * cannot silently enlarge the payload.
8
+ * The folders a backup carries, relative to the record root `workTree` resolves
9
+ * rather than to `.claude/` specifically, since the same nine names sit under
10
+ * whichever root a tree holds. Most of them are the `# Claude` group the claude
11
+ * manifest ships, minus three: the scratch folder, which is defined as deletable
12
+ * without loss, `worktrees/`, whose contents belong to the enclosing repository
13
+ * already, and `.records.git/`, which is the history the rest are pushed into.
14
+ * The list is spelled out rather than read off that group so adding an ignore
15
+ * entry cannot silently enlarge the payload.
14
16
  *
15
17
  * `diagrams` is the one name the manifest group does not carry, so a target
16
18
  * tracks it where this repository ignores it. That is the second reason to
@@ -65,10 +67,25 @@ const RETIRED_FOLDERS = [
65
67
  'task-archive',
66
68
  ] as const
67
69
 
68
- /** Holds the records history beside the folders it tracks, ignored by the enclosing repository. */
69
- const RECORDS_GIT_DIR = join('.claude', '.records.git')
70
+ /** The history directory's own name, which keeps its dot at either record root. */
71
+ const RECORDS_GIT_NAME = '.records.git'
70
72
 
71
- const WORK_TREE = '.claude'
73
+ /**
74
+ * The tree a backup stages, which is the record root itself.
75
+ *
76
+ * It resolves the root rather than each folder under it, so the history and the
77
+ * work tree are one answer. Resolving them apart would let a half-migrated tree
78
+ * open a history at one root and stage a work tree at the other, which stages
79
+ * the deletion of every folder the move relocated and pushes it.
80
+ */
81
+ function workTree(root: string): string {
82
+ return recordRoot(root)
83
+ }
84
+
85
+ /** Holds the records history beside the folders it tracks, ignored by the enclosing repository. */
86
+ function recordsGitDir(root: string): string {
87
+ return join(workTree(root), RECORDS_GIT_NAME)
88
+ }
72
89
 
73
90
  /** Both directions name the branch, so a machine whose `init.defaultBranch` differs still lands on it. */
74
91
  const RECORDS_BRANCH = 'main'
@@ -150,11 +167,11 @@ interface GitResult {
150
167
  * work tree.
151
168
  */
152
169
  async function records(root: string, args: string[]): Promise<GitResult> {
153
- const gitDir = resolve(root, RECORDS_GIT_DIR)
154
- const workTree = resolve(root, WORK_TREE)
170
+ const gitDir = resolve(recordsGitDir(root))
171
+ const tree = resolve(workTree(root))
155
172
 
156
173
  const result =
157
- await $`git -C ${workTree} --git-dir=${gitDir} --work-tree=${workTree} ${args}`
174
+ await $`git -C ${tree} --git-dir=${gitDir} --work-tree=${tree} ${args}`
158
175
  .env(gitEnv())
159
176
  .quiet()
160
177
  .nothrow()
@@ -237,13 +254,15 @@ async function enclosingRemoteUrls(
237
254
  * cannot be read is what keeps a failed comparison from reading as a pass.
238
255
  */
239
256
  async function resolveRemote(root: string): Promise<string | BackupRefused> {
240
- if (!existsSync(join(root, RECORDS_GIT_DIR))) {
257
+ const gitDir = recordsGitDir(root)
258
+
259
+ if (!existsSync(gitDir)) {
241
260
  return refuse(
242
261
  'no-repository',
243
262
  [
244
- `No records history at ${RECORDS_GIT_DIR}. Create it once, against a private repository:`,
245
- ` git --git-dir=${join(root, RECORDS_GIT_DIR)} init`,
246
- ` git --git-dir=${join(root, RECORDS_GIT_DIR)} remote add origin <private-repo-url>`,
263
+ `No records history at ${relative(root, gitDir)}. Create it once, against a private repository:`,
264
+ ` git --git-dir=${gitDir} init`,
265
+ ` git --git-dir=${gitDir} remote add origin <private-repo-url>`,
247
266
  ].join('\n'),
248
267
  )
249
268
  }
@@ -254,7 +273,7 @@ async function resolveRemote(root: string): Promise<string | BackupRefused> {
254
273
  'no-remote',
255
274
  [
256
275
  'The records history has no origin. Point it at a private repository:',
257
- ` git --git-dir=${join(root, RECORDS_GIT_DIR)} remote add origin <private-repo-url>`,
276
+ ` git --git-dir=${gitDir} remote add origin <private-repo-url>`,
258
277
  ].join('\n'),
259
278
  )
260
279
  }
@@ -299,8 +318,7 @@ async function scopedFolders(root: string): Promise<string[]> {
299
318
  )
300
319
 
301
320
  return [...BACKED_FOLDERS, ...RETIRED_FOLDERS].filter(
302
- (folder) =>
303
- existsSync(join(root, WORK_TREE, folder)) || indexed.has(folder),
321
+ (folder) => existsSync(join(workTree(root), folder)) || indexed.has(folder),
304
322
  )
305
323
  }
306
324
 
@@ -311,7 +329,7 @@ function topSegment(path: string): string {
311
329
  /** What a report names, which is the folders a reader can go and open. */
312
330
  function presentFolders(root: string): string[] {
313
331
  return BACKED_FOLDERS.filter((folder) =>
314
- existsSync(join(root, WORK_TREE, folder)),
332
+ existsSync(join(workTree(root), folder)),
315
333
  )
316
334
  }
317
335
 
@@ -1,12 +1,13 @@
1
1
  import { existsSync, type Stats } from 'node:fs'
2
2
  import { readdir, stat } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
+ import { RECORD_ROOTS, recordDir, SCRATCH } from '@/record-root'
4
5
  import { BACKED_FOLDERS } from '@/records/backup'
5
6
 
6
7
  /**
7
- * The folders a size reading covers, relative to `.claude/`.
8
+ * The folders a size reading covers, named at the record root they sit under.
8
9
  *
9
- * It is the backed set plus `.tmp`, which a backup skips because it is
10
+ * It is the backed set plus the scratch folder, which a backup skips because it is
10
11
  * deletable without loss and a reading covers because deletable is not the same
11
12
  * as empty: the routing handoffs and the memory archive both sit there and both
12
13
  * accumulate. `.records.git` stays out because it is the backup history rather
@@ -14,7 +15,7 @@ import { BACKED_FOLDERS } from '@/records/backup'
14
15
  * checkout of the enclosing repository with its own removal verb, and one of
15
16
  * them outweighs every record folder combined.
16
17
  */
17
- export const SIZED_FOLDERS = [...BACKED_FOLDERS, '.tmp'] as const
18
+ export const SIZED_FOLDERS = [...BACKED_FOLDERS, SCRATCH] as const
18
19
 
19
20
  /**
20
21
  * The windows a reading reports, in days.
@@ -34,7 +35,7 @@ export interface WindowCount {
34
35
  }
35
36
 
36
37
  export interface FolderSize {
37
- /** Relative to `.claude/`, which is the name a reader opens. */
38
+ /** Relative to the record root, which is the name a reader opens. */
38
39
  readonly folder: string
39
40
  readonly present: boolean
40
41
  readonly files: number
@@ -170,7 +171,7 @@ async function measure(
170
171
  folder: string,
171
172
  now: number,
172
173
  ): Promise<FolderSize> {
173
- const path = join(root, '.claude', folder)
174
+ const path = recordDir(root, folder)
174
175
  const empty = GROWTH_WINDOWS.map((days) => ({ days, files: 0 }))
175
176
 
176
177
  if (!existsSync(path)) {
@@ -212,11 +213,15 @@ export async function sizeRecords(
212
213
  root: string,
213
214
  now: number = Date.now(),
214
215
  ): Promise<SizeOutcome> {
215
- if (!existsSync(join(root, '.claude'))) {
216
+ // Either root answers, so a migrated tree is read rather than refused. The
217
+ // roots are tested rather than the folders under them, since a project that
218
+ // holds the root and no records yet is empty rather than absent and the
219
+ // per-folder `present` flags already say which of the ten it carries.
220
+ if (!RECORD_ROOTS.some((name) => existsSync(join(root, name)))) {
216
221
  return {
217
222
  ok: false,
218
223
  reason: 'no-folder',
219
- message: `No .claude directory at ${root}, so there are no record folders to read.`,
224
+ message: `No ${RECORD_ROOTS.join(' or ')} directory at ${root}, so there are no record folders to read.`,
220
225
  }
221
226
  }
222
227
 
@@ -3,6 +3,10 @@ import { readdir, readFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
4
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
5
5
  import { linesOutsideFences } from '@/markdown/scan'
6
+ import {
7
+ recordDir as resolveRecordDir,
8
+ recordDirs as resolveRecordDirs,
9
+ } from '@/record-root'
6
10
  import {
7
11
  TEACH_GLOSSARY,
8
12
  TEACH_MISSION,
@@ -25,22 +29,22 @@ export const RECORD_KINDS = [
25
29
  export type RecordKind = (typeof RECORD_KINDS)[number]
26
30
 
27
31
  /**
28
- * The folders each kind reads, in precedence order.
32
+ * The folders standards reads, in precedence order.
29
33
  *
30
- * Standards carry two because the corpus authors at the project root and
31
- * installs under `.claude/`. The authoring root wins where both exist, since the
34
+ * It carries two because the corpus authors at the project root and installs
35
+ * under `.claude/`. The authoring root wins where both exist, since the
32
36
  * installed tree is a generated copy here and a finding fixed there is
33
37
  * overwritten by the next regen. A project that consumed the corpus holds only
34
38
  * the second, so one order serves both.
39
+ *
40
+ * Every other kind is a session record and takes the record roots instead,
41
+ * resolved by `@/record-root`. Standards is the one kind that is tracked, so it
42
+ * does not move and spells its own two candidates here.
35
43
  */
36
- const FOLDERS_BY_KIND: Readonly<Record<RecordKind, readonly string[]>> = {
37
- plans: [join('.claude', 'plans')],
38
- groundwork: [join('.claude', 'groundwork')],
39
- intake: [join('.claude', 'intake')],
40
- memory: [join('.claude', 'memory')],
41
- standards: ['standards', join('.claude', 'standards')],
42
- teach: [join('.claude', 'teach')],
43
- }
44
+ const STANDARDS_FOLDERS: readonly string[] = [
45
+ 'standards',
46
+ join('.claude', 'standards'),
47
+ ]
44
48
 
45
49
  /**
46
50
  * `unknown-kind` is raised at the argument boundary rather than by the walk, and
@@ -107,17 +111,31 @@ export type ValidateOutcome = ValidateReport | ValidateRefused
107
111
 
108
112
  /** Every folder a kind would accept, whether or not it is on disk. */
109
113
  export function recordDirs(root: string, kind: RecordKind): string[] {
110
- return FOLDERS_BY_KIND[kind].map((folder) => join(root, folder))
114
+ if (kind === 'standards') {
115
+ return STANDARDS_FOLDERS.map((folder) => join(root, folder))
116
+ }
117
+
118
+ return resolveRecordDirs(root, kind)
111
119
  }
112
120
 
113
121
  /**
114
- * The folder a kind reads. The first candidate on disk wins, and the first
115
- * candidate stands in when none exists, so a refusal and a test fixture both
116
- * name the location the kind prefers.
122
+ * The folder a kind reads. The first candidate on disk wins, and the creation
123
+ * default stands in when none exists, so a refusal and a test fixture both name
124
+ * the location the kind would be written to.
125
+ *
126
+ * The default is where this parts company with `dirs[0]`, which the record
127
+ * kinds can no longer take: their first candidate is the root the move lands at
128
+ * and nothing creates there yet, so a fallback reading it would name a folder no
129
+ * verb would ever write. Standards has no such split, since it is tracked and
130
+ * its first candidate is where it is authored.
117
131
  */
118
132
  export function recordsDir(root: string, kind: RecordKind): string {
119
- const dirs = recordDirs(root, kind)
120
- return dirs.find((dir) => existsSync(dir)) ?? dirs[0]
133
+ if (kind === 'standards') {
134
+ const dirs = recordDirs(root, kind)
135
+ return dirs.find((dir) => existsSync(dir)) ?? dirs[0]
136
+ }
137
+
138
+ return resolveRecordDir(root, kind)
121
139
  }
122
140
 
123
141
  export function isRecordKind(value: string): value is RecordKind {
@@ -0,0 +1,206 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ /**
5
+ * What this run can compute from the tree alone. A field stays absent rather
6
+ * than empty when nothing local resolves it, so a caller never reads
7
+ * "nothing to propose" as "propose removing what is already there."
8
+ */
9
+ export interface MetadataProposal {
10
+ readonly description?: string
11
+ readonly homepage?: string
12
+ readonly topics?: readonly string[]
13
+ }
14
+
15
+ /** What the remote already carries, read by the caller rather than here. */
16
+ export interface CurrentMetadata {
17
+ readonly description: string
18
+ readonly homepage: string
19
+ readonly topics: readonly string[]
20
+ }
21
+
22
+ export interface MetadataDiff {
23
+ readonly description?: { readonly current: string; readonly proposed: string }
24
+ readonly homepage?: { readonly current: string; readonly proposed: string }
25
+ readonly topics?: {
26
+ readonly added: readonly string[]
27
+ readonly removed: readonly string[]
28
+ }
29
+ }
30
+
31
+ /** GitHub's own cap on the About field. */
32
+ const MAX_DESCRIPTION_LENGTH = 350
33
+
34
+ /** GitHub's own cap on topics per repository. */
35
+ const MAX_TOPICS = 20
36
+
37
+ /** GitHub's own shape for a topic: lowercase, alphanumeric, internal hyphens. */
38
+ const TOPIC_PATTERN = /^[a-z0-9][a-z0-9-]*$/
39
+
40
+ /**
41
+ * Whether an already-trimmed, already-lowercased string is a shape GitHub
42
+ * accepts as a topic. Exported so a caller validating an operator-supplied
43
+ * topic list checks against the same rule this reader silently filters
44
+ * `package.json`'s `keywords` through, rather than reimplementing it against
45
+ * a looser test such as non-emptiness alone.
46
+ */
47
+ export function isValidTopic(topic: string): boolean {
48
+ return TOPIC_PATTERN.test(topic)
49
+ }
50
+
51
+ /**
52
+ * A bare image, or an image wrapped in a link, which is the shape a shields.io
53
+ * badge takes. The wrapped alternative goes first, since the bare form would
54
+ * otherwise match its inner image alone and leave the wrapping link behind.
55
+ */
56
+ const BADGE_TOKEN = /\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)|!\[[^\]]*\]\([^)]*\)/g
57
+
58
+ /** True once every badge token is stripped and nothing remains. */
59
+ function isBadgeLine(line: string): boolean {
60
+ return line.replace(BADGE_TOKEN, '').trim() === ''
61
+ }
62
+
63
+ function stripInlineMarkdown(line: string): string {
64
+ return line
65
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
66
+ .replace(/(\*\*|__)(.+?)\1/g, '$2')
67
+ .replace(/(\*|_|`)(.+?)\1/g, '$2')
68
+ .trim()
69
+ }
70
+
71
+ /**
72
+ * The first prose line past the title and any badge row, stripped of inline
73
+ * markdown and capped at GitHub's About field length.
74
+ *
75
+ * `undefined` only when the file carries nothing past its title and badges,
76
+ * per the skill requirement's refusal boundary: a resolvable line that reads
77
+ * as a title rather than a sentence is still proposed, since rejecting it is
78
+ * a judgment for the person reading the proposal rather than for this reader.
79
+ */
80
+ export function extractOpeningLine(readme: string): string | undefined {
81
+ for (const raw of readme.split('\n')) {
82
+ const line = raw.trim()
83
+ if (line === '' || line.startsWith('#') || isBadgeLine(line)) continue
84
+
85
+ const stripped = stripInlineMarkdown(line)
86
+ if (stripped === '') continue
87
+
88
+ return stripped.length > MAX_DESCRIPTION_LENGTH
89
+ ? `${stripped.slice(0, MAX_DESCRIPTION_LENGTH - 1)}…`
90
+ : stripped
91
+ }
92
+ return undefined
93
+ }
94
+
95
+ /**
96
+ * `package.json`'s `keywords` read as candidate topics, the field the wider
97
+ * npm ecosystem already uses for this. Invalid entries are dropped silently
98
+ * rather than refused, since a manifest mixing free-text keywords with
99
+ * topic-shaped ones is ordinary and only the shaped half transfers.
100
+ */
101
+ function readTopics(keywords: unknown): readonly string[] | undefined {
102
+ if (!Array.isArray(keywords)) return undefined
103
+
104
+ const topics = new Set<string>()
105
+ for (const entry of keywords) {
106
+ if (typeof entry !== 'string') continue
107
+ const topic = entry.trim().toLowerCase()
108
+ if (isValidTopic(topic)) topics.add(topic)
109
+ if (topics.size === MAX_TOPICS) break
110
+ }
111
+ return [...topics]
112
+ }
113
+
114
+ interface PackageFields {
115
+ readonly homepage?: unknown
116
+ readonly keywords?: unknown
117
+ }
118
+
119
+ async function readManifest(root: string): Promise<PackageFields | undefined> {
120
+ try {
121
+ const parsed: unknown = JSON.parse(
122
+ await readFile(join(root, 'package.json'), 'utf8'),
123
+ )
124
+ return (parsed ?? undefined) as PackageFields | undefined
125
+ } catch {
126
+ return undefined
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Computes what this tree can propose for its own repository metadata, with
132
+ * no network call: an About text from the README's opening line, and a
133
+ * homepage and a topic set from `package.json`, the one manifest every
134
+ * target project this ships to already carries.
135
+ *
136
+ * A field a target project declares nowhere stays absent rather than empty,
137
+ * so the caller comparing this against the remote never treats a field this
138
+ * reader has no opinion on as a proposal to clear it.
139
+ */
140
+ export async function proposeMetadata(root: string): Promise<MetadataProposal> {
141
+ const [readme, manifest] = await Promise.all([
142
+ readFile(join(root, 'README.md'), 'utf8').catch(() => undefined),
143
+ readManifest(root),
144
+ ])
145
+
146
+ const description =
147
+ readme === undefined ? undefined : extractOpeningLine(readme)
148
+
149
+ const homepage =
150
+ typeof manifest?.homepage === 'string' && manifest.homepage.trim() !== ''
151
+ ? manifest.homepage.trim()
152
+ : undefined
153
+
154
+ const topics = readTopics(manifest?.keywords)
155
+
156
+ return {
157
+ ...(description !== undefined && { description }),
158
+ ...(homepage !== undefined && { homepage }),
159
+ ...(topics !== undefined && topics.length > 0 && { topics }),
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Compares a computed proposal against what the remote already carries.
165
+ *
166
+ * A field the proposal has no opinion on is never diffed, which is what
167
+ * keeps a target project with no `keywords` field from seeing every existing
168
+ * topic reported as a removal.
169
+ */
170
+ export function compareMetadata(
171
+ current: CurrentMetadata,
172
+ proposed: MetadataProposal,
173
+ ): MetadataDiff {
174
+ const diff: {
175
+ description?: { current: string; proposed: string }
176
+ homepage?: { current: string; proposed: string }
177
+ topics?: { added: readonly string[]; removed: readonly string[] }
178
+ } = {}
179
+
180
+ if (
181
+ proposed.description !== undefined &&
182
+ proposed.description !== current.description
183
+ ) {
184
+ diff.description = {
185
+ current: current.description,
186
+ proposed: proposed.description,
187
+ }
188
+ }
189
+
190
+ if (
191
+ proposed.homepage !== undefined &&
192
+ proposed.homepage !== current.homepage
193
+ ) {
194
+ diff.homepage = { current: current.homepage, proposed: proposed.homepage }
195
+ }
196
+
197
+ if (proposed.topics !== undefined) {
198
+ const currentSet = new Set(current.topics)
199
+ const proposedSet = new Set(proposed.topics)
200
+ const added = proposed.topics.filter((topic) => !currentSet.has(topic))
201
+ const removed = current.topics.filter((topic) => !proposedSet.has(topic))
202
+ if (added.length > 0 || removed.length > 0) diff.topics = { added, removed }
203
+ }
204
+
205
+ return diff
206
+ }