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.
- package/CHANGELOG.md +204 -0
- package/README.md +17 -3
- package/package.json +5 -2
- package/src/asks/asks-queue.ts +42 -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_Needs.ts +47 -0
- 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 +7 -2
- package/src/collections/actions.ts +85 -0
- package/src/collections/collection-types.ts +65 -0
- package/src/collections/graphs.ts +34 -0
- package/src/collections/pings.ts +72 -0
- package/src/collections/posts.ts +125 -0
- package/src/collections/providers.ts +275 -0
- package/src/contracts/apply-contracts.ts +47 -0
- 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 +24 -11
- package/src/reflect/prompt-corpus.ts +305 -0
- package/src/repo-config.ts +26 -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/rule-registry.ts +32 -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 +240 -127
- 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 -35
- 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 +46 -91
- package/src/serve/serve-hub.ts +99 -49
- package/src/shipkit.ts +13 -6
- 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 -45
package/src/core/write-status.ts
CHANGED
|
@@ -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
|
|
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
|
|
28
|
-
const
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 {
|
|
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)) {
|
|
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
|
|
44
|
-
// vocabulary owned by
|
|
45
|
-
|
|
46
|
-
for (const [k,
|
|
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
|
+
}
|
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 = {
|
|
@@ -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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
}
|