rvlib-shipkit 1.2.0 → 1.4.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 (173) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/asks/asks-queue.ts +4 -2
  5. package/src/assets/kits/identity-kit.ts +15 -13
  6. package/src/assets/run-assets.ts +15 -10
  7. package/src/check.ts +26 -10
  8. package/src/checks/SK001-package-json-shape.check.ts +34 -5
  9. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  10. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  11. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  12. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  13. package/src/checks/SK007-github-visibility.check.ts +9 -9
  14. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  15. package/src/checks/SK009-commit-ai-tells.check.ts +103 -61
  16. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  17. package/src/checks/SK014-prose-ste.check.ts +16 -6
  18. package/src/checks/SK015-nested-repo.check.ts +5 -1
  19. package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
  20. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  21. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -2
  22. package/src/checks/SK024-identity-colors.check.ts +5 -0
  23. package/src/checks/SK025-release-drift.check.ts +58 -0
  24. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  25. package/src/checks/SK027-agent-wiki-links.check.ts +50 -0
  26. package/src/checks/SK028-readme.check.ts +83 -0
  27. package/src/cli/cmd_Assets.ts +1 -1
  28. package/src/cli/cmd_Ci.ts +5 -0
  29. package/src/cli/cmd_CrossRepoActivity.ts +119 -0
  30. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  31. package/src/cli/cmd_Keys.ts +17 -3
  32. package/src/cli/cmd_Overview.ts +98 -0
  33. package/src/cli/cmd_Planned.ts +117 -0
  34. package/src/cli.ts +9 -1
  35. package/src/collections/checks.ts +3 -2
  36. package/src/collections/collection-types.ts +15 -0
  37. package/src/collections/providers.ts +7 -3
  38. package/src/collections/test-time.ts +10 -1
  39. package/src/contracts/apply-contracts.ts +9 -8
  40. package/src/contracts/contract-types.ts +8 -2
  41. package/src/contracts/contracts-engine.ts +34 -13
  42. package/src/contracts/manifests/identity.contract.ts +6 -5
  43. package/src/contracts/manifests/social.contract.ts +4 -2
  44. package/src/contracts/manifests/steam.contract.ts +7 -3
  45. package/src/contracts/web-surface.ts +59 -0
  46. package/src/core/agent-spawn.ts +46 -0
  47. package/src/core/archived.ts +82 -0
  48. package/src/core/budget.ts +19 -9
  49. package/src/core/canonical-scripts.ts +12 -3
  50. package/src/core/check-runner.ts +66 -17
  51. package/src/core/discover-checks.ts +10 -5
  52. package/src/core/file-stamp.ts +20 -0
  53. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  54. package/src/core/identity-color.ts +1 -1
  55. package/src/core/layout.ts +2 -0
  56. package/src/core/motherhome.ts +88 -66
  57. package/src/core/repo-facts.ts +14 -0
  58. package/src/core/retention.ts +14 -5
  59. package/src/core/retired-checks.ts +7 -0
  60. package/src/core/run-check.ts +8 -7
  61. package/src/core/run-ci.ts +46 -15
  62. package/src/core/run-fix.ts +7 -2
  63. package/src/core/run-fixers.ts +10 -6
  64. package/src/core/wire.ts +49 -9
  65. package/src/fleet/scan-fleet.ts +2 -1
  66. package/src/home/activity-shape.ts +138 -0
  67. package/src/home/activity.ts +256 -0
  68. package/src/home/global-units.ts +3 -2
  69. package/src/home/gui-app/ShipkitGui.swift +144 -25
  70. package/src/home/gui-app.ts +93 -30
  71. package/src/home/home-launchd.ts +10 -2
  72. package/src/home/home-routes.ts +34 -6
  73. package/src/home/home-start.ts +33 -5
  74. package/src/home/machine-stores.ts +4 -2
  75. package/src/home/open-app.ts +23 -10
  76. package/src/home/pm-config.ts +15 -11
  77. package/src/home/pm-inputs.ts +76 -0
  78. package/src/home/pm.ts +40 -7
  79. package/src/home/process-rows.ts +222 -0
  80. package/src/home/resources.ts +99 -20
  81. package/src/home/run-daemon.ts +12 -7
  82. package/src/home/run-pm.ts +30 -5
  83. package/src/home/serve-web.ts +6 -3
  84. package/src/home/supervise.ts +16 -1
  85. package/src/home/widget-text.ts +119 -0
  86. package/src/home/widgets.ts +32 -3
  87. package/src/hooks/commit-message.ts +2 -3
  88. package/src/hooks/run-hooks.ts +32 -5
  89. package/src/ledger/db.ts +1 -1
  90. package/src/ledger/gate-checks.ts +20 -6
  91. package/src/ledger/house-runs.ts +43 -0
  92. package/src/ledger/live-timeline.ts +4 -19
  93. package/src/ledger/run-log.ts +39 -10
  94. package/src/ledger/store.ts +30 -2
  95. package/src/ledger/world-cache.ts +63 -14
  96. package/src/plan/cadence.ts +21 -5
  97. package/src/plan/catch-up.ts +37 -0
  98. package/src/plan/plan-timeline.ts +99 -0
  99. package/src/plan/planner.ts +138 -13
  100. package/src/plan/repo-source.ts +120 -8
  101. package/src/release/run-release.ts +9 -2
  102. package/src/release/unit-resolve.ts +8 -12
  103. package/src/repo-config.ts +17 -4
  104. package/src/serve/house-runs.ts +53 -0
  105. package/src/serve/hub-app/activity-st.ts +168 -0
  106. package/src/serve/hub-app/activity-view.tsx +218 -0
  107. package/src/serve/hub-app/activity.css +78 -0
  108. package/src/serve/hub-app/admin-svc.ts +41 -0
  109. package/src/serve/hub-app/admin-view.tsx +42 -0
  110. package/src/serve/hub-app/chart-view.ts +12 -5
  111. package/src/serve/hub-app/check-words.ts +2 -0
  112. package/src/serve/hub-app/checks-svc.ts +2 -1
  113. package/src/serve/hub-app/col-tables-view.tsx +124 -0
  114. package/src/serve/hub-app/col-tables.ts +115 -0
  115. package/src/serve/hub-app/cols-svc.ts +28 -0
  116. package/src/serve/hub-app/controls.css +32 -0
  117. package/src/serve/hub-app/cycle-button.tsx +36 -0
  118. package/src/serve/hub-app/dock-anim.ts +25 -0
  119. package/src/serve/hub-app/dock-st.ts +329 -0
  120. package/src/serve/hub-app/dock-view.tsx +68 -0
  121. package/src/serve/hub-app/dock.css +35 -0
  122. package/src/serve/hub-app/hash-route.ts +7 -1
  123. package/src/serve/hub-app/header-bar.tsx +79 -104
  124. package/src/serve/hub-app/home-st.ts +50 -13
  125. package/src/serve/hub-app/home-strip.css +81 -23
  126. package/src/serve/hub-app/home-strip.tsx +243 -102
  127. package/src/serve/hub-app/home.html +4 -0
  128. package/src/serve/hub-app/hub-app.tsx +103 -161
  129. package/src/serve/hub-app/hub-types.ts +6 -3
  130. package/src/serve/hub-app/hub.css +101 -39
  131. package/src/serve/hub-app/icons.tsx +27 -3
  132. package/src/serve/hub-app/index.html +4 -0
  133. package/src/serve/hub-app/keys.ts +44 -9
  134. package/src/serve/hub-app/live-svc.ts +24 -64
  135. package/src/serve/hub-app/machine-st.ts +65 -13
  136. package/src/serve/hub-app/md.ts +38 -5
  137. package/src/serve/hub-app/next-run.ts +1 -1
  138. package/src/serve/hub-app/palette-st.ts +13 -9
  139. package/src/serve/hub-app/palette-view.tsx +47 -0
  140. package/src/serve/hub-app/pill-row.tsx +65 -0
  141. package/src/serve/hub-app/planned-st.ts +21 -85
  142. package/src/serve/hub-app/planned-view.tsx +12 -7
  143. package/src/serve/hub-app/planned.css +5 -2
  144. package/src/serve/hub-app/project-list.css +43 -21
  145. package/src/serve/hub-app/project-list.tsx +122 -23
  146. package/src/serve/hub-app/projects-svc.ts +120 -12
  147. package/src/serve/hub-app/readme-svc.ts +38 -0
  148. package/src/serve/hub-app/readme-view.tsx +19 -0
  149. package/src/serve/hub-app/readme.css +11 -0
  150. package/src/serve/hub-app/repo-sections.tsx +61 -0
  151. package/src/serve/hub-app/repo-st.ts +26 -7
  152. package/src/serve/hub-app/runs-tabs.ts +1 -1
  153. package/src/serve/hub-app/sidebar.tsx +84 -0
  154. package/src/serve/hub-app/tests-view.tsx +31 -4
  155. package/src/serve/hub-app/timeline-chart.tsx +31 -27
  156. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  157. package/src/serve/hub-app/timeline-svc.ts +2 -2
  158. package/src/serve/hub-app/timeline-view.tsx +29 -9
  159. package/src/serve/hub-runtime.ts +17 -9
  160. package/src/serve/other-repos.ts +8 -45
  161. package/src/serve/project-gutter-route.ts +100 -20
  162. package/src/serve/project-gutter.ts +34 -13
  163. package/src/serve/readme-route.ts +57 -0
  164. package/src/serve/serve-hub.ts +13 -8
  165. package/src/serve/tracked-watch.ts +33 -1
  166. package/src/serve/warm-cell.ts +49 -16
  167. package/src/shipkit-files.ts +20 -4
  168. package/src/suite/judge-suites.ts +42 -17
  169. package/src/suite/suite-cli.ts +5 -8
  170. package/src/wiki/lint-md.ts +2 -0
  171. package/src/wiki/scan-md.ts +37 -10
  172. package/src/checks/SK023-stores-under-cap.check.ts +0 -49
  173. package/src/checks/contracts.check.ts +0 -20
@@ -48,6 +48,7 @@ import { basename, join } from 'path'
48
48
  import type { CheckCtx } from '../core/check-ctx.ts'
49
49
  import { allowMajor, breakingAdvice, isBreaking } from '../core/update-policy.ts'
50
50
  import { isRunningCopy, rememberLatest } from '../core/self-update.ts'
51
+ import { rootPublishesNpm } from '../core/repo-facts.ts'
51
52
  import { mutateLease, withMutateLease } from '../core/locked-write.ts'
52
53
  import { detectPackageManager, isRefusal, LOCK_FILES } from '../core/package-manager.ts'
53
54
  import { check, type CheckEnv } from '../check.ts'
@@ -77,6 +78,11 @@ export const rvlibVersions = check({
77
78
  ttl: '24h',
78
79
  // it updates the rvlib deps unless the run is frozen
79
80
  writes: true,
81
+ planOff: (env) => {
82
+ let pkg: Pkg
83
+ try { pkg = JSON.parse(readFileSync(join(env.root, 'package.json'), 'utf8')) as Pkg } catch { return 'no readable package.json' }
84
+ return Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).some((n) => n.startsWith('rvlib-')) ? null : 'no rvlib-* dependency'
85
+ },
80
86
  cacheKey: (env) => {
81
87
  let pkg: Pkg
82
88
  try { pkg = JSON.parse(readFileSync(join(env.root, 'package.json'), 'utf8')) as Pkg } catch { return 'unreadable' }
@@ -84,7 +90,7 @@ export const rvlibVersions = check({
84
90
  .filter((n) => n.startsWith('rvlib-'))
85
91
  .sort()
86
92
  .map((n) => `${n}@${installedVersion(env.root, n) ?? 'absent'}`)
87
- .join(',')
93
+ .join(',') + (rootPublishesNpm(env.config) ? ' npm' : '')
88
94
  },
89
95
  run: async (c) => {
90
96
  let pkg: Pkg
@@ -137,6 +143,12 @@ export const rvlibVersions = check({
137
143
  c.warn(`newer rvlib live: ${drift} (frozen — apply with: ${cmd.join(' ')})`)
138
144
  return
139
145
  }
146
+ // a package published to npm: its package.json is its public
147
+ // contract, so the bump is a release decision, named and never made
148
+ if (rootPublishesNpm(c.config)) {
149
+ c.warn(`newer rvlib live: ${drift} (published to npm, never updated by a check — apply with: ${cmd.join(' ')})`)
150
+ return
151
+ }
140
152
  const dirty = await c.exec(['git', 'status', '--porcelain', '--', ...manifest], { cwd: c.root })
141
153
  if (dirty.code !== 0 || dirty.out.trim() !== '') {
142
154
  c.warn(`newer rvlib live: ${drift} — self-update deferred (${dirty.code !== 0 ? `git says: ${dirty.out.trim().split('\n')[0] ?? `exit ${dirty.code}`}` : 'package.json/lockfile dirty'}); commit first, then re-run check`)
@@ -15,6 +15,7 @@
15
15
  import { join, relative } from 'path'
16
16
  import { checkUnitReader, resolveUnits } from '../release/unit-resolve.ts'
17
17
  import { check } from '../check.ts'
18
+ import { rootPublishesNpm } from '../core/repo-facts.ts'
18
19
 
19
20
  type Pkg = {
20
21
  name?: string
@@ -65,6 +66,10 @@ export const npmPublishSafety = check({
65
66
  if (localHits.length === 0) c.ok('rvlib-* package — publishes by design (distribute/npm owns it)')
66
67
  return
67
68
  }
69
+ if (rootPublishesNpm(c.config)) {
70
+ if (localHits.length === 0) c.ok('the root unit declares release.npm: publishes by design, no local dependency protocol in any releasing unit')
71
+ return
72
+ }
68
73
  if (pkg.private !== true) return c.note('private:true missing — SK001 reports it; publish machinery not judged until the lock is on')
69
74
  const machinery: string[] = []
70
75
  if (pkg.publishConfig != null) machinery.push('publishConfig')
@@ -8,8 +8,9 @@
8
8
  // own base-repo resolution prefers `upstream`, which in a fork-shaped
9
9
  // checkout would confidently validate the WRONG repository. Flipping a
10
10
  // repo public stays a human act behind the pre-public audit; this rule is
11
- // the standing tripwire if it ever happens out of band. Clean passes cache
12
- // 7d, keyed on the declared value (a flip re-probes immediately); every
11
+ // the standing tripwire if it ever happens out of band. A decided fact: a
12
+ // clean pass is kept forever, keyed on the declared value (a flip re-probes
13
+ // immediately, and the plan asks only while no answer exists); every
13
14
  // unverifiable path (no git, no remote, non-github remote, gh missing or
14
15
  // erroring, unreadable answer) is a loud skip — an UNKNOWN, never red,
15
16
  // never cached.
@@ -17,20 +18,19 @@ import { carveGhJson, firstGhLine } from '../git-bootstrap.ts'
17
18
  import { probeGitRoot } from '../core/git-root.ts'
18
19
  import { nestedIn } from '../core/nesting.ts'
19
20
  import { check } from '../check.ts'
21
+ import { declaredVisibility } from '../core/repo-facts.ts'
20
22
 
21
23
  export const githubVisibility = check({
22
24
  id: 'SK007',
23
- // blocking despite being measured over the network: the fact is about THIS
24
- // REPO (it is public while it declares itself private), and every
25
- // unverifiable path above is a skip, so an offline machine never goes red
26
- blocking: true,
25
+ // a gate block does not un-leak a repo: the red shows on the needs surface
26
+ blocking: false,
27
27
  title: 'github visibility',
28
28
  description: 'the github visibility of this repo matches the one it declares',
29
29
  owner: 'meta/contracts.md',
30
- ttl: '7d',
31
- cacheKey: (env) => env.config.visibility ?? 'private',
30
+ ttl: 'forever',
31
+ cacheKey: (env) => declaredVisibility(env.config),
32
32
  run: async (c) => {
33
- const declared = c.config.visibility ?? 'private'
33
+ const declared = declaredVisibility(c.config)
34
34
  const skip = (why: string): void => void c.lines.push({ kind: 'skip', text: `github visibility unverified — ${why} (declared: ${declared})` })
35
35
  const firstLine = firstGhLine
36
36
  const repo = probeGitRoot(c.root)
@@ -8,12 +8,13 @@
8
8
  // harness then inherits every tool. The brief itself must exist. No
9
9
  // declaration = not a reviewer repo, and the rule passes silently.
10
10
  import { existsSync, mkdirSync, readFileSync } from 'fs'
11
- import { homedir } from 'os'
12
- import { dirname, isAbsolute, join } from 'path'
11
+ import { dirname, join } from 'path'
13
12
  import type { RepoConfig } from '../repo-config.ts'
14
13
  import { check, type FixResult, type CheckEnv } from '../check.ts'
15
14
  import { LAYOUT } from '../core/layout.ts'
16
15
  import { atomicWrite } from '../core/locked-write.ts'
16
+ import { configPath, fileStamp } from '../core/file-stamp.ts'
17
+ import { CONFIG_FILE } from '../core/check-pass.ts'
17
18
 
18
19
  /** where a review lands when its caller named no output file:
19
20
  * `YYYYMMDD-HHMMSS-<review-name>.md`, local time, gitignored */
@@ -48,8 +49,7 @@ export function reviewerAgentIssues(text: string, reviewer: NonNullable<RepoConf
48
49
  /** the brief must EXIST: a reviewer whose mandated first action is reading a
49
50
  * missing file flounders silently, which is the one thing this rule prevents */
50
51
  export function briefMissing(root: string, brief: string): boolean {
51
- const p = brief.startsWith('~/') ? join(homedir(), brief.slice(2)) : isAbsolute(brief) ? brief : join(root, brief)
52
- return !existsSync(p)
52
+ return !existsSync(configPath(root, brief))
53
53
  }
54
54
 
55
55
  function canonicalAgent(reviewer: NonNullable<RepoConfig['reviewer']>): string {
@@ -83,12 +83,18 @@ That brief overrides any habit of reviewing a diff for correctness alone.
83
83
  export const reviewerAgent = check({
84
84
  id: 'SK008',
85
85
  blocking: true,
86
- title: 'reviewer agent',
86
+ title: 'reviewer definition',
87
87
  description: 'the declared reviewer agent has its brief, its definition and read only tools',
88
88
  owner: 'identity/how-we-work.md',
89
- // the brief is a CONFIG path, and a `~/...` or absolute one puts it
90
- // outside the repo, where no tree id can see whether it still exists
91
- never: 'the reviewer brief may live outside the repo, where no tree id reaches it',
89
+ // the answer is the config, the definition file and the brief. The brief
90
+ // is a CONFIG path that may sit outside the repo, so both files enter the
91
+ // key by content, wherever they live
92
+ inputs: [CONFIG_FILE],
93
+ cacheKey: (env) => {
94
+ const r = env.config.reviewer
95
+ if (r == null) return 'no reviewer'
96
+ return `${r.name}|${(r.tools ?? REVIEWER_TOOLS).join(',')}|def=${fileStamp(join(env.root, reviewerAgentPath(r.name)))}|${r.brief}=${fileStamp(configPath(env.root, r.brief))}`
97
+ },
92
98
  run: (c) => {
93
99
  const r = c.config.reviewer
94
100
  if (r == null) return c.ok('no reviewer agent declared (repo.config reviewer:) — skipped')
@@ -1,18 +1,22 @@
1
- // SK009 — commit AI tells (identity/how-we-commit.md): a repo that will be
2
- // seen must not read as AI-driven. Prevention lives at the source (the
3
- // attribution setting + the standing from-commit-one ban, which binds every
4
- // repo because history follows a repo public and is never rewritten); this
5
- // rule is the declared-public tripwire: the FULL git log scanned for AI
6
- // attribution and dash punctuation, plus the tracked settings file that
7
- // silences attribution. Declared-private repos idle with an ok that RESTATES
8
- // the ban — a green line must never teach the wrong rule. The remedy for
9
- // pushed history is the owner's decision, never an automatic rewrite; only
10
- // an unpushed tip commit may be amended, and the fails say so.
1
+ // SK009 commit AI tells (identity/how-we-commit.md): the commit-msg hook is
2
+ // what refuses AI attribution, in every repo, so this check verifies the hook
3
+ // is ARMED: shipkit's own hooksPath, or a foreign one whose commit-msg chains
4
+ // the shipkit gate. It never scans history: what is pushed is accepted as it
5
+ // is, and only a new commit can still be stopped. A declared-public repo also
6
+ // needs the tracked attribution silencer, and its ref NAMES are checked (a
7
+ // FABLE-checkpoint tag outs the agent like a trailer); a branch nobody pushed
8
+ // is named "local only" and warns.
9
+ import { createHash } from 'crypto'
11
10
  import { readFileSync } from 'fs'
12
- import { join } from 'path'
13
- import { hasAiAttribution, hasDashPunctuation } from '../hooks/commit-message.ts'
11
+ import { isAbsolute, join } from 'path'
14
12
  import { nestedIn } from '../core/nesting.ts'
15
- import { check } from '../check.ts'
13
+ import { gitBin } from '../core/git-bin.ts'
14
+ import { fileStamp } from '../core/file-stamp.ts'
15
+ import { CONFIG_FILE } from '../core/check-pass.ts'
16
+ import { LAYOUT } from '../core/layout.ts'
17
+ import { check, type CheckEnv } from '../check.ts'
18
+ import { declaredVisibility } from '../core/repo-facts.ts'
19
+ import { hookBinInvocation } from '../hooks/run-hooks.ts'
16
20
 
17
21
  // the documented silencer: .claude/settings.json attribution.commit/.pr both
18
22
  // empty — the harness then never writes the trailer or the PR footer
@@ -25,62 +29,100 @@ const attributionSilenced = (root: string): boolean => {
25
29
  }
26
30
  }
27
31
 
32
+ const readOrNull = (abs: string): string | null => {
33
+ try {
34
+ return readFileSync(abs, 'utf8')
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ /** a commit-msg body that runs the shipkit gate on the message: a live line
41
+ * (never a comment) whose failure is not swallowed by `|| true` */
42
+ const chainsShipkit = (body: string): boolean =>
43
+ body.split('\n').some((l) => !l.trim().startsWith('#') && /\bhooks\s+commit-msg\b/.test(l) && !/\|\|\s*(?:true|:)\b/.test(l))
44
+
45
+ /** the chain line to paste into a foreign hook: a clone with no install skips
46
+ * the gate with a note, rather than failing every commit */
47
+ export const guardedChain = (bin: string): string => {
48
+ const cli = bin.replace(/^bun\s+/, '')
49
+ return `if [ -f ${cli} ]; then ${bin} hooks commit-msg "$1" || exit 1; else echo "commit-msg: rvlib-shipkit not installed (run bun install), attribution gate skipped" >&2; fi`
50
+ }
51
+
52
+ // ref NAMES tell too. bare match only for names with no other meaning;
53
+ // vendor and codename words (cursor, openai, gemini, opus, fable, …) name
54
+ // legitimate branches and features, they only match in a checkpoint/session shape
55
+ const REF_TELL = /\b(?:claude|chatgpt|copilot|aider|devin)\b|(?:^|[-_/])(?:fable|sonnet|haiku|opus|gemini|codex)[-_](?:checkpoint|session|ckpt)/i
56
+
57
+ const gitOut = (root: string, args: string[]): { code: number; out: string } => {
58
+ const p = Bun.spawnSync([gitBin(), ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
59
+ return { code: p.exitCode, out: p.stdout.toString() }
60
+ }
61
+
62
+ /** what the answer depends on: the declared visibility, the hooksPath and the
63
+ * commit-msg file it points at, and for a public repo every ref NAME and the
64
+ * silencer. A commit moves none of them */
65
+ export function historyKey(env: CheckEnv): string {
66
+ const nested = nestedIn(env.root)
67
+ if (nested != null) return `nested in ${nested}`
68
+ const declared = declaredVisibility(env.config)
69
+ const hooksPath = gitOut(env.root, ['config', 'core.hooksPath'])
70
+ const dir = hooksPath.code === 0 ? hooksPath.out.trim() : ''
71
+ const hook = `hooks=${dir}|msg=${dir === '' ? 'none' : fileStamp(isAbsolute(dir) ? join(dir, 'commit-msg') : join(env.root, dir, 'commit-msg'))}`
72
+ if (declared !== 'public') return `declared ${declared}|${hook}`
73
+ const refs = gitOut(env.root, ['for-each-ref', '--format=%(refname)', 'refs/tags', 'refs/heads', 'refs/remotes']).out
74
+ const digest = createHash('sha256').update(refs).digest('hex').slice(0, 16)
75
+ return `public|${hook}|refs=${digest}|settings=${fileStamp(join(env.root, '.claude', 'settings.json'))}`
76
+ }
77
+
28
78
  export const commitAiTells = check({
29
79
  id: 'SK009',
30
80
  blocking: true,
31
81
  title: 'commit ai tells',
32
- description: 'the history of a public repo carries no AI attribution and no dash punctuation',
82
+ description: 'the commit-msg hook that refuses AI attribution is armed, and no ref name tells',
33
83
  owner: 'identity/how-we-commit.md',
34
- // it scans the full `git log` and every ref name. History and refs are not
35
- // work-tree content, so nothing a scan can produce describes this answer
36
- never: 'reads the full commit history and every ref name, neither of which is work-tree content',
84
+ // git config and ref names, which no tree id describes, so they are the key
85
+ inputs: [CONFIG_FILE],
86
+ cacheKey: historyKey,
37
87
  run: async (c) => {
38
88
  const nested = nestedIn(c.root)
39
- if (nested != null)
40
- return void c.lines.push({ kind: 'skip', text: `commit history not scanned, nested in ${nested}: that history belongs to the parent repo` })
41
- const declared = c.config.visibility ?? 'private'
42
- if (declared !== 'public')
43
- return c.ok(`declared ${declared} — the no-AI-attribution ban still applies from commit one (identity/how-we-commit.md); the full-history scan arms at visibility: 'public'`)
44
- // %x1f separates hash from body, %x1e terminates the record — commit
45
- // bodies are free text, so only unprintable separators are safe
46
- const log = await c.exec(['git', 'log', '--format=%h%x1f%B%x1e'], { cwd: c.root, timeoutMs: 30_000 })
47
- if (log.code !== 0) {
48
- const first = log.out.trim().split('\n').find((l) => l.trim() !== '')?.slice(0, 120) ?? `exit ${log.code}`
49
- return void c.lines.push({ kind: 'skip', text: `commit history unverifiable — git says: ${first}` })
89
+ if (nested != null) return void c.lines.push({ kind: 'skip', text: `commit gates not checked, nested in ${nested}: the parent repo owns them` })
90
+ const top = await c.exec(['git', 'rev-parse', '--show-toplevel'], { cwd: c.root })
91
+ if (top.code !== 0) return void c.lines.push({ kind: 'skip', text: 'not a git repo: no commit to guard yet' })
92
+
93
+ const hp = await c.exec(['git', 'config', 'core.hooksPath'], { cwd: c.root })
94
+ const dir = hp.code === 0 ? hp.out.trim() : ''
95
+ const chain = guardedChain(hookBinInvocation(c.root))
96
+ if (dir === '' && process.env['CI'] != null && process.env['CI'] !== '') {
97
+ c.lines.push({ kind: 'skip', text: 'commit-msg hook not armed in this CI checkout: a CI run makes no commit, and the fix pass arms hooks on a machine only' })
98
+ } else if (dir === '') {
99
+ c.fail('the commit-msg hook is not armed, so nothing refuses an AI attribution trailer: run `shipkit hooks install` (`shipkit ci` arms it too)')
100
+ } else {
101
+ const body = isAbsolute(dir) ? readOrNull(join(dir, 'commit-msg')) : c.read(`${dir}/commit-msg`)
102
+ const own = dir === LAYOUT.hooks
103
+ if (body == null)
104
+ c.fail(`core.hooksPath=${dir} has no ${dir}/commit-msg, so nothing refuses an AI attribution trailer: ${own ? 'run `shipkit hooks install`' : `add one that runs \`${chain}\``}`)
105
+ else if (!chainsShipkit(body))
106
+ c.fail(`core.hooksPath=${dir}: its commit-msg (${body.split('\n').find((l) => l.trim() !== '' && !l.startsWith('#'))?.trim().slice(0, 80) ?? 'empty'}) does not run the shipkit gate, so nothing refuses AI attribution: add \`${chain}\` above its last line`)
107
+ else c.ok(`the commit-msg hook refuses AI attribution (${own ? dir : `${dir}, chained to the shipkit gate`})`)
50
108
  }
51
- const commits = log.out
52
- .split('\x1e')
53
- .map((r) => r.trim())
54
- .filter((r) => r !== '')
55
- .map((r) => {
56
- const cut = r.indexOf('\x1f')
57
- return { hash: r.slice(0, cut), body: r.slice(cut + 1) }
58
- })
59
- if (commits.length === 0) return void c.lines.push({ kind: 'skip', text: 'commit history unverifiable — no commits yet' })
60
- const remedy = 'an unpushed TIP commit may be amended; anything deeper is the owner\'s decision (history is never rewritten as a side effect)'
61
- const sample = (hits: { hash: string }[]): string => hits.slice(0, 5).map((h) => h.hash).join(' ') + (hits.length > 5 ? ' …' : '')
62
- const attributed = commits.filter((m) => hasAiAttribution(m.body))
63
- const dashed = commits.filter((m) => hasDashPunctuation(m.body))
64
- // ref NAMES tell too (a FABLE-checkpoint-3 tag outs the agent as
65
- // loudly as a trailer). bare match only for names with no other
66
- // meaning; vendor and codename words (cursor, openai, gemini, opus,
67
- // fable, …) name legitimate branches and features, they only match in
68
- // a checkpoint/session shape
69
- const refTell = /\b(?:claude|chatgpt|copilot|aider|devin)\b|(?:^|[-_/])(?:fable|sonnet|haiku|opus|gemini|codex)[-_](?:checkpoint|session|ckpt)/i
70
- const refs = await c.exec(['git', 'for-each-ref', '--format=%(refname:short)', 'refs/tags', 'refs/heads', 'refs/remotes'], { cwd: c.root, timeoutMs: 30_000 })
71
- if (refs.code !== 0) c.lines.push({ kind: 'skip', text: `ref names unverifiable — git for-each-ref exit ${refs.code}` })
72
- const tellRefs = refs.code === 0 ? refs.out.split('\n').map((r) => r.trim()).filter((r) => r !== '' && refTell.test(r)) : []
73
- if (tellRefs.length > 0)
74
- c.fail(`${tellRefs.length} ref name(s) carry an AI tell in a declared-public repo (${tellRefs.slice(0, 5).join(' ')}${tellRefs.length > 5 ? ' …' : ''}) — rename or delete the refs before the flip; a pushed tag also needs the remote delete`)
75
- if (attributed.length > 0)
76
- c.fail(`${attributed.length}/${commits.length} commits carry AI attribution (a trailer naming an assistant / a session trailer or claude.ai url / "Generated with" / 🤖) in a declared-public repo — e.g. ${sample(attributed)}; ${remedy}`)
77
- if (dashed.length > 0)
78
- c.fail(`${dashed.length}/${commits.length} commit messages carry dash punctuation (em/en dash or inline " - ") in a declared-public repo — e.g. ${sample(dashed)}; ${remedy} (identity/writing-voice.md owns the punctuation rule)`)
79
- const silenced = attributionSilenced(c.root)
80
- if (!silenced)
81
- c.fail(`.claude/settings.json does not silence AI attribution — track it with {"attribution": {"commit": "", "pr": ""}} so no agent's harness ever writes the trailer or the PR footer`)
82
- if (attributed.length === 0 && dashed.length === 0 && tellRefs.length === 0 && silenced)
83
- c.ok(`${commits.length} commits + ${refs.code === 0 ? refs.out.split('\n').filter((r) => r.trim() !== '').length : 0} ref names scanned, attribution silenced in .claude/settings.json — no AI tells`)
109
+
110
+ if (declaredVisibility(c.config) !== 'public') return
111
+ if (!attributionSilenced(c.root))
112
+ c.fail(`.claude/settings.json does not silence AI attribution: track it with {"attribution": {"commit": "", "pr": ""}} so no agent's harness ever writes the trailer or the PR footer`)
113
+ const refs = await c.exec(['git', 'for-each-ref', '--format=%(refname)', 'refs/tags', 'refs/heads', 'refs/remotes'], { cwd: c.root, timeoutMs: 30_000 })
114
+ if (refs.code !== 0) return void c.lines.push({ kind: 'skip', text: `ref names unverifiable: git for-each-ref exit ${refs.code}` })
115
+ const all = refs.out.split('\n').map((r) => r.trim()).filter((r) => r !== '')
116
+ const remote = new Set(all.filter((r) => r.startsWith('refs/remotes/')).map((r) => r.split('/').slice(3).join('/')))
117
+ const tells = all.filter((r) => REF_TELL.test(r.replace(/^refs\/(?:heads|tags|remotes)\//, '')))
118
+ const shown = (r: string): string => r.replace(/^refs\/(?:heads|tags|remotes)\//, '')
119
+ const local = tells.filter((r) => r.startsWith('refs/heads/') && !remote.has(shown(r)))
120
+ const out = tells.filter((r) => !local.includes(r) && !(r.startsWith('refs/heads/') && remote.has(shown(r))))
121
+ const list = (rs: string[], suffix = ''): string => rs.slice(0, 5).map((r) => `${shown(r)}${suffix}`).join(' ') + (rs.length > 5 ? ' …' : '')
122
+ if (out.length > 0)
123
+ c.fail(`${out.length} ref name(s) carry an AI tell in a declared-public repo (${list(out)}): rename or delete them, a pushed one on the remote too, then \`git fetch --prune\` so a remote ref deleted there leaves this clone`)
124
+ if (local.length > 0)
125
+ c.warn(`${local.length} branch name(s) carry an AI tell (${list(local, ' (local only)')}): never push them under this name, rename or delete them`)
84
126
  },
85
127
  })
86
128
 
@@ -15,6 +15,7 @@
15
15
  import { isAbsolute, join } from 'path'
16
16
  import { inferBin } from '../core/canonical-scripts.ts'
17
17
  import { denyRules, RAW_RUNNERS } from '../core/one-way-in-policy.ts'
18
+ import { rootPublishesNpm } from '../core/repo-facts.ts'
18
19
  import type { RepoConfig } from '../repo-config.ts'
19
20
  import { check, type CheckCtx, type FixResult } from '../check.ts'
20
21
 
@@ -95,8 +96,8 @@ async function guardScriptMissing(d: Disk, command: string): Promise<string | nu
95
96
  return null // a form this rule cannot resolve is not a form it may condemn
96
97
  }
97
98
 
98
- /** the guard the fixer writes: the cli path the scripts run when a guard sits
99
- * beside it (a repo's own `src/cli.ts` matches the same shape), else the
99
+ /** the guard the fixer writes: the shipkit cli path the scripts run when a
100
+ * guard sits beside it, else the
100
101
  * repo's own dependency, else the copy of shipkit doing the fixing, which is
101
102
  * the one the launcher falls back to in a repo that has none */
102
103
  export async function guardCommandFor(d: Disk): Promise<string> {
@@ -109,6 +110,11 @@ export async function guardCommandFor(d: Disk): Promise<string> {
109
110
  return guardCommand(join(import.meta.dir, '..', 'cli.ts'))
110
111
  }
111
112
 
113
+ /** a package published to npm is worked on by strangers: this file binds
114
+ * their agents, and a deny list with a hook would tie them to a toolkit they
115
+ * never installed */
116
+ const PUBLIC_SKIP = `the root unit publishes to npm: ${SETTINGS_PATH} belongs to every contributor, so it is never written`
117
+
112
118
  export const oneWayInPolicy = check({
113
119
  id: 'SK013',
114
120
  blocking: true,
@@ -119,6 +125,7 @@ export const oneWayInPolicy = check({
119
125
  // the declared guard script resolves. A guard at an absolute path lives
120
126
  // outside the repo, so an answer that probed one is never stored
121
127
  run: async (c) => {
128
+ if (rootPublishesNpm(c.config)) return c.ok(PUBLIC_SKIP)
122
129
  const d = c
123
130
  const want = denyRules(exemptSlots(c.config))
124
131
  if (want.length === 0) return c.ok('every governed slot is exempt (repo.config oneWayIn/defaults) — nothing to deny')
@@ -165,6 +172,7 @@ export const oneWayInPolicy = check({
165
172
  // a repo's own allow list and its own hooks, and clobbering those to fix a
166
173
  // deny list would cost more than the finding it repairs
167
174
  fix: async (c): Promise<FixResult> => {
175
+ if (rootPublishesNpm(c.config)) return { changed: false, summary: PUBLIC_SKIP }
168
176
  const d = c
169
177
  const want = denyRules(exemptSlots(c.config))
170
178
  if (want.length === 0) return { changed: false, summary: 'every governed slot is exempt — nothing to write' }
@@ -7,16 +7,17 @@
7
7
  //
8
8
  // The tier split is the whole design. `fix` and `hard` hits BLOCK: they are
9
9
  // mechanical, and a machine reporting them is never guessing. `soft` hits
10
- // (sentence length, passive voice) need a human read, so they WARN, and only
11
- // a declared `baseline` makes them blocking, as a ratchet that can only go
12
- // down.
10
+ // (sentence length, passive voice, modals, semicolons, …) need a human read,
11
+ // so they are a note, never an attention item, and only a declared
12
+ // `baseline` makes them blocking, as a ratchet that can only go down.
13
13
  //
14
14
  // It carries NO `fix()` on purpose. `shipkit ci` runs `--fix-rules` by
15
15
  // default, so a rule with a fixer rewrites tracked files unattended, and
16
16
  // prose is not repo shape: a wrong package.json line is a config, a wrong
17
17
  // sentence is content. The rewrite lives behind an explicit `shipkit prose
18
18
  // --fix` that a human ran and can read the diff of.
19
- import { classSummary, proseLines, runProseCheck } from '../prose/run-prose.ts'
19
+ import { classSummary, proseLines, runProseCheck, type ProseReport } from '../prose/run-prose.ts'
20
+ import { tierOf, type ProseClass } from '../prose/ste.ts'
20
21
  import { check } from '../check.ts'
21
22
 
22
23
  const OWNER = 'identity/writing-voice.md'
@@ -53,12 +54,21 @@ export const proseSte = check({
53
54
  `${blocking} STE violation(s) across ${r.byFile.length}/${r.files} file(s) — ${classSummary(r)}`,
54
55
  r.fixable > 0 ? `shipkit prose --fix rewrites ${r.fixable} of them` : undefined,
55
56
  )
57
+ const soft = softSummary(r)
56
58
  if (cfg.baseline != null && r.byTier.soft > cfg.baseline)
57
- c.fail(`${r.byTier.soft} soft hits (sentence length, passive voice), above the declared baseline of ${cfg.baseline} — the ratchet only goes down`)
59
+ c.fail(`${r.byTier.soft} soft hits (${soft}), above the declared baseline of ${cfg.baseline}: the ratchet only goes down`)
58
60
  else if (r.byTier.soft > 0)
59
- c.warn(`${r.byTier.soft} soft hit(s): sentence length and passive voice need a human read${cfg.baseline != null ? ` (baseline ${cfg.baseline})` : ''}`)
61
+ c.note(`${r.byTier.soft} soft hit(s) need a human read (${soft})${cfg.baseline != null ? `, within the baseline of ${cfg.baseline}` : ''}: \`shipkit prose\` lists them`)
60
62
  if (!c.failed && blocking === 0) c.ok(`${r.files} file(s) STE-clean on the mechanical rules`)
61
63
  }
62
64
  })
63
65
 
66
+ /** every soft class with its count, most first */
67
+ const softSummary = (r: ProseReport): string =>
68
+ Object.entries(r.byClass)
69
+ .filter(([cls]) => tierOf(cls as ProseClass) === 'soft')
70
+ .sort((a, b) => b[1] - a[1])
71
+ .map(([cls, n]) => `${cls} ${n}`)
72
+ .join(', ')
73
+
64
74
  export default proseSte
@@ -3,6 +3,7 @@
3
3
  // sites that write history refuse on the disk fact either way (core/nesting.ts),
4
4
  // so this rule is about the declaration telling the truth to the next reader.
5
5
  import { nestedIn } from '../core/nesting.ts'
6
+ import { CONFIG_FILE } from '../core/check-pass.ts'
6
7
  import { check } from '../check.ts'
7
8
 
8
9
  export const nestedRepo = check({
@@ -11,7 +12,10 @@ export const nestedRepo = check({
11
12
  title: 'nested repo',
12
13
  description: 'a config inside a larger work tree declares `nested: true`, and only there',
13
14
  owner: 'build/shipkit/config-keys.md',
14
- never: 'reads where the git work tree root sits, which no content inside the repo describes',
15
+ // the declaration, and where the git work tree root sits, which no tree
16
+ // id describes, so its path is the rest of the key
17
+ inputs: [CONFIG_FILE],
18
+ cacheKey: (env) => `nested=${env.config.nested === true}|top=${nestedIn(env.root) ?? 'root'}`,
15
19
  run: (c) => {
16
20
  const top = nestedIn(c.root)
17
21
  const declared = c.config.nested === true
@@ -1,8 +1,9 @@
1
1
  // SK016 — the shapes the check file replaced, and the machine that
2
2
  // rewrites them (build/shipkit/writing-a-check.md owns the shape itself).
3
3
  //
4
- // The window: every shape below is READ through 0.40, named once per run, and
5
- // refused at the first release after `shipkit fleet` shows no repo below 0.40.
4
+ // The window: every shape below is still READ, named once per run, and
5
+ // refused at the first release after `shipkit fleet` shows no repo below 0.40
6
+ // and none still carrying a check script.
6
7
  // So a repo on an old declaration keeps working, learns what to write instead
7
8
  // on every gate, and is carried across by one command, save what only a
8
9
  // reader can port: a check script's body, a rule, a task whose body is code.
@@ -60,7 +61,7 @@ export const legacyCheckShapes = check({
60
61
  if (s.placeholders.length > 0)
61
62
  c.warn(
62
63
  `${s.placeholders.length} check file(s) still open with the TODO description the migration wrote: ${s.placeholders.join(', ')}`,
63
- 'replace each TODO with one line stating what must be true',
64
+ 'by hand: replace each TODO with one line stating what must be true',
64
65
  )
65
66
  if (s.count === 0) return
66
67
  // one WARN per shape, never a bare line: the gate promises every line
@@ -78,9 +79,14 @@ export const legacyCheckShapes = check({
78
79
  }
79
80
  if (s.rules.length > 0) c.warn(`${s.rules.length} \`rules: []\` entr(ies): ${s.rules.join(', ')} — each one is a *.check.ts file whose \`run\` is its \`lint\``)
80
81
  if (s.ruleExempt.length > 0) c.warn(`ruleExempt names ${s.ruleExempt.join(', ')} — the spelling is \`when: { id: 'off:<why>' }\``)
82
+ // what the fixer rewrites without guessing: the exemptions, the tasks
83
+ // once all of them are data, checkDefaults once no script leans on it
84
+ const mechanical = s.ruleExempt.length + (s.tasks.every((x) => x.data) ? s.tasks.length : 0) + (s.checkDefaults && s.checkFiles.length === 0 ? 1 : 0)
81
85
  c.warn(
82
- `${s.count} declaration(s) in a shape the check file replaced — read through 0.40, refused after`,
83
- 'shipkit check --fix SK016 rewrites what is mechanical and names the rest',
86
+ `${s.count} declaration(s) in a shape the check file replaced: still read, and refused by the first shipkit release after \`shipkit fleet\` shows no repo below 0.40 and none still carrying a check script`,
87
+ mechanical > 0
88
+ ? `shipkit check --fix SK016 rewrites ${mechanical} of them and names the rest`
89
+ : `nothing left is mechanical, so the port is by hand: each line above becomes a *.check.ts file (${OWNER})`,
84
90
  )
85
91
  },
86
92
  fix: async (c) => await fixLegacyShapes(c),
@@ -11,6 +11,7 @@ import { basename } from 'path'
11
11
  import { check } from '../check.ts'
12
12
  import type { CheckCtx } from '../core/check-ctx.ts'
13
13
  import { checkFloorReader, resolveFloor } from '../core/floor-resolve.ts'
14
+ import { rootPublishesNpm } from '../core/repo-facts.ts'
14
15
  import { whenOfTask } from '../core/when.ts'
15
16
  import { hubProblemMatcher } from '../problems/problem-line.ts'
16
17
  import { indentAt, parseSpans, plain, removalSpan, type Node } from '../scaffold/jsonc-spans.ts'
@@ -165,6 +166,10 @@ function ownTasks(c: CheckCtx): { text: string } | 'none' | 'linked' {
165
166
  return text == null ? 'none' : { text }
166
167
  }
167
168
 
169
+ /** a package published to npm is worked on by strangers, whose editor
170
+ * runs these tasks */
171
+ const PUBLIC_SKIP = `the root unit publishes to npm: ${TASKS} belongs to every contributor, so it is never rewritten`
172
+
168
173
  export const vscodeHubTask = check({
169
174
  id: 'SK017',
170
175
  // a stale editor file is behind, not broken: it warns, and the fix pass
@@ -174,6 +179,7 @@ export const vscodeHubTask = check({
174
179
  description: 'the vscode hub task carries the problem matcher and no extra tsc watcher runs',
175
180
  owner: 'build/vscode-tasks.md',
176
181
  run: (c) => {
182
+ if (rootPublishesNpm(c.config)) return c.ok(PUBLIC_SKIP)
177
183
  const own = ownTasks(c)
178
184
  if (own === 'none') return c.ok(`no ${TASKS}, so there is no hub task to wire`)
179
185
  if (own === 'linked') return c.ok(`.vscode/ is a link to a shared dir, left alone`)
@@ -185,6 +191,7 @@ export const vscodeHubTask = check({
185
191
  for (const ch of r.changes) c.fail(`${TASKS}: ${ch.replace(/^removed/, 'should remove').replace(/^dropped/, 'should drop').replace(/^wrote/, 'should write').replace(/^folded/, 'should fold')}`)
186
192
  },
187
193
  fix: (c) => {
194
+ if (rootPublishesNpm(c.config)) return { changed: false, summary: PUBLIC_SKIP }
188
195
  const own = ownTasks(c)
189
196
  if (typeof own === 'string') return { changed: false, summary: `no ${TASKS} to repair` }
190
197
  const r = repairTasks(own.text, { typecheckOn: typecheckOn(c) })
@@ -3,7 +3,9 @@
3
3
  // rvlib-shipkit pin is a minimum. It warns when the repo asks for a version
4
4
  // newer than the one this process runs: a rule or an api that pin relies on
5
5
  // is absent here until the house copy catches up.
6
+ import { join } from 'path'
6
7
  import { LIB_VERSION } from '../core/lib-version.ts'
8
+ import { fileStamp } from '../core/file-stamp.ts'
7
9
  import { check } from '../check.ts'
8
10
 
9
11
  type Pkg = { version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
@@ -23,8 +25,10 @@ export const machineRunsThePin = check({
23
25
  title: 'machine runs the pin',
24
26
  description: 'the shipkit this machine runs is at least the version this repo pins',
25
27
  owner: 'build/shipkit.md',
26
- // the running version is no file of the repo
27
- cacheKey: () => LIB_VERSION,
28
+ // the pin, the install (gitignored) and the version this machine runs,
29
+ // which is no file of the repo
30
+ inputs: ['package.json'],
31
+ cacheKey: (env) => `${LIB_VERSION}|installed=${fileStamp(join(env.root, 'node_modules', NAME, 'package.json'))}`,
28
32
  run: (c) => {
29
33
  const pkg = c.json<Pkg>('package.json')
30
34
  const spec = pkg?.devDependencies?.[NAME] ?? pkg?.dependencies?.[NAME]
@@ -3,7 +3,9 @@
3
3
  // surface that shows the repo (the home page header, its rail tile) draws it
4
4
  // in its own colour. The fix derives them from the repo icon, or from a hue
5
5
  // hashed from the name, and writes that one key, every other byte kept.
6
+ import { join } from 'path'
6
7
  import { check } from '../check.ts'
8
+ import { fileStamp } from '../core/file-stamp.ts'
7
9
  import { configName, CONFIG_FILE, deriveIdentity, identityRefusal, INSTALLED_CONFIG_SRC } from '../core/identity.ts'
8
10
  import { declaresIdentity, identityOfText, identityProblem, setIdentityText } from '../core/identity-color.ts'
9
11
 
@@ -16,6 +18,9 @@ export const identityColors = check({
16
18
  title: 'identity colors',
17
19
  description: 'repo.config.ts declares identity colours: colorLight and colorDark, two hex',
18
20
  owner: 'build/shipkit/built-in-checks.md',
21
+ // the config, and the installed lib that says whether it knows the key
22
+ inputs: [CONFIG_FILE],
23
+ cacheKey: (env) => `installed=${fileStamp(join(env.root, INSTALLED_CONFIG_SRC))}`,
19
24
  run: (c) => {
20
25
  const text = c.read(CONFIG_FILE)
21
26
  if (text == null) return c.note(`no ${CONFIG_FILE}`)