rvlib-shipkit 0.11.0 → 0.14.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.
- package/CHANGELOG.md +200 -0
- package/README.md +17 -3
- package/package.json +6 -3
- package/src/asks/asks-queue.ts +8 -0
- package/src/assets/kit-config.ts +2 -3
- package/src/assets/roll-workspace.ts +2 -1
- package/src/assets/run-assets.ts +0 -11
- package/src/assets/server/studio-server.ts +7 -24
- package/src/cli/cmd_Assets.ts +3 -14
- package/src/cli/cmd_Check.ts +22 -4
- package/src/cli/cmd_Reflect.ts +116 -0
- package/src/cli/cmd_Rules.ts +32 -0
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli.ts +5 -2
- package/src/collections/actions.ts +19 -0
- package/src/collections/collection-types.ts +13 -0
- package/src/collections/graphs.ts +34 -0
- package/src/collections/pings.ts +5 -1
- package/src/collections/posts.ts +20 -1
- package/src/collections/providers.ts +163 -50
- package/src/contracts/apply-contracts.ts +47 -0
- package/src/contracts/contract-types.ts +5 -2
- package/src/contracts/contracts-engine.ts +13 -3
- package/src/contracts/manifests/discord.contract.ts +1 -1
- package/src/contracts/manifests/identity.contract.ts +1 -1
- package/src/contracts/manifests/itch.contract.ts +19 -2
- package/src/contracts/run-contracts.ts +8 -3
- package/src/core/canonical-scripts.ts +46 -0
- package/src/core/check-suite.ts +6 -3
- package/src/core/locked-write.ts +39 -0
- package/src/core/motherhome.ts +87 -0
- package/src/core/run-check.ts +28 -15
- package/src/core/run-ci.ts +4 -0
- package/src/core/write-status.ts +7 -26
- package/src/init-repo.ts +27 -11
- package/src/reflect/prompt-corpus.ts +305 -0
- package/src/repo-config.ts +30 -38
- package/src/rules/SK001-package-json-shape.ts +156 -0
- package/src/rules/SK002-contracts-fulfillness.ts +19 -0
- package/src/rules/SK003-claude-md-shape.ts +64 -0
- package/src/rules/SK004-gitignore-policy.ts +105 -0
- package/src/rules/SK005-rvlib-versions.ts +99 -0
- package/src/rules/SK006-npm-publish-safety.ts +42 -0
- package/src/rules/SK007-github-visibility.ts +63 -0
- package/src/rules/SK008-reviewer-agent.ts +98 -0
- package/src/rules/rule-registry.ts +34 -0
- package/src/rules/rule.ts +62 -0
- package/src/rules/run-fix.ts +57 -0
- package/src/serve/hub-app/col-card.tsx +192 -0
- package/src/serve/hub-app/cols-svc.ts +136 -0
- package/src/serve/hub-app/geometry-view.tsx +155 -0
- package/src/serve/hub-app/health-svc.ts +49 -0
- package/src/serve/hub-app/hub-app.tsx +236 -215
- package/src/serve/hub-app/hub-st.ts +169 -0
- package/src/serve/hub-app/hub-types.ts +72 -0
- package/src/serve/hub-app/hub.css +286 -59
- package/src/serve/hub-app/index.html +1 -1
- package/src/serve/hub-app/md.ts +24 -0
- package/src/serve/hub-app/media-svc.ts +429 -0
- package/src/serve/hub-app/media-view.tsx +299 -0
- package/src/serve/run-serve.ts +23 -9
- package/src/serve/serve-hub.ts +62 -47
- package/src/shipkit.ts +9 -5
- package/src/assets/server/studio-page.ts +0 -687
- package/src/core/package-json-check.ts +0 -88
- package/src/serve/hub-pages.ts +0 -38
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// shipkit reflect — mine the user's TYPED prompts out of Claude Code transcripts.
|
|
2
|
+
// Transcripts live at <configRoot>/projects/<slug>/*.jsonl where <slug> is the
|
|
3
|
+
// repo's absolute path with EVERY non-alphanumeric char replaced by '-'.
|
|
4
|
+
//
|
|
5
|
+
// A corpus is only as good as what it excludes: the harness writes many
|
|
6
|
+
// user-role entries the human never typed (tool results, command wrappers,
|
|
7
|
+
// compaction summaries, subagent prompts). Every filter below exists because
|
|
8
|
+
// one of them polluted a real extraction pass.
|
|
9
|
+
import { existsSync, readFileSync, readdirSync } from 'fs'
|
|
10
|
+
import { homedir } from 'os'
|
|
11
|
+
import { basename, join, resolve } from 'path'
|
|
12
|
+
|
|
13
|
+
export type PromptEntry = {
|
|
14
|
+
uuid: string
|
|
15
|
+
timestamp: string
|
|
16
|
+
sessionId: string
|
|
17
|
+
/** config root the prompt came from, e.g. `.claude` */
|
|
18
|
+
account: string
|
|
19
|
+
text: string
|
|
20
|
+
/** 'queued' = typed WHILE the agent was working (a `queue-operation` entry).
|
|
21
|
+
* Same human, higher signal: corrections and standing conventions land there. */
|
|
22
|
+
origin: 'typed' | 'queued'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ConfigRoot = { account: string; projects: string }
|
|
26
|
+
|
|
27
|
+
const siblingConfigDirs = (home: string): string[] => {
|
|
28
|
+
if (!existsSync(home)) return []
|
|
29
|
+
return readdirSync(home)
|
|
30
|
+
.filter((d) => d.startsWith('.claude-'))
|
|
31
|
+
.map((d) => join(home, d))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** EVERY Claude Code config root on this machine, unioned: $CLAUDE_CONFIG_DIR
|
|
35
|
+
* (`:` or `,` separated) PLUS ~/.claude PLUS any sibling ~/.claude-<name>
|
|
36
|
+
* holding a projects/ dir. Additive on purpose — a second local account keeps
|
|
37
|
+
* its own transcripts, and the env var only names the account running RIGHT
|
|
38
|
+
* NOW, so honoring it alone hides every session of the other one (field bug
|
|
39
|
+
* 2026-07-24: a corpus mined that way lost a third of his prompts). */
|
|
40
|
+
export function discoverConfigRoots(home: string = homedir()): ConfigRoot[] {
|
|
41
|
+
const fromEnv = (process.env.CLAUDE_CONFIG_DIR ?? '')
|
|
42
|
+
.split(/[:,]/)
|
|
43
|
+
.map((p) => p.trim())
|
|
44
|
+
.filter((p) => p !== '')
|
|
45
|
+
const seen = new Set<string>()
|
|
46
|
+
const roots: ConfigRoot[] = []
|
|
47
|
+
for (const dir of [...fromEnv, join(home, '.claude'), ...siblingConfigDirs(home)]) {
|
|
48
|
+
const projects = join(resolve(dir), 'projects')
|
|
49
|
+
if (seen.has(projects) || !existsSync(projects)) continue
|
|
50
|
+
seen.add(projects)
|
|
51
|
+
roots.push({ account: basename(resolve(dir)), projects })
|
|
52
|
+
}
|
|
53
|
+
return roots
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Claude Code slugs EVERY non-alphanumeric char to '-', not just '/' */
|
|
57
|
+
export const projectSlug = (repoPath: string): string => resolve(repoPath).replace(/[^a-zA-Z0-9]/g, '-')
|
|
58
|
+
|
|
59
|
+
// wrappers the harness injects as user-role entries — none of them are typed
|
|
60
|
+
const NOISE_PREFIXES = [
|
|
61
|
+
'<local-command-caveat>',
|
|
62
|
+
'<local-command-stdout>',
|
|
63
|
+
'<command-name>',
|
|
64
|
+
'<command-message>',
|
|
65
|
+
'<system-reminder>',
|
|
66
|
+
'<task-notification>',
|
|
67
|
+
'Caveat: The messages below',
|
|
68
|
+
'[Request interrupted',
|
|
69
|
+
'This session is being continued from a previous conversation',
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
export function isTypedPrompt(text: string): boolean {
|
|
73
|
+
const trimmed = text.trim()
|
|
74
|
+
if (trimmed === '') return false
|
|
75
|
+
return !NOISE_PREFIXES.some((p) => trimmed.startsWith(p))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type RawEntry = {
|
|
79
|
+
type?: unknown
|
|
80
|
+
entrypoint?: unknown
|
|
81
|
+
uuid?: unknown
|
|
82
|
+
timestamp?: unknown
|
|
83
|
+
sessionId?: unknown
|
|
84
|
+
isMeta?: unknown
|
|
85
|
+
isSidechain?: unknown
|
|
86
|
+
isCompactSummary?: unknown
|
|
87
|
+
/** queue-operation entries only */
|
|
88
|
+
operation?: unknown
|
|
89
|
+
content?: unknown
|
|
90
|
+
message?: { role?: unknown; content?: unknown }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** His words out of one transcript line, or null when the line is not a prompt
|
|
94
|
+
* he typed. isSidechain = a subagent prompt the AGENT wrote; isMeta and
|
|
95
|
+
* isCompactSummary = harness-generated. Content is a string for a plain prompt,
|
|
96
|
+
* an array of blocks when the message carried pastes or attachments (the text
|
|
97
|
+
* blocks in there are still his). */
|
|
98
|
+
export function promptTextOf(line: string): { text: string; entry: RawEntry } | null {
|
|
99
|
+
let parsed: unknown
|
|
100
|
+
try {
|
|
101
|
+
parsed = JSON.parse(line)
|
|
102
|
+
} catch {
|
|
103
|
+
return null // truncated tail line of a live session
|
|
104
|
+
}
|
|
105
|
+
if (parsed == null || typeof parsed !== 'object') return null
|
|
106
|
+
const entry = parsed as RawEntry
|
|
107
|
+
if (entry.type !== 'user') return null
|
|
108
|
+
if (entry.isMeta === true || entry.isSidechain === true || entry.isCompactSummary === true) return null
|
|
109
|
+
if (entry.entrypoint === 'sdk-cli') return null // a script drove this session, nobody typed it
|
|
110
|
+
if (entry.message?.role !== 'user') return null
|
|
111
|
+
const content = entry.message.content
|
|
112
|
+
let text: string
|
|
113
|
+
if (typeof content === 'string') text = content
|
|
114
|
+
else if (Array.isArray(content)) {
|
|
115
|
+
const parts = content
|
|
116
|
+
.filter((b): b is { type: string; text: string } => b?.type === 'text' && typeof b?.text === 'string')
|
|
117
|
+
.map((b) => b.text)
|
|
118
|
+
if (parts.length === 0) return null // pure tool_result turn
|
|
119
|
+
text = parts.join('\n')
|
|
120
|
+
} else return null
|
|
121
|
+
// the harness appends reminder blocks INSIDE real prompts — strip, keep the text
|
|
122
|
+
text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
|
|
123
|
+
// a slash-command entry is a harness WRAPPER around typed text:
|
|
124
|
+
// <command-name>/cmd</command-name><command-args>THE TEXT</command-args>.
|
|
125
|
+
// Dropping the whole entry throws away real instructions and repro pastes.
|
|
126
|
+
const args = text.match(/<command-args>([\s\S]*?)<\/command-args>/)
|
|
127
|
+
if (args?.[1] != null) {
|
|
128
|
+
const body = args[1].trim()
|
|
129
|
+
if (body === '') return null // bare invocation, nothing typed
|
|
130
|
+
const name = text.match(/<command-name>([\s\S]*?)<\/command-name>/)
|
|
131
|
+
text = name?.[1] == null ? body : `${name[1].trim()} ${body}`
|
|
132
|
+
}
|
|
133
|
+
if (!isTypedPrompt(text)) return null
|
|
134
|
+
return { text, entry }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A prompt he typed while the agent was still working: the harness parks it as
|
|
138
|
+
* `{type:'queue-operation', operation:'enqueue', content}` and, if the run ends
|
|
139
|
+
* first, it may NEVER become a user message. Skipping these loses exactly the
|
|
140
|
+
* mid-flight corrections and standing conventions a retrospective is for
|
|
141
|
+
* (measured 2026-07-26: 72 such texts invisible in this repo alone). */
|
|
142
|
+
export function queuedTextOf(line: string): { text: string; entry: RawEntry } | null {
|
|
143
|
+
let parsed: unknown
|
|
144
|
+
try {
|
|
145
|
+
parsed = JSON.parse(line)
|
|
146
|
+
} catch {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
if (parsed == null || typeof parsed !== 'object') return null
|
|
150
|
+
const entry = parsed as RawEntry
|
|
151
|
+
if (entry.type !== 'queue-operation' || entry.operation !== 'enqueue') return null
|
|
152
|
+
if (typeof entry.content !== 'string') return null
|
|
153
|
+
const text = entry.content.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
|
|
154
|
+
if (!isTypedPrompt(text)) return null
|
|
155
|
+
return { text, entry }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function extractPrompts(jsonl: string, ctx: { sessionId: string; account: string }): PromptEntry[] {
|
|
159
|
+
const out: PromptEntry[] = []
|
|
160
|
+
for (const line of jsonl.split('\n')) {
|
|
161
|
+
if (line.trim() === '') continue
|
|
162
|
+
const queued = queuedTextOf(line)
|
|
163
|
+
const hit = queued ?? promptTextOf(line)
|
|
164
|
+
if (hit == null) continue
|
|
165
|
+
const timestamp = typeof hit.entry.timestamp === 'string' ? hit.entry.timestamp : ''
|
|
166
|
+
const fallbackId = `${queued == null ? 'typed' : 'queue'}:${ctx.sessionId}:${timestamp}`
|
|
167
|
+
out.push({
|
|
168
|
+
uuid: typeof hit.entry.uuid === 'string' ? hit.entry.uuid : fallbackId,
|
|
169
|
+
timestamp,
|
|
170
|
+
sessionId: typeof hit.entry.sessionId === 'string' ? hit.entry.sessionId : ctx.sessionId,
|
|
171
|
+
account: ctx.account,
|
|
172
|
+
text: hit.text,
|
|
173
|
+
origin: queued == null ? 'typed' : 'queued',
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
return out
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Genuine repetition is SIGNAL (saying it twice raises priority), so dedupe
|
|
180
|
+
* must remove only mechanical copies:
|
|
181
|
+
* - a resumed/forked session replaying an entry (same uuid, or same text at
|
|
182
|
+
* the same instant),
|
|
183
|
+
* - a queued prompt that later became a user message (same text, any gap —
|
|
184
|
+
* one keystroke, two records).
|
|
185
|
+
* Anything else repeated is kept, so the corpus never manufactures a priority
|
|
186
|
+
* he did not give. Input must be chronological. */
|
|
187
|
+
const REPEAT_WINDOW_MS = 5 * 60 * 1000
|
|
188
|
+
|
|
189
|
+
const normalize = (text: string): string => text.replace(/\s+/g, ' ').trim().toLowerCase()
|
|
190
|
+
|
|
191
|
+
export function dedupePrompts(prompts: PromptEntry[]): PromptEntry[] {
|
|
192
|
+
const seenUuid = new Set<string>()
|
|
193
|
+
const lastSeenAt = new Map<string, number>()
|
|
194
|
+
/** queued texts not yet matched by their user-message twin — consumption is
|
|
195
|
+
* ONE-time, so a genuine re-ask of the same words later still counts */
|
|
196
|
+
const awaitingConsume = new Set<string>()
|
|
197
|
+
const out: PromptEntry[] = []
|
|
198
|
+
for (const p of prompts) {
|
|
199
|
+
if (seenUuid.has(p.uuid)) continue
|
|
200
|
+
seenUuid.add(p.uuid)
|
|
201
|
+
const key = normalize(p.text)
|
|
202
|
+
const at = Number.isNaN(Date.parse(p.timestamp)) ? 0 : Date.parse(p.timestamp)
|
|
203
|
+
const prev = lastSeenAt.get(key)
|
|
204
|
+
if (prev != null) {
|
|
205
|
+
if (awaitingConsume.has(key) && p.origin === 'typed') {
|
|
206
|
+
awaitingConsume.delete(key) // the queued keystroke arriving as a message
|
|
207
|
+
lastSeenAt.set(key, at)
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
if (at - prev < REPEAT_WINDOW_MS) continue // replay, or a resend seconds apart
|
|
211
|
+
}
|
|
212
|
+
lastSeenAt.set(key, at)
|
|
213
|
+
if (p.origin === 'queued') awaitingConsume.add(key)
|
|
214
|
+
out.push(p)
|
|
215
|
+
}
|
|
216
|
+
return out
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Sessions whose transcript was pruned by retention survive only as a
|
|
220
|
+
* truncated firstPrompt in the per-project sessions-index.json. */
|
|
221
|
+
function indexFallbackPrompts(dir: string, account: string, have: Set<string>): PromptEntry[] {
|
|
222
|
+
const indexPath = join(dir, 'sessions-index.json')
|
|
223
|
+
if (!existsSync(indexPath)) return []
|
|
224
|
+
let index: { entries?: unknown }
|
|
225
|
+
try {
|
|
226
|
+
index = JSON.parse(readFileSync(indexPath, 'utf8')) as { entries?: unknown }
|
|
227
|
+
} catch {
|
|
228
|
+
return []
|
|
229
|
+
}
|
|
230
|
+
const entries = Array.isArray(index.entries) ? index.entries : []
|
|
231
|
+
const out: PromptEntry[] = []
|
|
232
|
+
for (const raw of entries) {
|
|
233
|
+
const e = raw as { sessionId?: string; firstPrompt?: string; created?: string; fullPath?: string }
|
|
234
|
+
if (typeof e.sessionId !== 'string' || have.has(e.sessionId)) continue
|
|
235
|
+
if (typeof e.fullPath === 'string' && existsSync(e.fullPath)) continue
|
|
236
|
+
if (typeof e.firstPrompt !== 'string' || !isTypedPrompt(e.firstPrompt)) continue
|
|
237
|
+
out.push({
|
|
238
|
+
uuid: `index:${e.sessionId}`,
|
|
239
|
+
timestamp: typeof e.created === 'string' ? e.created : '',
|
|
240
|
+
sessionId: e.sessionId,
|
|
241
|
+
account,
|
|
242
|
+
text: `${e.firstPrompt}\n\n[transcript pruned — first prompt only, truncated]`,
|
|
243
|
+
origin: 'typed',
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
return out
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export type RepoCorpus = {
|
|
250
|
+
/** project dirs actually scanned, across every config root */
|
|
251
|
+
dirs: string[]
|
|
252
|
+
/** config roots searched — printed when nothing was found */
|
|
253
|
+
roots: string[]
|
|
254
|
+
prompts: PromptEntry[]
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Every prompt he typed in a repo: all config roots, the repo's project dir
|
|
258
|
+
* PLUS its worktree dirs (`<slug>--claude-worktrees-*`), deduped, chronological. */
|
|
259
|
+
export function collectRepoPrompts(repoPath: string, roots: ConfigRoot[] = discoverConfigRoots()): RepoCorpus {
|
|
260
|
+
const slug = projectSlug(repoPath)
|
|
261
|
+
const dirs: string[] = []
|
|
262
|
+
const prompts: PromptEntry[] = []
|
|
263
|
+
for (const root of roots) {
|
|
264
|
+
const projectDirs = readdirSync(root.projects).filter(
|
|
265
|
+
(d) => d === slug || d.startsWith(`${slug}--claude-worktrees-`),
|
|
266
|
+
)
|
|
267
|
+
for (const projectDir of projectDirs) {
|
|
268
|
+
const dir = join(root.projects, projectDir)
|
|
269
|
+
dirs.push(dir)
|
|
270
|
+
const sessions = new Set<string>()
|
|
271
|
+
for (const file of readdirSync(dir)) {
|
|
272
|
+
if (!file.endsWith('.jsonl')) continue
|
|
273
|
+
const sessionId = file.replace(/\.jsonl$/, '')
|
|
274
|
+
sessions.add(sessionId)
|
|
275
|
+
prompts.push(
|
|
276
|
+
...extractPrompts(readFileSync(join(dir, file), 'utf8'), { sessionId, account: root.account }),
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
prompts.push(...indexFallbackPrompts(dir, root.account, sessions))
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
prompts.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
|
|
283
|
+
return { dirs, roots: roots.map((r) => r.projects), prompts: dedupePrompts(prompts) }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Fence a prompt so its own backticks and markdown cannot break the document */
|
|
287
|
+
function fenced(text: string): string {
|
|
288
|
+
const longest = Math.max(2, ...[...text.matchAll(/`+/g)].map((m) => m[0].length))
|
|
289
|
+
const fence = '`'.repeat(Math.max(3, longest + 1))
|
|
290
|
+
return `${fence}text\n${text}\n${fence}`
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Chronological corpus, one numbered section per prompt — meant to be read as
|
|
294
|
+
* a corpus, not as a task list. */
|
|
295
|
+
export function corpusMarkdown(repoPath: string, prompts: PromptEntry[]): string {
|
|
296
|
+
const queued = prompts.filter((p) => p.origin === 'queued').length
|
|
297
|
+
const count = queued === 0 ? `${prompts.length}` : `${prompts.length}, ${queued} queued mid-run`
|
|
298
|
+
const lines: string[] = [`# prompt corpus — ${resolve(repoPath)} (${count})`, '']
|
|
299
|
+
prompts.forEach((p, i) => {
|
|
300
|
+
const when = p.timestamp.replace('T', ' ').slice(0, 16)
|
|
301
|
+
const tag = p.origin === 'queued' ? ' · queued' : ''
|
|
302
|
+
lines.push(`## ${i + 1} · ${when} · ${p.account} · ${p.sessionId.slice(0, 8)}${tag}`, '', fenced(p.text), '')
|
|
303
|
+
})
|
|
304
|
+
return `${lines.join('\n')}\n`
|
|
305
|
+
}
|
package/src/repo-config.ts
CHANGED
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
import { existsSync, readFileSync } from 'fs'
|
|
12
12
|
import { join } from 'path'
|
|
13
13
|
import { fileURLToPath } from 'url'
|
|
14
|
-
import {
|
|
15
|
-
import { evaluateContracts
|
|
14
|
+
import { activeContracts, applyContractsResult, contractsConfigFrom } from './contracts/apply-contracts.ts'
|
|
15
|
+
import { evaluateContracts } from './contracts/contracts-engine.ts'
|
|
16
16
|
import type { ContractManifest } from './contracts/contract-types.ts'
|
|
17
|
+
import { contractsFulfillness } from './rules/SK002-contracts-fulfillness.ts'
|
|
17
18
|
import type { TaskDef } from './core/task-runner.ts'
|
|
18
|
-
import type { CheckCtx } from './core/check-ctx.ts'
|
|
19
19
|
import type { TuiPanelDef } from './tui/tui-panel.ts'
|
|
20
20
|
import type { MdGraph } from './wiki/md-graph.ts'
|
|
21
21
|
|
|
@@ -29,6 +29,10 @@ export type ServeConfig = {
|
|
|
29
29
|
wikiGraph?: { title?: string; load: () => MdGraph }
|
|
30
30
|
/** agent/ graph entrypoint (default: index.md AND README.md) */
|
|
31
31
|
agentIndex?: string
|
|
32
|
+
/** off-machine hostname for the hub (e.g. a tailnet MagicDNS name) —
|
|
33
|
+
* printed beside localhost and appended to ping messages (phones
|
|
34
|
+
* can't resolve localhost) */
|
|
35
|
+
publicHost?: string
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
export type RepoConfig = {
|
|
@@ -52,6 +56,19 @@ export type RepoConfig = {
|
|
|
52
56
|
/** notify command for new RED needs while the hub serves, `{message}` substituted (e.g. a push-notification CLI) */
|
|
53
57
|
notifyCommand?: string
|
|
54
58
|
contracts?: { exempt?: Record<string, string>; extra?: ContractManifest[] }
|
|
59
|
+
/** repo-contributed .gitignore lines, rendered INSIDE rule SK004's managed
|
|
60
|
+
* block — for ecosystem-specific names the published lib may not carry */
|
|
61
|
+
gitignore?: string[]
|
|
62
|
+
/** declared github visibility INTENT (absent = 'private') — rule SK007
|
|
63
|
+
* fails loud when `gh repo view` disagrees in either direction */
|
|
64
|
+
visibility?: 'private' | 'public'
|
|
65
|
+
/** the reviewer agent type this repo mandates: `.claude/agents/<name>.md`,
|
|
66
|
+
* whose system prompt points at `brief` (an md file holding the standing
|
|
67
|
+
* review framing — one home, shared across repos). `tools` overrides the
|
|
68
|
+
* read-only allowlist SK008 enforces (default: Read, Grep, Glob, Bash).
|
|
69
|
+
* Rule SK008 lints it, `check --fix SK008` writes it. Absent = no
|
|
70
|
+
* mandated reviewer. */
|
|
71
|
+
reviewer?: { name: string; brief: string; tools?: string[] }
|
|
55
72
|
/** extra dashboard panels appended after the built-in Tasks/Checks (`shipkit tui`) */
|
|
56
73
|
tui?: { panels?: TuiPanelDef[] }
|
|
57
74
|
/** the `shipkit serve` hub: port + optional graph hooks */
|
|
@@ -59,11 +76,16 @@ export type RepoConfig = {
|
|
|
59
76
|
logDir?: string
|
|
60
77
|
}
|
|
61
78
|
|
|
62
|
-
const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'notifyCommand', 'contracts', 'tui', 'serve', 'logDir'])
|
|
79
|
+
const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'notifyCommand', 'contracts', 'gitignore', 'visibility', 'reviewer', 'tui', 'serve', 'logDir'])
|
|
63
80
|
|
|
64
81
|
export function defineRepo(c: RepoConfig): RepoConfig {
|
|
65
82
|
if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
|
|
66
83
|
for (const v of c.venues ?? []) if (!/^[a-z0-9-]+$/.test(v)) throw new Error(`defineRepo: venue '${v}' must be kebab-case`)
|
|
84
|
+
if (c.visibility != null && c.visibility !== 'private' && c.visibility !== 'public')
|
|
85
|
+
throw new Error(`defineRepo: visibility '${String(c.visibility)}' — 'private' or 'public' only`)
|
|
86
|
+
// a half-declared reviewer would lint a path like `.claude/agents/.md`
|
|
87
|
+
if (c.reviewer != null && (!/^[a-z0-9][a-z0-9-]*$/.test(c.reviewer.name ?? '') || (c.reviewer.brief ?? '') === ''))
|
|
88
|
+
throw new Error(`defineRepo: reviewer needs { name: kebab-case, brief: path to the standing brief }`)
|
|
67
89
|
// unknown keys die loud — a typo'd or removed key silently changing the
|
|
68
90
|
// gate is exactly the never-silence failure mode (A23)
|
|
69
91
|
for (const k of Object.keys(c))
|
|
@@ -83,44 +105,14 @@ export async function loadRepoConfig(root: string): Promise<RepoConfig> {
|
|
|
83
105
|
return mod.default
|
|
84
106
|
}
|
|
85
107
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const declared = [
|
|
90
|
-
...venues,
|
|
91
|
-
...(social.length > 0 ? ['social'] : []),
|
|
92
|
-
...(social.includes('discord') ? ['discord'] : []),
|
|
93
|
-
]
|
|
94
|
-
return {
|
|
95
|
-
root,
|
|
96
|
-
declared,
|
|
97
|
-
social,
|
|
98
|
-
publicSurface: venues.length > 0 || existsSync(join(root, 'deploy.sh')),
|
|
99
|
-
exempt: c.contracts?.exempt,
|
|
100
|
-
docsBase: c.docsBase,
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function activeContracts(c: RepoConfig): ContractManifest[] {
|
|
105
|
-
return [...allContracts, ...(c.contracts?.extra ?? [])]
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// ONE mapping from an engine result into a CheckCtx — used by BOTH the ci
|
|
109
|
-
// task and `shipkit check`; kinds pass through raw so fix pointers and
|
|
110
|
-
// visible exempts survive every printer
|
|
111
|
-
export function applyContractsResult(t: CheckCtx, r: ContractsResult): void {
|
|
112
|
-
for (const l of r.lines) t.lines.push(l)
|
|
113
|
-
if (r.exitCode === 1) t.failed = true
|
|
114
|
-
if (r.lines.some((l) => l.kind === 'warn')) t.warned = true
|
|
115
|
-
if (r.lines.length === 0) t.ok('no contracts apply (nothing declared, nothing detected)')
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// the contracts commit-gate task — venue deploy tasks should dep on it
|
|
108
|
+
// the contracts commit-gate task — venue deploy tasks should dep on it.
|
|
109
|
+
// Same SK002-tagged rendering as `shipkit check` and `shipkit contracts`
|
|
110
|
+
// (contracts/apply-contracts.ts is the one bridge).
|
|
119
111
|
export function contractsTask(c: RepoConfig, root: string): TaskDef {
|
|
120
112
|
return {
|
|
121
113
|
id: 'contracts',
|
|
122
114
|
logic: async (t) => {
|
|
123
|
-
applyContractsResult(t, await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root)))
|
|
115
|
+
applyContractsResult(t, await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root)), contractsFulfillness)
|
|
124
116
|
},
|
|
125
117
|
}
|
|
126
118
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// SK001 — canonical package.json shape (meta/rv-conventions/package-json.md):
|
|
2
|
+
// private:true, no license, capability-required script names FAIL, banned
|
|
3
|
+
// aliases + missing baseline trio WARN. Doctor surface only — never wired
|
|
4
|
+
// into the ci gate. Pure local fs, no TTL.
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
6
|
+
import { join } from 'path'
|
|
7
|
+
import { canonicalBodies, conditionalBodies, inferBin } from '../core/canonical-scripts.ts'
|
|
8
|
+
import type { CheckCtx } from '../core/check-ctx.ts'
|
|
9
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
10
|
+
import type { FixResult, Rule, RuleEnv } from './rule.ts'
|
|
11
|
+
|
|
12
|
+
type Pkg = { name?: string; private?: boolean; license?: string; scripts?: Record<string, string> }
|
|
13
|
+
|
|
14
|
+
const OWNER = 'meta/rv-conventions/package-json.md'
|
|
15
|
+
|
|
16
|
+
// alias → { canonical, bodyMatch } — WARN only when the body proves the
|
|
17
|
+
// alias really is the canonical capability (a `serve` that serves a dist/
|
|
18
|
+
// folder must not be told to rename)
|
|
19
|
+
const ALIASES: Record<string, { canonical: string; bodyMatch: RegExp }> = {
|
|
20
|
+
'serve': { canonical: 'hub', bodyMatch: /(shipkit|cli\.ts) serve\b/ },
|
|
21
|
+
'identity': { canonical: 'assets:identity', bodyMatch: /assets(:| )identity\b/ },
|
|
22
|
+
'pretty': { canonical: 'fmt', bodyMatch: /./ },
|
|
23
|
+
'check-types': { canonical: 'typecheck', bodyMatch: /./ },
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const BASELINE = ['test', 'lint', 'fmt'] as const
|
|
27
|
+
|
|
28
|
+
// no per-line fix pointers here — the rule framework appends ONE pointer
|
|
29
|
+
// (docsBase + OWNER) to a failing/warning run, the same one-per-severity
|
|
30
|
+
// doctrine the contracts engine follows
|
|
31
|
+
export async function evaluatePackageJson(t: CheckCtx, root: string, c: RepoConfig): Promise<void> {
|
|
32
|
+
let pkg: Pkg
|
|
33
|
+
try {
|
|
34
|
+
pkg = (await Bun.file(join(root, 'package.json')).json()) as Pkg
|
|
35
|
+
} catch (e) {
|
|
36
|
+
t.fail(`package.json unreadable (${String(e).slice(0, 120)})`)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
const scripts = pkg.scripts ?? {}
|
|
40
|
+
// rvlib-* as a probe ROOT is theoretical (libs live as sub-packages and
|
|
41
|
+
// the probe only runs where repo.config.ts loads) — field inversion is
|
|
42
|
+
// still handled so a future lib-root repo fails correctly, not absurdly
|
|
43
|
+
const isLib = pkg.name?.startsWith('rvlib-') === true
|
|
44
|
+
|
|
45
|
+
// published rvlib-* roots invert the field rules (distribute/npm owns them)
|
|
46
|
+
if (isLib) {
|
|
47
|
+
if (pkg.license !== 'MIT') t.fail(`package.json: rvlib-* package must carry license MIT`)
|
|
48
|
+
if (pkg.private === true) t.fail(`package.json: rvlib-* package must not be private`)
|
|
49
|
+
} else {
|
|
50
|
+
if (pkg.private !== true) t.fail(`package.json: "private": true is required`)
|
|
51
|
+
if (pkg.license != null) t.fail(`package.json: license field is banned on project repos (rvlib-* sub-packages excepted)`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// aliases first — an alias satisfies the requirement but earns a WARN,
|
|
55
|
+
// never a double report (alias WARN + missing-canonical FAIL). A banned
|
|
56
|
+
// alias lingering NEXT TO its canonical still WARNs (sweep on sight),
|
|
57
|
+
// it just doesn't satisfy anything.
|
|
58
|
+
const aliased = new Set<string>()
|
|
59
|
+
for (const [name, a] of Object.entries(ALIASES)) {
|
|
60
|
+
const body = scripts[name]
|
|
61
|
+
if (body == null || !a.bodyMatch.test(body)) continue
|
|
62
|
+
if (scripts[a.canonical] == null) aliased.add(a.canonical)
|
|
63
|
+
t.warn(`package.json: script '${name}' is the house '${a.canonical}' — ${scripts[a.canonical] == null ? 'rename it' : `delete it ('${a.canonical}' exists)`}`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const has = (name: string): boolean => scripts[name] != null || aliased.has(name)
|
|
67
|
+
|
|
68
|
+
const required = requiredScripts(root, c)
|
|
69
|
+
const missing = required.filter((r) => !has(r.name))
|
|
70
|
+
for (const m of missing) t.fail(`package.json: missing script '${m.name}' (${m.why})`)
|
|
71
|
+
|
|
72
|
+
const missingBaseline = BASELINE.filter((n) => !has(n))
|
|
73
|
+
if (missingBaseline.length > 0)
|
|
74
|
+
t.warn(`package.json: missing baseline script(s): ${missingBaseline.join(', ')}`)
|
|
75
|
+
|
|
76
|
+
if (!t.failed && !t.warned)
|
|
77
|
+
t.ok(`package.json shape ok (private, no license, ${required.length} required scripts present)`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// capability → required script names (FAIL); repo.config.ts existence is
|
|
81
|
+
// implied — this rule only runs under `shipkit check`
|
|
82
|
+
function requiredScripts(root: string, c: RepoConfig): Array<{ name: string; why: string }> {
|
|
83
|
+
const required: Array<{ name: string; why: string }> = [
|
|
84
|
+
{ name: 'ci', why: 'shipkit repo' },
|
|
85
|
+
{ name: 'check', why: 'shipkit repo' },
|
|
86
|
+
{ name: 'contracts', why: 'shipkit repo' },
|
|
87
|
+
{ name: 'tui', why: 'shipkit repo' },
|
|
88
|
+
{ name: 'hub', why: 'shipkit repo' },
|
|
89
|
+
{ name: 'mv', why: 'shipkit repo' },
|
|
90
|
+
]
|
|
91
|
+
if (existsSync(join(root, '.rv-assets/kit.ts'))) required.push({ name: 'assets', why: '.rv-assets/kit.ts exists' })
|
|
92
|
+
if (existsSync(join(root, '.rv-identity/brand.ts'))) required.push({ name: 'assets:identity', why: '.rv-identity/brand.ts exists' })
|
|
93
|
+
if (existsSync(join(root, '.rv-comfy'))) required.push({ name: 'comfy:up', why: '.rv-comfy/ exists' })
|
|
94
|
+
if (existsSync(join(root, 'deploy.sh'))) required.push({ name: 'up', why: 'deploy.sh exists' })
|
|
95
|
+
if (existsSync(join(root, c.asks?.dir ?? 'asks'))) required.push({ name: 'asks', why: 'ask queue exists' })
|
|
96
|
+
return required
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const packageJsonShape: Rule = {
|
|
100
|
+
id: 'SK001',
|
|
101
|
+
title: 'package.json',
|
|
102
|
+
owner: OWNER,
|
|
103
|
+
lint: (t: CheckCtx, env: RuleEnv) => evaluatePackageJson(t, env.root, env.config),
|
|
104
|
+
// mechanical repairs only: field flips + scripts whose canonical body the
|
|
105
|
+
// vocabulary page states. Bodies it does NOT state (assets, comfy:up) are
|
|
106
|
+
// named in the summary, never invented. rvlib-* roots untouched — their
|
|
107
|
+
// license/private are publish decisions.
|
|
108
|
+
fix: (env: RuleEnv): FixResult => {
|
|
109
|
+
const pkgPath = join(env.root, 'package.json')
|
|
110
|
+
if (!existsSync(pkgPath)) return { changed: false, summary: 'no package.json — nothing to fix' }
|
|
111
|
+
const raw = readFileSync(pkgPath, 'utf8')
|
|
112
|
+
let pkg: Pkg & { scripts?: Record<string, string> }
|
|
113
|
+
try { pkg = JSON.parse(raw) as Pkg } catch { return { changed: false, summary: 'package.json unparseable — repair it by hand first' } }
|
|
114
|
+
if (pkg.name?.startsWith('rvlib-') === true) return { changed: false, summary: 'rvlib-* package — license/private are publish decisions, not autofixable' }
|
|
115
|
+
const changes: string[] = []
|
|
116
|
+
const notes: string[] = []
|
|
117
|
+
// any "I am published" marker → the field flips are a publish
|
|
118
|
+
// decision, not a mechanical repair (the LIBRARY-profile gap in
|
|
119
|
+
// meta/gaps.md is still open)
|
|
120
|
+
const publishMarkers: string[] = ['files', 'exports', 'publishConfig'].filter((k) => (pkg as Record<string, unknown>)[k] != null)
|
|
121
|
+
if (pkg.scripts?.['prepublishOnly'] != null) publishMarkers.push('prepublishOnly')
|
|
122
|
+
if (publishMarkers.length > 0) {
|
|
123
|
+
notes.push(`publishable package (${publishMarkers.join(', ')}) — private/license left for a human`)
|
|
124
|
+
} else {
|
|
125
|
+
if (pkg.private !== true) { pkg.private = true; changes.push('private:true set') }
|
|
126
|
+
if (pkg.license != null) { delete pkg.license; changes.push('license removed') }
|
|
127
|
+
}
|
|
128
|
+
pkg.scripts ??= {}
|
|
129
|
+
const bin = inferBin(pkg.scripts)
|
|
130
|
+
const bodies: Record<string, string> = { ...canonicalBodies({ bin }), ...conditionalBodies({ bin }) }
|
|
131
|
+
const skipped: string[] = []
|
|
132
|
+
// a banned alias satisfies a requirement for the lint — adding its
|
|
133
|
+
// canonical beside it would create the exact duplicate the lint then
|
|
134
|
+
// tells a human to delete
|
|
135
|
+
const aliasSatisfied = new Set<string>()
|
|
136
|
+
for (const [name, a] of Object.entries(ALIASES))
|
|
137
|
+
if (pkg.scripts[name] != null && a.bodyMatch.test(pkg.scripts[name]!)) aliasSatisfied.add(a.canonical)
|
|
138
|
+
for (const m of requiredScripts(env.root, env.config).filter((r) => pkg.scripts![r.name] == null && !aliasSatisfied.has(r.name))) {
|
|
139
|
+
const body = bodies[m.name]
|
|
140
|
+
if (body == null) { skipped.push(m.name); continue }
|
|
141
|
+
pkg.scripts[m.name] = body
|
|
142
|
+
changes.push(`script '${m.name}' added`)
|
|
143
|
+
}
|
|
144
|
+
if (pkg.scripts['test'] == null) { pkg.scripts['test'] = 'bun test'; changes.push(`script 'test' added`) }
|
|
145
|
+
if (changes.length > 0) {
|
|
146
|
+
const indent = raw.match(/^(\s+)"/m)?.[1] ?? ' '
|
|
147
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
|
|
148
|
+
}
|
|
149
|
+
const summary = [
|
|
150
|
+
changes.length > 0 ? changes.join(', ') : 'nothing mechanical to change',
|
|
151
|
+
...notes,
|
|
152
|
+
...(skipped.length > 0 ? [`left for a human (no canonical body): ${skipped.join(', ')}`] : []),
|
|
153
|
+
].join(' · ')
|
|
154
|
+
return { changed: changes.length > 0, summary }
|
|
155
|
+
},
|
|
156
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// SK002 — .rv-* contract fulfillness (meta/contracts.md). The rule is the
|
|
2
|
+
// registration point; the evaluation machinery stays in
|
|
3
|
+
// contracts/contracts-engine.ts (venue manifests are DATA rows, not rules —
|
|
4
|
+
// one SK id covers the invariant "declared contracts are fulfilled, folders
|
|
5
|
+
// can't rot undeclared"). Every surface reporting fulfillness (`check`,
|
|
6
|
+
// `shipkit contracts`, the ci `contracts` task) tags with this id via
|
|
7
|
+
// contracts/apply-contracts.ts.
|
|
8
|
+
import { activeContracts, applyContractsResult, contractsConfigFrom } from '../contracts/apply-contracts.ts'
|
|
9
|
+
import { evaluateContracts } from '../contracts/contracts-engine.ts'
|
|
10
|
+
import type { Rule } from './rule.ts'
|
|
11
|
+
|
|
12
|
+
export const contractsFulfillness: Rule = {
|
|
13
|
+
id: 'SK002',
|
|
14
|
+
title: 'contracts',
|
|
15
|
+
owner: 'meta/contracts.md',
|
|
16
|
+
lint: async (t, env) => {
|
|
17
|
+
applyContractsResult(t, await evaluateContracts(activeContracts(env.config), contractsConfigFrom(env.config, env.root)), contractsFulfillness)
|
|
18
|
+
},
|
|
19
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// SK003 — the CLAUDE.md two-line contract (meta/agent-folder.md): CLAUDE.md
|
|
2
|
+
// is an include index, never a doc; `@./agent/index.md` comes LAST (include
|
|
3
|
+
// order is SEMANTIC — base context first, repo docs after so they
|
|
4
|
+
// override; a field bug moved the local include line and inverted it); a
|
|
5
|
+
// public repo's `@./CLAUDE.local.md` line-1 indirection must be gitignored.
|
|
6
|
+
// The lib stays generic: no personal include path is asserted — `shipkit
|
|
7
|
+
// start --include` flags own which base include a machine wires in. A repo
|
|
8
|
+
// with neither CLAUDE.md nor agent/ is not a two-line-contract repo and
|
|
9
|
+
// passes.
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
11
|
+
import { join } from 'path'
|
|
12
|
+
import { spawnWithTimeout } from '../core/spawn-timeout.ts'
|
|
13
|
+
import type { FixResult, Rule, RuleEnv } from './rule.ts'
|
|
14
|
+
|
|
15
|
+
const AGENT_LINE = '@./agent/index.md'
|
|
16
|
+
const LOCAL_LINE = '@./CLAUDE.local.md'
|
|
17
|
+
|
|
18
|
+
export const claudeMdShape: Rule = {
|
|
19
|
+
id: 'SK003',
|
|
20
|
+
title: 'CLAUDE.md',
|
|
21
|
+
owner: 'meta/agent-folder.md',
|
|
22
|
+
lint: async (t, env) => {
|
|
23
|
+
const claudePath = join(env.root, 'CLAUDE.md')
|
|
24
|
+
const hasAgent = existsSync(join(env.root, 'agent/index.md'))
|
|
25
|
+
if (!existsSync(claudePath)) {
|
|
26
|
+
if (hasAgent) t.fail(`agent/index.md exists but there is no CLAUDE.md routing it — the two-line contract wants ${AGENT_LINE}`)
|
|
27
|
+
else t.ok('no CLAUDE.md / agent — not a two-line-contract repo')
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
const lines = readFileSync(claudePath, 'utf8').split('\n').filter((l) => l.trim() !== '')
|
|
31
|
+
const includes = lines.filter((l) => l.startsWith('@'))
|
|
32
|
+
const prose = lines.length - includes.length
|
|
33
|
+
if (prose > 0)
|
|
34
|
+
t.fail(`CLAUDE.md carries ${prose} non-include line(s) — it is a tiny include index, content belongs in agent/index.md`)
|
|
35
|
+
if (includes.length > 2)
|
|
36
|
+
t.fail(`CLAUDE.md has ${includes.length} include lines — the contract is 2: base context, then ${AGENT_LINE}`)
|
|
37
|
+
if (hasAgent && !lines.includes(AGENT_LINE))
|
|
38
|
+
t.fail(`agent/index.md exists but CLAUDE.md does not import it — add ${AGENT_LINE} as the LAST line`)
|
|
39
|
+
if (lines.includes(AGENT_LINE) && lines.at(-1) !== AGENT_LINE)
|
|
40
|
+
t.fail(`include order inverted — base context first, ${AGENT_LINE} LAST (order is semantic: repo docs override the base)`)
|
|
41
|
+
if (lines.includes(LOCAL_LINE)) {
|
|
42
|
+
const r = await spawnWithTimeout(['git', 'check-ignore', '-q', 'CLAUDE.local.md'], { cwd: env.root, timeoutMs: 5_000 })
|
|
43
|
+
if (r.code === 1)
|
|
44
|
+
t.fail(`${LOCAL_LINE} is imported but CLAUDE.local.md is not gitignored — the machine-local include must never be tracked`)
|
|
45
|
+
else if (r.code !== 0)
|
|
46
|
+
t.note(`CLAUDE.local.md gitignore not verifiable (git says: ${r.out.trim().split('\n')[0] ?? `exit ${r.code}`})`)
|
|
47
|
+
}
|
|
48
|
+
if (!t.failed) t.ok(`CLAUDE.md include-index ok (${lines.length} line(s))`)
|
|
49
|
+
},
|
|
50
|
+
// the one mechanical repair: the missing agent/index.md route, appended
|
|
51
|
+
// to an EXISTING CLAUDE.md. Never creates the file — a created one-line
|
|
52
|
+
// CLAUDE.md would lint green with the base include forever missing,
|
|
53
|
+
// trading a loud FAIL for a permanent silent gap. Creation and prose
|
|
54
|
+
// relocation stay human/machine-local (`shipkit start --include`).
|
|
55
|
+
fix: (env: RuleEnv): FixResult => {
|
|
56
|
+
const claudePath = join(env.root, 'CLAUDE.md')
|
|
57
|
+
if (!existsSync(join(env.root, 'agent/index.md'))) return { changed: false, summary: 'no agent/index.md — nothing mechanical to fix' }
|
|
58
|
+
if (!existsSync(claudePath)) return { changed: false, summary: 'CLAUDE.md missing — creation needs the base include (shipkit start --include), not autofixable' }
|
|
59
|
+
const cur = readFileSync(claudePath, 'utf8')
|
|
60
|
+
if (cur.split('\n').includes(AGENT_LINE)) return { changed: false, summary: `${AGENT_LINE} already routed — remaining findings need a human` }
|
|
61
|
+
writeFileSync(claudePath, `${cur.endsWith('\n') || cur === '' ? cur : `${cur}\n`}${AGENT_LINE}\n`)
|
|
62
|
+
return { changed: true, summary: `${AGENT_LINE} appended as the last line` }
|
|
63
|
+
},
|
|
64
|
+
}
|