rvlib-shipkit 0.10.0 → 0.13.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 (61) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/README.md +17 -3
  3. package/package.json +5 -2
  4. package/src/asks/asks-queue.ts +42 -0
  5. package/src/assets/kit-config.ts +2 -3
  6. package/src/assets/roll-workspace.ts +2 -1
  7. package/src/assets/run-assets.ts +0 -11
  8. package/src/assets/server/studio-server.ts +7 -24
  9. package/src/cli/cmd_Assets.ts +3 -14
  10. package/src/cli/cmd_Check.ts +22 -4
  11. package/src/cli/cmd_Needs.ts +47 -0
  12. package/src/cli/cmd_Reflect.ts +116 -0
  13. package/src/cli/cmd_Rules.ts +32 -0
  14. package/src/cli/cmd_Serve.ts +1 -1
  15. package/src/cli.ts +7 -2
  16. package/src/collections/actions.ts +85 -0
  17. package/src/collections/collection-types.ts +65 -0
  18. package/src/collections/graphs.ts +34 -0
  19. package/src/collections/pings.ts +72 -0
  20. package/src/collections/posts.ts +125 -0
  21. package/src/collections/providers.ts +275 -0
  22. package/src/contracts/apply-contracts.ts +47 -0
  23. package/src/contracts/run-contracts.ts +8 -3
  24. package/src/core/canonical-scripts.ts +46 -0
  25. package/src/core/check-suite.ts +6 -3
  26. package/src/core/locked-write.ts +39 -0
  27. package/src/core/motherhome.ts +87 -0
  28. package/src/core/run-check.ts +28 -15
  29. package/src/core/run-ci.ts +4 -0
  30. package/src/core/write-status.ts +7 -26
  31. package/src/init-repo.ts +24 -11
  32. package/src/reflect/prompt-corpus.ts +305 -0
  33. package/src/repo-config.ts +26 -38
  34. package/src/rules/SK001-package-json-shape.ts +156 -0
  35. package/src/rules/SK002-contracts-fulfillness.ts +19 -0
  36. package/src/rules/SK003-claude-md-shape.ts +64 -0
  37. package/src/rules/SK004-gitignore-policy.ts +105 -0
  38. package/src/rules/SK005-rvlib-versions.ts +99 -0
  39. package/src/rules/SK006-npm-publish-safety.ts +42 -0
  40. package/src/rules/SK007-github-visibility.ts +63 -0
  41. package/src/rules/rule-registry.ts +32 -0
  42. package/src/rules/rule.ts +62 -0
  43. package/src/rules/run-fix.ts +57 -0
  44. package/src/serve/hub-app/col-card.tsx +192 -0
  45. package/src/serve/hub-app/cols-svc.ts +136 -0
  46. package/src/serve/hub-app/geometry-view.tsx +155 -0
  47. package/src/serve/hub-app/health-svc.ts +49 -0
  48. package/src/serve/hub-app/hub-app.tsx +240 -127
  49. package/src/serve/hub-app/hub-st.ts +169 -0
  50. package/src/serve/hub-app/hub-types.ts +72 -0
  51. package/src/serve/hub-app/hub.css +286 -35
  52. package/src/serve/hub-app/index.html +1 -1
  53. package/src/serve/hub-app/md.ts +24 -0
  54. package/src/serve/hub-app/media-svc.ts +429 -0
  55. package/src/serve/hub-app/media-view.tsx +299 -0
  56. package/src/serve/run-serve.ts +46 -91
  57. package/src/serve/serve-hub.ts +99 -49
  58. package/src/shipkit.ts +13 -6
  59. package/src/assets/server/studio-page.ts +0 -687
  60. package/src/core/package-json-check.ts +0 -88
  61. package/src/serve/hub-pages.ts +0 -45
@@ -1,11 +1,11 @@
1
1
  // THE STATUS.md writer — every repo gets one (O4, one-runner GO
2
2
  // 2026-07-22): timestamp header + verdict + fenced compact render, the
3
- // shape rv-doctor pioneered. Atomic (tmp file + rename) and lock-guarded:
3
+ // shape rv-doctor pioneered. Atomic + lock-guarded via core/locked-write:
4
4
  // concurrent runners never interleave — the loser skips with a WARN
5
5
  // outcome, never corrupts the file.
6
6
 
7
- import { renameSync, rmSync, statSync, writeFileSync } from 'fs'
8
7
  import { join } from 'path'
8
+ import { atomicWrite, withFileLock } from './locked-write.ts'
9
9
 
10
10
  export type StatusInput = {
11
11
  /** repo name — headline */
@@ -20,25 +20,10 @@ export type StatusInput = {
20
20
  export type StatusOutcome = { written: boolean; warn?: string }
21
21
 
22
22
  const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, '')
23
- // a runner that died without unlocking must not wedge STATUS.md forever
24
- const LOCK_STALE_MS = 120_000
25
23
 
26
24
  export function writeStatus(root: string, s: StatusInput): StatusOutcome {
27
- const lockPath = join(root, 'STATUS.md.lock')
28
- const grab = (): boolean => {
29
- try { writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); return true } catch { return false }
30
- }
31
- if (!grab()) {
32
- let stale = false
33
- try { stale = Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS } catch {
34
- // lock vanished between grab and stat — the racing writer just
35
- // finished; still skip this round rather than fight over it
36
- }
37
- if (stale) rmSync(lockPath, { force: true })
38
- if (!stale || !grab())
39
- return { written: false, warn: `STATUS.md locked by another runner (${lockPath}) — skipped this write` }
40
- }
41
- try {
25
+ const target = join(root, 'STATUS.md')
26
+ const r = withFileLock(target, () => {
42
27
  const clean = s.lines.map(stripAnsi)
43
28
  const fails = clean.filter((l) => l.startsWith('FAIL')).length
44
29
  const warns = clean.filter((l) => l.startsWith('WARN')).length
@@ -60,11 +45,7 @@ export function writeStatus(root: string, s: StatusInput): StatusOutcome {
60
45
  ${clean.join('\n').trim()}
61
46
  \`\`\`
62
47
  `
63
- const tmpPath = join(root, `.STATUS.md.tmp-${process.pid}`)
64
- writeFileSync(tmpPath, body)
65
- renameSync(tmpPath, join(root, 'STATUS.md'))
66
- return { written: true }
67
- } finally {
68
- rmSync(lockPath, { force: true })
69
- }
48
+ atomicWrite(target, body)
49
+ })
50
+ return r.ok ? { written: true } : { written: false, warn: `STATUS.md locked by another runner — skipped this write` }
70
51
  }
package/src/init-repo.ts CHANGED
@@ -7,7 +7,10 @@
7
7
  // by path instead of from node_modules.
8
8
  import { existsSync, readFileSync, writeFileSync } from 'fs'
9
9
  import { join } from 'path'
10
- import { defineRepo, resolveFloor } from './repo-config.ts'
10
+ import { canonicalBodies } from './core/canonical-scripts.ts'
11
+ import { reportToMotherhome } from './core/motherhome.ts'
12
+ import { defineRepo, loadRepoConfig, resolveFloor } from './repo-config.ts'
13
+ import { gitignorePolicy } from './rules/SK004-gitignore-policy.ts'
11
14
 
12
15
  const CONFIG_TEMPLATE = (name: string): string => `import { defineRepo } from 'rvlib-shipkit'
13
16
 
@@ -28,7 +31,18 @@ export type InitOpts = {
28
31
 
29
32
  export async function initRepo(root: string, opts: InitOpts = {}): Promise<number> {
30
33
  const configPath = join(root, 'repo.config.ts')
31
- if (existsSync(configPath)) { console.log(`skip: repo.config.ts already exists`); return 0 }
34
+ if (existsSync(configPath)) {
35
+ console.log(`skip: repo.config.ts already exists`)
36
+ // adoption path: re-running init on an existing repo still converges
37
+ // its .gitignore policy block (same fixer as `check --fix SK004`),
38
+ // with the repo's own contributed lines honored
39
+ const cfg = await loadRepoConfig(root)
40
+ const g = await gitignorePolicy.fix!({ root, config: cfg })
41
+ console.log(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
42
+ const mh = reportToMotherhome(root, cfg)
43
+ if (mh.note != null) console.log(mh.note)
44
+ return 0
45
+ }
32
46
  const pkgPath = join(root, 'package.json')
33
47
  if (!existsSync(pkgPath)) { console.error('FAIL: no package.json here — run from the repo root'); return 1 }
34
48
  const pkgRaw = readFileSync(pkgPath, 'utf8')
@@ -38,23 +52,22 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
38
52
  console.log('ok: repo.config.ts created')
39
53
  const indent = pkgRaw.match(/^(\s+)"/m)?.[1] ?? ' '
40
54
  pkg.scripts ??= {}
41
- const run = (cmd: string): string => (opts.bin != null ? `bun ${opts.bin} ${cmd}` : `shipkit ${cmd}`)
42
55
  let added = 0
43
- // canonical script NAME → shipkit command (hub runs `shipkit serve`) —
44
- // vocabulary owned by meta/rv-conventions/package-json.md
45
- const CANONICAL = { ci: 'ci', check: 'check', contracts: 'contracts', tui: 'tui', hub: 'serve', mv: 'mv' } as const
46
- for (const [k, cmd] of Object.entries(CANONICAL)) {
47
- const v = run(cmd)
56
+ // canonical script bodies — ONE source (core/canonical-scripts.ts),
57
+ // shared with rule SK001's autofix; vocabulary owned by
58
+ // meta/rv-conventions/package-json.md
59
+ for (const [k, v] of Object.entries(canonicalBodies({ bin: opts.bin }))) {
48
60
  if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
49
61
  else if (pkg.scripts[k] !== v) console.log(`skip: package.json script '${k}' exists — left untouched`)
50
62
  }
51
- // baseline `test` is universal (bun test) — lint/fmt bodies are
52
- // stack-specific, the scaffold pages own those
53
- if (pkg.scripts['test'] == null) { pkg.scripts['test'] = 'bun test'; added++ }
54
63
  if (added > 0) {
55
64
  writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
56
65
  console.log(`ok: ${added} package.json script(s) added`)
57
66
  }
67
+ // the .gitignore policy block (contract git policy) — same fixer as
68
+ // `shipkit check --fix SK004`, one code path
69
+ const g = await gitignorePolicy.fix!({ root, config: defineRepo({ name }) })
70
+ console.log(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
58
71
  // show what the gate actually is — a thin floor must be seen at init
59
72
  // time, not discovered after a bad commit slipped through
60
73
  const floor = resolveFloor(defineRepo({ name }), root)
@@ -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
+ }
@@ -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 { allContracts } from './contracts/contract-manifests.ts'
15
- import { evaluateContracts, type ContractsConfig, type ContractsResult } from './contracts/contracts-engine.ts'
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 = {
@@ -45,7 +49,19 @@ export type RepoConfig = {
45
49
  tasks?: TaskDef[]
46
50
  /** emptyText: the `shipkit asks` all-clear line, in the repo's own voice */
47
51
  asks?: { dir?: string; emptyText?: string }
52
+ /** secrets this repo needs; mint artifact rides as data (needs feed tile) */
53
+ creds?: { name: string; service?: string; mintUrl?: string; steps?: string[] }[]
54
+ /** store-command template for a missing secret tile, `{name}` substituted (e.g. your secret manager's set command) */
55
+ credsCommand?: string
56
+ /** notify command for new RED needs while the hub serves, `{message}` substituted (e.g. a push-notification CLI) */
57
+ notifyCommand?: string
48
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'
49
65
  /** extra dashboard panels appended after the built-in Tasks/Checks (`shipkit tui`) */
50
66
  tui?: { panels?: TuiPanelDef[] }
51
67
  /** the `shipkit serve` hub: port + optional graph hooks */
@@ -53,11 +69,13 @@ export type RepoConfig = {
53
69
  logDir?: string
54
70
  }
55
71
 
56
- const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'contracts', 'tui', 'serve', 'logDir'])
72
+ const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'notifyCommand', 'contracts', 'gitignore', 'visibility', 'tui', 'serve', 'logDir'])
57
73
 
58
74
  export function defineRepo(c: RepoConfig): RepoConfig {
59
75
  if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
60
76
  for (const v of c.venues ?? []) if (!/^[a-z0-9-]+$/.test(v)) throw new Error(`defineRepo: venue '${v}' must be kebab-case`)
77
+ if (c.visibility != null && c.visibility !== 'private' && c.visibility !== 'public')
78
+ throw new Error(`defineRepo: visibility '${String(c.visibility)}' — 'private' or 'public' only`)
61
79
  // unknown keys die loud — a typo'd or removed key silently changing the
62
80
  // gate is exactly the never-silence failure mode (A23)
63
81
  for (const k of Object.keys(c))
@@ -77,44 +95,14 @@ export async function loadRepoConfig(root: string): Promise<RepoConfig> {
77
95
  return mod.default
78
96
  }
79
97
 
80
- export function contractsConfigFrom(c: RepoConfig, root: string): ContractsConfig {
81
- const venues = c.venues ?? []
82
- const social = c.social ?? []
83
- const declared = [
84
- ...venues,
85
- ...(social.length > 0 ? ['social'] : []),
86
- ...(social.includes('discord') ? ['discord'] : []),
87
- ]
88
- return {
89
- root,
90
- declared,
91
- social,
92
- publicSurface: venues.length > 0 || existsSync(join(root, 'deploy.sh')),
93
- exempt: c.contracts?.exempt,
94
- docsBase: c.docsBase,
95
- }
96
- }
97
-
98
- export function activeContracts(c: RepoConfig): ContractManifest[] {
99
- return [...allContracts, ...(c.contracts?.extra ?? [])]
100
- }
101
-
102
- // ONE mapping from an engine result into a CheckCtx — used by BOTH the ci
103
- // task and `shipkit check`; kinds pass through raw so fix pointers and
104
- // visible exempts survive every printer
105
- export function applyContractsResult(t: CheckCtx, r: ContractsResult): void {
106
- for (const l of r.lines) t.lines.push(l)
107
- if (r.exitCode === 1) t.failed = true
108
- if (r.lines.some((l) => l.kind === 'warn')) t.warned = true
109
- if (r.lines.length === 0) t.ok('no contracts apply (nothing declared, nothing detected)')
110
- }
111
-
112
- // the contracts commit-gate task — venue deploy tasks should dep on it
98
+ // the contracts commit-gate task — venue deploy tasks should dep on it.
99
+ // Same SK002-tagged rendering as `shipkit check` and `shipkit contracts`
100
+ // (contracts/apply-contracts.ts is the one bridge).
113
101
  export function contractsTask(c: RepoConfig, root: string): TaskDef {
114
102
  return {
115
103
  id: 'contracts',
116
104
  logic: async (t) => {
117
- applyContractsResult(t, await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root)))
105
+ applyContractsResult(t, await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root)), contractsFulfillness)
118
106
  },
119
107
  }
120
108
  }