rvlib-shipkit 2.1.1 → 2.3.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 +50 -0
- package/package.json +2 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/assets/kits/identity-kit.ts +7 -3
- package/src/assets/run-assets.ts +18 -4
- package/src/checks/SK004-gitignore-policy.check.ts +11 -6
- package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
- package/src/cli/cmd_Calls.ts +53 -0
- package/src/cli/cmd_Commands.ts +163 -43
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +361 -0
- package/src/cli/kit/builtins.ts +40 -0
- package/src/cli/kit/calls.ts +177 -0
- package/src/cli/kit/families.ts +67 -0
- package/src/cli/kit/index.ts +8 -0
- package/src/cli/kit/pages-check.ts +36 -0
- package/src/cli/kit/project-cli.ts +73 -0
- package/src/cli/kit/verb.ts +167 -0
- package/src/cli.ts +15 -2
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/layout.ts +12 -2
- package/src/core/migrate-layout.ts +31 -8
- package/src/core/one-way-in-policy.ts +12 -4
- package/src/reflect/prompt-corpus.ts +15 -114
- package/src/reflect/transcript.ts +254 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +150 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +854 -0
- package/src/review/project.ts +110 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +228 -0
- package/src/review/store.ts +1002 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +165 -0
- package/src/review/watchers.ts +54 -0
- package/src/scaffold/cli-skeleton.ts +149 -0
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/files/tsconfig.json +3 -0
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/scaffold/project-scaffold.ts +90 -12
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +28 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +10 -2
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +45 -13
- package/src/serve/hub-app/icons.tsx +9 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +22 -4
- package/src/serve/hub-app/project-list.tsx +41 -8
- package/src/serve/hub-app/projects-svc.ts +4 -0
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +45 -3
- package/src/serve/hub-app/review/clip-st.ts +208 -0
- package/src/serve/hub-app/review/kind-view.tsx +171 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +239 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +101 -0
- package/src/serve/hub-app/review/review-st.ts +779 -0
- package/src/serve/hub-app/review/review-views.tsx +503 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +329 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- package/src/serve/hub-runtime.ts +5 -3
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +8 -0
- package/src/shipkit-files.ts +12 -4
- package/src/shot/client.ts +67 -0
- package/src/shot/clip-encoder.ts +146 -0
- package/src/shot/dom-clip.ts +124 -0
- package/src/shot/serve-shot.ts +54 -0
- package/src/shot/shooter.ts +343 -0
- package/src/shot/shot-ask.ts +189 -0
- package/src/shot/shot.shipkit.ts +14 -0
- package/src/smoke/run-smoke.ts +2 -2
- package/src/start-repo.ts +29 -14
- package/src/suite/judge-suites.ts +56 -10
- package/src/suite/select.ts +2 -5
- package/src/suite/suite-cli.ts +3 -2
- package/src/trailer/adapter.ts +78 -0
- package/src/trailer/build.ts +603 -0
- package/src/trailer/ffmpeg.ts +72 -0
- package/src/trailer/footage.ts +173 -0
- package/src/trailer/grammar.ts +1290 -0
- package/src/trailer/graph.ts +508 -0
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +31 -0
- package/src/trailer/look.ts +124 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +192 -0
- package/src/trailer/prune.ts +193 -0
- package/src/trailer/score/compose.ts +240 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +91 -0
- package/src/trailer/score/score.ts +214 -0
- package/src/trailer/score/sound.ts +112 -0
- package/src/trailer/score/synth.ts +361 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +233 -0
- package/src/trailer/titles.ts +379 -0
- package/src/trailer/url.ts +108 -0
- package/src/trailer/vertical-draw.ts +286 -0
- package/src/trailer/vertical.ts +148 -0
- package/src/trailer/vocab.ts +59 -0
- package/src/trailer/words.ts +254 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// The time math of one transcript stream (a session, or one sub-agent run):
|
|
2
|
+
// how long each tool call took, and where the wall time went.
|
|
3
|
+
//
|
|
4
|
+
// The constraint a reader breaks first: parallel calls overlap. A call's own
|
|
5
|
+
// time is end minus start, and a sum of those is CALL time, which can exceed
|
|
6
|
+
// the wall. The wall is cut once, by a sweep over every boundary, so a second
|
|
7
|
+
// of wall lands in exactly one bucket.
|
|
8
|
+
import type { Outcome } from '../reflect/transcript.ts'
|
|
9
|
+
|
|
10
|
+
export type Call = {
|
|
11
|
+
id: string
|
|
12
|
+
start: number
|
|
13
|
+
/** null while its result has not arrived (a live call, or one never answered) */
|
|
14
|
+
end: number | null
|
|
15
|
+
tool: string
|
|
16
|
+
shape: string
|
|
17
|
+
cmd: string
|
|
18
|
+
outcome: Outcome | null
|
|
19
|
+
code: number | null
|
|
20
|
+
/** it waited for the human to allow it */
|
|
21
|
+
approval: boolean
|
|
22
|
+
/** how it was allowed (transcript.ts Perm) */
|
|
23
|
+
perm: string
|
|
24
|
+
/** it only read (turns.ts isReadOnly, judged when indexed) */
|
|
25
|
+
read: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type Mark = { at: number; kind: 'prompt' | 'wake' | 'message' | 'reply' }
|
|
29
|
+
|
|
30
|
+
export type Stream = { calls: Call[]; marks: Mark[] }
|
|
31
|
+
|
|
32
|
+
/** where a slice of wall time went */
|
|
33
|
+
export type Bucket = 'model' | 'tools' | 'approval' | 'subagent' | 'background' | 'human' | 'idle'
|
|
34
|
+
|
|
35
|
+
export type Breakdown = Record<Bucket, number>
|
|
36
|
+
|
|
37
|
+
/** a wait on him longer than this is the session parked, not a wait */
|
|
38
|
+
export const IDLE_MS = 30 * 60 * 1000
|
|
39
|
+
|
|
40
|
+
export const emptyBreakdown = (): Breakdown => ({ model: 0, tools: 0, approval: 0, subagent: 0, background: 0, human: 0, idle: 0 })
|
|
41
|
+
|
|
42
|
+
/** a foreground sub-agent call: the session waits on another agent's work */
|
|
43
|
+
export const isAgentCall = (c: Call): boolean => c.tool === 'Agent' || c.tool === 'Task'
|
|
44
|
+
|
|
45
|
+
/** the first value of a sorted list above `t`, by bisection */
|
|
46
|
+
function firstAfter(sorted: number[], t: number): number | undefined {
|
|
47
|
+
let lo = 0
|
|
48
|
+
let hi = sorted.length
|
|
49
|
+
while (lo < hi) {
|
|
50
|
+
const mid = (lo + hi) >> 1
|
|
51
|
+
if ((sorted[mid] as number) <= t) lo = mid + 1
|
|
52
|
+
else hi = mid
|
|
53
|
+
}
|
|
54
|
+
return sorted[lo]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** the end every call is measured to: its result, else the next prompt or wake
|
|
58
|
+
* after it (the human moved on, so it was interrupted), else the stream's last
|
|
59
|
+
* event. `open` says the end was inferred */
|
|
60
|
+
export type Closed = Call & { stop: number; open: boolean }
|
|
61
|
+
|
|
62
|
+
/** sorted by start */
|
|
63
|
+
export function closeCalls(s: Stream): Closed[] {
|
|
64
|
+
const turns = s.marks.filter((m) => m.kind !== 'reply').map((m) => m.at).sort((a, b) => a - b)
|
|
65
|
+
let last = 0
|
|
66
|
+
for (const m of s.marks) if (m.at > last) last = m.at
|
|
67
|
+
for (const c of s.calls) if ((c.end ?? c.start) > last) last = c.end ?? c.start
|
|
68
|
+
return s.calls
|
|
69
|
+
.map((c): Closed => {
|
|
70
|
+
if (c.end != null) return { ...c, stop: Math.max(c.start, c.end), open: false }
|
|
71
|
+
const next = firstAfter(turns, c.start)
|
|
72
|
+
return { ...c, stop: next ?? last, open: true }
|
|
73
|
+
})
|
|
74
|
+
.sort((a, b) => a.start - b.start)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** the call's own duration in ms */
|
|
78
|
+
export const callMs = (c: { start: number; stop: number }): number => Math.max(0, c.stop - c.start)
|
|
79
|
+
|
|
80
|
+
type Point = { at: number; d: 'start' | 'stop' | Mark['kind']; lane: 'tool' | 'agent' | 'approval'; ix: number }
|
|
81
|
+
|
|
82
|
+
/** the wall of one stream cut into buckets, clipped to [from, to]. Between two
|
|
83
|
+
* boundaries: a tool in flight → tools; only calls waiting for his allow →
|
|
84
|
+
* approval; only a sub-agent in flight → subagent;
|
|
85
|
+
* nothing in flight and the next thing is his prompt → human (idle past
|
|
86
|
+
* IDLE_MS); the next thing is a wake (a task notification, a hand-back) →
|
|
87
|
+
* background; a message relayed in counts as a prompt (a sub-agent that
|
|
88
|
+
* handed back sits parked until the coordinator or he writes to it); anything else → the model was working.
|
|
89
|
+
*
|
|
90
|
+
* `turnModel` (one slot per closed call) receives the model stretch that
|
|
91
|
+
* ended in each call: the turn that issued it, split evenly over the calls
|
|
92
|
+
* of one batch. A stretch that ends in his prompt or a wake was the final
|
|
93
|
+
* reply and goes to no call */
|
|
94
|
+
export function wallBreakdown(s: Stream, from = -Infinity, to = Infinity, idleMs = IDLE_MS, closed: Closed[] = closeCalls(s), turnModel?: Float64Array): Breakdown {
|
|
95
|
+
const out = emptyBreakdown()
|
|
96
|
+
const points: Point[] = []
|
|
97
|
+
closed.forEach((c, ix) => {
|
|
98
|
+
const lane = isAgentCall(c) ? 'agent' : c.approval ? 'approval' : 'tool'
|
|
99
|
+
points.push({ at: c.start, d: 'start', lane, ix }, { at: c.stop, d: 'stop', lane, ix })
|
|
100
|
+
})
|
|
101
|
+
for (const m of s.marks) points.push({ at: m.at, d: m.kind, lane: 'tool', ix: -1 })
|
|
102
|
+
points.sort((a, b) => a.at - b.at)
|
|
103
|
+
let tools = 0
|
|
104
|
+
let agents = 0
|
|
105
|
+
let approvals = 0
|
|
106
|
+
let pending = 0
|
|
107
|
+
const issued: number[] = []
|
|
108
|
+
let i = 0
|
|
109
|
+
while (i < points.length) {
|
|
110
|
+
const at = (points[i] as Point).at
|
|
111
|
+
issued.length = 0
|
|
112
|
+
let ended = false
|
|
113
|
+
while (i < points.length && (points[i] as Point).at === at) {
|
|
114
|
+
const p = points[i] as Point
|
|
115
|
+
const n = p.d === 'start' ? 1 : p.d === 'stop' ? -1 : 0
|
|
116
|
+
if (p.lane === 'agent') agents += n
|
|
117
|
+
else if (p.lane === 'approval') approvals += n
|
|
118
|
+
else tools += n
|
|
119
|
+
if (p.d === 'start') issued.push(p.ix)
|
|
120
|
+
else if (p.d === 'prompt' || p.d === 'wake' || p.d === 'message') ended = true
|
|
121
|
+
i++
|
|
122
|
+
}
|
|
123
|
+
if (turnModel != null) {
|
|
124
|
+
if (issued.length > 0) for (const ix of issued) turnModel[ix] = (turnModel[ix] ?? 0) + pending / issued.length
|
|
125
|
+
if (issued.length > 0 || ended) pending = 0
|
|
126
|
+
}
|
|
127
|
+
if (i >= points.length) break
|
|
128
|
+
const nextAt = (points[i] as Point).at
|
|
129
|
+
const a = Math.max(at, from)
|
|
130
|
+
const b = Math.min(nextAt, to)
|
|
131
|
+
if (b <= a) continue
|
|
132
|
+
const len = b - a
|
|
133
|
+
let bucket: Bucket
|
|
134
|
+
if (tools > 0) bucket = 'tools'
|
|
135
|
+
else if (approvals > 0) bucket = 'approval'
|
|
136
|
+
else if (agents > 0) bucket = 'subagent'
|
|
137
|
+
else {
|
|
138
|
+
let next = i
|
|
139
|
+
let prompt = false
|
|
140
|
+
let wake = false
|
|
141
|
+
while (next < points.length && (points[next] as Point).at === nextAt) {
|
|
142
|
+
const d = (points[next] as Point).d
|
|
143
|
+
if (d === 'prompt' || d === 'message') prompt = true
|
|
144
|
+
if (d === 'wake') wake = true
|
|
145
|
+
next++
|
|
146
|
+
}
|
|
147
|
+
const gap = nextAt - at
|
|
148
|
+
bucket = prompt ? (gap > idleMs ? 'idle' : 'human') : wake ? 'background' : 'model'
|
|
149
|
+
}
|
|
150
|
+
out[bucket] += len
|
|
151
|
+
if (bucket === 'model') pending += len
|
|
152
|
+
}
|
|
153
|
+
return out
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const addBreakdown = (into: Breakdown, b: Breakdown): Breakdown => {
|
|
157
|
+
for (const k of Object.keys(into) as Bucket[]) into[k] += b[k]
|
|
158
|
+
return into
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** a failed call is retried when the next call of the same shape in the
|
|
162
|
+
* stream comes within this window */
|
|
163
|
+
export const RETRY_MS = 10 * 60 * 1000
|
|
164
|
+
|
|
165
|
+
export const FAILED: ReadonlySet<Outcome> = new Set<Outcome>(['exit', 'error', 'timeout'])
|
|
166
|
+
|
|
167
|
+
export type Retry = { failed: Closed; next: Closed; blind: boolean }
|
|
168
|
+
|
|
169
|
+
/** every failed call followed by the same shape again: `blind` when the
|
|
170
|
+
* command is the exact same text, so nothing changed between the two.
|
|
171
|
+
* `calls` sorted by start (closeCalls) */
|
|
172
|
+
export function retries(calls: Closed[]): Retry[] {
|
|
173
|
+
const out: Retry[] = []
|
|
174
|
+
const nextOfShape = new Map<string, Closed>()
|
|
175
|
+
for (let i = calls.length - 1; i >= 0; i--) {
|
|
176
|
+
const c = calls[i] as Closed
|
|
177
|
+
const next = nextOfShape.get(c.shape)
|
|
178
|
+
nextOfShape.set(c.shape, c)
|
|
179
|
+
if (c.outcome == null || !FAILED.has(c.outcome)) continue
|
|
180
|
+
if (next == null || next.start - c.stop > RETRY_MS) continue
|
|
181
|
+
out.push({ failed: c, next, blind: next.cmd.trim() === c.cmd.trim() })
|
|
182
|
+
}
|
|
183
|
+
return out.reverse()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** a call that ran longer than this before a complaint is not looked for */
|
|
187
|
+
const LOOKBACK_MS = 3 * 60 * 60 * 1000
|
|
188
|
+
|
|
189
|
+
/** what the agent ran in the minutes before `at`: calls in flight at that
|
|
190
|
+
* moment or started in the window, cut at `at`, longest first. `calls`
|
|
191
|
+
* sorted by start (closeCalls) */
|
|
192
|
+
export function ranBefore(calls: Closed[], at: number, windowMs = 10 * 60 * 1000): Closed[] {
|
|
193
|
+
let lo = 0
|
|
194
|
+
let hi = calls.length
|
|
195
|
+
while (lo < hi) {
|
|
196
|
+
const mid = (lo + hi) >> 1
|
|
197
|
+
if ((calls[mid] as Closed).start <= at) lo = mid + 1
|
|
198
|
+
else hi = mid
|
|
199
|
+
}
|
|
200
|
+
const out: Closed[] = []
|
|
201
|
+
for (let i = lo - 1; i >= 0; i--) {
|
|
202
|
+
const c = calls[i] as Closed
|
|
203
|
+
if (c.start < at - LOOKBACK_MS) break
|
|
204
|
+
if (c.stop >= at - windowMs) out.push({ ...c, stop: Math.min(c.stop, at) })
|
|
205
|
+
}
|
|
206
|
+
return out.sort((a, b) => callMs(b) - callMs(a))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** p in [0, 1] of a list of ms, nearest rank */
|
|
210
|
+
export function quantile(xs: number[], p: number): number {
|
|
211
|
+
return quantileSorted([...xs].sort((a, b) => a - b), p)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** the same over a list already sorted ascending */
|
|
215
|
+
export function quantileSorted(s: number[], p: number): number {
|
|
216
|
+
if (s.length === 0) return 0
|
|
217
|
+
return s[Math.min(s.length - 1, Math.max(0, Math.ceil(p * s.length) - 1))] ?? 0
|
|
218
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Turn count: what makes an agent slow when no command is. Every tool call
|
|
2
|
+
// costs a model turn before it, so an agent that reads one file per turn
|
|
3
|
+
// spends its wall on the model, not on the reads. The two questions this
|
|
4
|
+
// module answers on pure data: was a call a single read, and did it go out in
|
|
5
|
+
// parallel with the one before it.
|
|
6
|
+
//
|
|
7
|
+
// The constraint a reader breaks first: a read is judged on the WHOLE command,
|
|
8
|
+
// never on its shape alone. `grep -l x | xargs sed -i` shapes as `grep`, and
|
|
9
|
+
// it writes.
|
|
10
|
+
import { stripHeredocs } from './shape.ts'
|
|
11
|
+
import type { Closed } from './timeline.ts'
|
|
12
|
+
|
|
13
|
+
/** bumped with any read rule below: the index judges every stored call again */
|
|
14
|
+
export const READ_VERSION = 1
|
|
15
|
+
|
|
16
|
+
/** tools that only read */
|
|
17
|
+
export const READ_TOOLS: ReadonlySet<string> = new Set(['Read', 'Grep', 'Glob'])
|
|
18
|
+
|
|
19
|
+
/** Bash shapes that only read (shape.ts names them). `sed` without `-i` prints;
|
|
20
|
+
* `sed -i` is shaped `sed -i (edit)` and `cat > f` is `cat > (write)`, so
|
|
21
|
+
* neither is here */
|
|
22
|
+
export const READ_SHAPES: ReadonlySet<string> = new Set(['grep', 'rg', 'git grep', 'sed', 'cat', 'ls', 'head', 'tail', 'wc', 'find', 'git status', 'git log', 'git diff', 'git show'])
|
|
23
|
+
|
|
24
|
+
/** words a read command may carry beside its read: scene setting, loop and
|
|
25
|
+
* test words, a filter it pipes into, a print */
|
|
26
|
+
const BESIDE = new Set([
|
|
27
|
+
'cd', 'pushd', 'popd', 'export', 'set', 'true', ':', 'time', 'for', 'do', 'done', 'while', 'if', 'then', 'else', 'fi', '[', '[[', 'test',
|
|
28
|
+
'echo', 'printf', 'pwd', 'uptime', 'date', 'which', 'basename', 'dirname', 'realpath', 'stat', 'file', 'du', 'tree', 'diff',
|
|
29
|
+
'sort', 'uniq', 'cut', 'tr', 'nl', 'column', 'jq', 'awk', 'xargs', 'grep', 'egrep', 'rg', 'head', 'tail', 'wc', 'cat', 'sed', 'ls', 'find', 'git',
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
/** a find action that writes or runs something */
|
|
33
|
+
const FIND_ACTS = /\s-(?:delete|exec|execdir|ok|okdir|fprint0?|fprintf|fls)\b/
|
|
34
|
+
|
|
35
|
+
/** git subcommands that only read */
|
|
36
|
+
const GIT_READS = new Set(['status', 'log', 'diff', 'show', 'grep', 'ls-files', 'ls-tree', 'rev-parse', 'rev-list', 'merge-base', 'cat-file', 'describe', 'shortlog', 'branch', 'blame'])
|
|
37
|
+
|
|
38
|
+
/** the parts of a command line: statements and every pipeline part, quoted
|
|
39
|
+
* text blanked (a pattern is never a separator nor a redirect) */
|
|
40
|
+
const parts = (command: string): string[] =>
|
|
41
|
+
(command.includes('<<') ? stripHeredocs(command) : command)
|
|
42
|
+
.replace(/\\\n/g, ' ')
|
|
43
|
+
.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, '""')
|
|
44
|
+
.split(/&&|\|\||[;\n|]|(?<![>&])&(?![>&])/)
|
|
45
|
+
|
|
46
|
+
/** an output redirect to a file: `> f`, `>> f`, `&> f`; `2>&1` and
|
|
47
|
+
* `> /dev/null` write nothing */
|
|
48
|
+
const writesFile = (part: string): boolean => {
|
|
49
|
+
for (const m of part.matchAll(/>{1,2}\s*([^\s>]*)/g)) {
|
|
50
|
+
const target = m[1] ?? ''
|
|
51
|
+
if (target === '' || target.startsWith('&') || target === '/dev/null' || target.startsWith('/dev/std')) continue
|
|
52
|
+
return true
|
|
53
|
+
}
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** the words of a part after env assignments and a leading `X=$(` */
|
|
58
|
+
const HEAD = /^[\s(]*(?:[A-Za-z_][A-Za-z0-9_]*=(?:\$\(|\S*\s+))*(\S+)\s*(.*)$/s
|
|
59
|
+
|
|
60
|
+
/** one part of the line reads only */
|
|
61
|
+
function partReads(part: string, whole: string): boolean {
|
|
62
|
+
if (writesFile(part)) return false
|
|
63
|
+
if (/^\s*[A-Za-z_][A-Za-z0-9_]*=\S*\s*$/.test(part)) return true
|
|
64
|
+
const m = HEAD.exec(part)
|
|
65
|
+
if (m == null) return true
|
|
66
|
+
const head = (m[1] as string).replace(/^.*\//, '').replace(/\)+$/, '')
|
|
67
|
+
const rest = ` ${m[2] ?? ''}`
|
|
68
|
+
if (!BESIDE.has(head)) return false
|
|
69
|
+
if (head === 'sed') return !/\s-[a-zA-Z]*i|\s--in-place/.test(rest)
|
|
70
|
+
if (head === 'find') return !FIND_ACTS.test(rest)
|
|
71
|
+
if (head === 'awk') return !/system\(|print[^;'"]*>/.test(whole)
|
|
72
|
+
if (head === 'xargs') return partReads(rest.replace(/^(?:\s+-\S+(?:\s+\d+)?)*/, ''), whole)
|
|
73
|
+
if (head === 'git') return GIT_READS.has(/^(?:\s+-[Cc]\s+\S+|\s+-\S+)*\s+(\S+)/.exec(rest)?.[1] ?? '')
|
|
74
|
+
if (head === 'do' || head === 'then' || head === 'else') return rest.trim() === '' || partReads(rest, whole)
|
|
75
|
+
return true
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** the call only read: a read tool, or a Bash command of a read shape (a for
|
|
79
|
+
* loop over one included) whose every part reads */
|
|
80
|
+
export function isReadOnly(c: { tool: string; shape: string; cmd: string }): boolean {
|
|
81
|
+
if (READ_TOOLS.has(c.tool)) return true
|
|
82
|
+
if (c.tool !== 'Bash' || !READ_SHAPES.has(c.shape.replace(/^\(for\) /, ''))) return false
|
|
83
|
+
return parts(c.cmd).every((p) => p.trim() === '' || partReads(p, c.cmd))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** a call issued this soon after the one before, while that one still ran,
|
|
87
|
+
* went out in the same turn */
|
|
88
|
+
export const PARALLEL_MS = 2000
|
|
89
|
+
|
|
90
|
+
/** one flag per call (sorted by start, closeCalls): it started before the
|
|
91
|
+
* previous call ended, within PARALLEL_MS of its start */
|
|
92
|
+
export function parallelFlags(calls: readonly Closed[]): boolean[] {
|
|
93
|
+
return calls.map((c, i) => {
|
|
94
|
+
const prev = calls[i - 1]
|
|
95
|
+
return prev != null && c.start < prev.stop && c.start - prev.start <= PARALLEL_MS
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** the turn metrics of a set of streams */
|
|
100
|
+
export type TurnStats = { calls: number; reads: number; parallel: number; model: number }
|
|
101
|
+
|
|
102
|
+
export const emptyTurns = (): TurnStats => ({ calls: 0, reads: 0, parallel: 0, model: 0 })
|
|
103
|
+
|
|
104
|
+
export const addTurns = (into: TurnStats, t: TurnStats): TurnStats => {
|
|
105
|
+
into.calls += t.calls
|
|
106
|
+
into.reads += t.reads
|
|
107
|
+
into.parallel += t.parallel
|
|
108
|
+
into.model += t.model
|
|
109
|
+
return into
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** what a read shape costs in model turns: its calls, and the model time of
|
|
113
|
+
* the turns that issued them */
|
|
114
|
+
export type ReadCost = { shape: string; calls: number; model: number }
|
|
115
|
+
|
|
116
|
+
/** a read call's name in the cost table: its tool, or its Bash shape */
|
|
117
|
+
export const readName = (c: { tool: string; shape: string }): string => (c.tool === 'Bash' ? c.shape : c.tool)
|
|
118
|
+
|
|
119
|
+
/** the turns of one stream: `model` is its model bucket, `turnModel` the
|
|
120
|
+
* per call share wallBreakdown filled; read calls add to `costs` */
|
|
121
|
+
export function turnsOf(calls: readonly Closed[], model: number, turnModel: Float64Array, costs: Map<string, ReadCost>): TurnStats {
|
|
122
|
+
const par = parallelFlags(calls)
|
|
123
|
+
const t: TurnStats = { calls: calls.length, reads: 0, parallel: 0, model }
|
|
124
|
+
calls.forEach((c, i) => {
|
|
125
|
+
if (par[i]) t.parallel++
|
|
126
|
+
if (!c.read) return
|
|
127
|
+
t.reads++
|
|
128
|
+
const name = readName(c)
|
|
129
|
+
const row = costs.get(name) ?? { shape: name, calls: 0, model: 0 }
|
|
130
|
+
row.calls++
|
|
131
|
+
row.model += turnModel[i] ?? 0
|
|
132
|
+
costs.set(name, row)
|
|
133
|
+
})
|
|
134
|
+
return t
|
|
135
|
+
}
|
|
@@ -15,6 +15,7 @@ import { mkdirSync } from 'fs'
|
|
|
15
15
|
import { dirname, join } from 'path'
|
|
16
16
|
import sharp from 'sharp'
|
|
17
17
|
import { defineKind, type CanvasGen, type ComfyGen, type Kind } from '../kit-config.ts'
|
|
18
|
+
import { HOUSE_FAMILIES, type TrailerLookInput } from '../../trailer/look.ts'
|
|
18
19
|
|
|
19
20
|
export type Brand = {
|
|
20
21
|
name: string
|
|
@@ -29,6 +30,9 @@ export type Brand = {
|
|
|
29
30
|
/** og layout hook — replaces the WHOLE default card (bg + mark left +
|
|
30
31
|
* name/tagline right); the hook owns compositing the mark if it wants one */
|
|
31
32
|
drawOg?: (ctx: SKRSContext2D, brand: Brand, size: { w: number; h: number }) => void
|
|
33
|
+
/** the look every trailer of the repo wears (titles, card, face, logo);
|
|
34
|
+
* each value left out is derived from the palette (trailer/look.ts) */
|
|
35
|
+
trailer?: TrailerLookInput
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
export const FAVICON_SIZE = 512
|
|
@@ -52,9 +56,9 @@ export function identityKinds(brand: Brand): Kind[] {
|
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
// named faces first: the generic sans-serif can resolve to a face with no
|
|
55
|
-
// Latin-1 accents (macOS). Each family
|
|
56
|
-
//
|
|
57
|
-
const OG_FAMILIES =
|
|
59
|
+
// Latin-1 accents (macOS). Each family carries them, and an absent one is
|
|
60
|
+
// skipped (macOS, Windows, then the common Linux faces)
|
|
61
|
+
const OG_FAMILIES = HOUSE_FAMILIES
|
|
58
62
|
export const ogFont = (px: number, bold = false): string => `${bold ? 'bold ' : ''}${px}px ${OG_FAMILIES}`
|
|
59
63
|
|
|
60
64
|
// shrink px until text fits maxW (og cards must never clip the tagline)
|
package/src/assets/run-assets.ts
CHANGED
|
@@ -122,14 +122,28 @@ async function repoWebRoot(root: string): Promise<string> {
|
|
|
122
122
|
|
|
123
123
|
export type IdentityRender = { written: string[] } | { blocked: string }
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
/** the repo's brand, `.shipkit/identity/brand.ts`: null when the file is
|
|
126
|
+
* absent, a string saying why when it does not default-export one */
|
|
127
|
+
export async function loadBrand(root: string): Promise<Brand | null | string> {
|
|
126
128
|
const brandPath = join(root, '.shipkit/identity/brand.ts')
|
|
127
|
-
if (!existsSync(brandPath)) return
|
|
129
|
+
if (!existsSync(brandPath)) return null
|
|
128
130
|
// cache-bust: bun caches dynamic imports for the process lifetime — a
|
|
129
131
|
// long-running hub must re-render the EDITED brand, not the cached one
|
|
130
132
|
const mod = (await import(`${brandPath}?v=${await brandHash(root)}`)) as { default?: Brand }
|
|
131
|
-
|
|
132
|
-
|
|
133
|
+
return mod.default ?? '.shipkit/identity/brand.ts must default-export the Brand'
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** the rendered logo of the identity set, repo relative, when it exists */
|
|
137
|
+
export async function identityLogo(root: string): Promise<string | null> {
|
|
138
|
+
const web = await repoWebRoot(root)
|
|
139
|
+
const file = web === '.' ? 'logo.png' : `${web}/logo.png`
|
|
140
|
+
return existsSync(join(root, file)) ? file : null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function renderIdentityNow(root: string): Promise<IdentityRender> {
|
|
144
|
+
const brand = await loadBrand(root)
|
|
145
|
+
if (brand == null) return { blocked: 'no .shipkit/identity/brand.ts — create one exporting a Brand (name, palette, mark)' }
|
|
146
|
+
if (typeof brand === 'string') return { blocked: brand }
|
|
133
147
|
let markFile: string | undefined
|
|
134
148
|
if (brand.mark.backend === 'comfy') {
|
|
135
149
|
// comfy marks render from the PICKED identity-logo roll — the kit
|
|
@@ -32,6 +32,10 @@ const CONTRIB = '# repo-contributed lines (repo.config.ts gitignore:)'
|
|
|
32
32
|
|
|
33
33
|
const DRAFTS_BACK = '!.comfy-ts/drafts/'
|
|
34
34
|
|
|
35
|
+
/** re-includes a private repo carries and a declared public one drops: a
|
|
36
|
+
* draft names private loras, a plan or a board quotes the owner */
|
|
37
|
+
const PRIVATE_ONLY: ReadonlySet<string> = new Set([DRAFTS_BACK, `!${LAYOUT.plans}/`])
|
|
38
|
+
|
|
35
39
|
export const POLICY_LINES = [
|
|
36
40
|
// everything shipkit keeps lives under .shipkit/ (core/layout.ts). Local
|
|
37
41
|
// by default: the private notes, drafts, venue listings and caches, and the
|
|
@@ -45,12 +49,13 @@ export const POLICY_LINES = [
|
|
|
45
49
|
// asks git about it: a negation below the block that exposed the keyword
|
|
46
50
|
// list would otherwise pass as a venue override
|
|
47
51
|
`${LAYOUT.private}/`,
|
|
48
|
-
// the scratch and the review files, spelled out for the same reason:
|
|
49
|
-
//
|
|
52
|
+
// the scratch and the review files, spelled out for the same reason:
|
|
53
|
+
// nothing durable lives in the scratch, and a review narrates agent process
|
|
50
54
|
`${LAYOUT.tmp}/`,
|
|
51
55
|
`${LAYOUT.reviews}/`,
|
|
52
56
|
// a root scratch stays out of git too: the fix pass moves it under
|
|
53
|
-
// .shipkit/,
|
|
57
|
+
// .shipkit/, its dated plans into .shipkit/plans/, and until it runs
|
|
58
|
+
// nothing there is exposed
|
|
54
59
|
`${LEGACY_TMP}/`,
|
|
55
60
|
'CLAUDE.local.md',
|
|
56
61
|
// atomicWrite lands every file via `.<name>.tmp-<pid>` next to its target,
|
|
@@ -74,9 +79,9 @@ export const POLICY_LINES = [
|
|
|
74
79
|
]
|
|
75
80
|
|
|
76
81
|
/** the policy one repo carries. A declared public tree ships to strangers,
|
|
77
|
-
* so
|
|
78
|
-
*
|
|
79
|
-
const policyLines = (config: RepoConfig): string[] => declaredPublic(config) ? POLICY_LINES.filter((l) => l
|
|
82
|
+
* so its drafts and plans stay ignored there: authored state in a private
|
|
83
|
+
* repo, private words in a public one */
|
|
84
|
+
const policyLines = (config: RepoConfig): string[] => declaredPublic(config) ? POLICY_LINES.filter((l) => !PRIVATE_ONLY.has(l)) : POLICY_LINES
|
|
80
85
|
|
|
81
86
|
const LEGACY = '# the old root layout, ignored until the fix pass moves it under .shipkit/'
|
|
82
87
|
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// scaffold, so a policy written there would bind exactly one checkout.
|
|
15
15
|
import { isAbsolute, join } from 'path'
|
|
16
16
|
import { inferBin } from '../core/canonical-scripts.ts'
|
|
17
|
-
import { denyRules,
|
|
17
|
+
import { denyRules, GIT_STASH_RULES, LEGACY_STASH_RULE, RAW_RUNNERS } from '../core/one-way-in-policy.ts'
|
|
18
18
|
import { rootPublishesNpm } from '../core/repo-facts.ts'
|
|
19
19
|
import type { RepoConfig } from '../repo-config.ts'
|
|
20
20
|
import { repoOff } from '../core/when.ts'
|
|
@@ -139,9 +139,12 @@ export const oneWayInPolicy = check({
|
|
|
139
139
|
return
|
|
140
140
|
}
|
|
141
141
|
const have = new Set(s.permissions?.deny ?? [])
|
|
142
|
-
const missing = want.filter((r) => !have.has(r) && r
|
|
143
|
-
|
|
144
|
-
|
|
142
|
+
const missing = want.filter((r) => !have.has(r) && !GIT_STASH_RULES.includes(r))
|
|
143
|
+
const stashMissing = GIT_STASH_RULES.filter((r) => !have.has(r))
|
|
144
|
+
if (stashMissing.length > 0)
|
|
145
|
+
c.fail(`${SETTINGS_PATH}: ${stashMissing.length} stash deny row(s) missing (${stashMissing.slice(0, 2).join(', ')}${stashMissing.length > 2 ? ', …' : ''}), so an agent can stash every other agent's uncommitted work in a shared tree`)
|
|
146
|
+
if (have.has(LEGACY_STASH_RULE))
|
|
147
|
+
c.fail(`${SETTINGS_PATH}: the blanket "${LEGACY_STASH_RULE}" row also refuses the read only \`git stash list\` and \`git stash show\``)
|
|
145
148
|
if (missing.length > 0)
|
|
146
149
|
c.fail(`${SETTINGS_PATH}: ${missing.length} raw runner(s) still permitted (${missing.slice(0, 3).join(', ')}${missing.length > 3 ? ', …' : ''}) — those calls never reach shipkit, so nothing is cached and no run can be attached to`)
|
|
147
150
|
const guard = guardHookCommand(s)
|
|
@@ -172,7 +175,7 @@ export const oneWayInPolicy = check({
|
|
|
172
175
|
// replacement is named while the guard cannot run is the green that
|
|
173
176
|
// measured nothing, one line under the WARN that says otherwise
|
|
174
177
|
if (!c.failed)
|
|
175
|
-
c.ok(`${SETTINGS_PATH} closes ${want.length -
|
|
178
|
+
c.ok(`${SETTINGS_PATH} closes ${want.length - GIT_STASH_RULES.length}/${RAW_RUNNERS.length} raw runner(s) and refuses git stash${guardRuns ? ', with the replacement named' : ' (the guard that names the replacement is declared, not yet runnable)'}`)
|
|
176
179
|
},
|
|
177
180
|
// MERGES, never rewrites: this file also carries the attribution silencer,
|
|
178
181
|
// a repo's own allow list and its own hooks, and clobbering those to fix a
|
|
@@ -185,11 +188,13 @@ export const oneWayInPolicy = check({
|
|
|
185
188
|
if (existing == null && c.exists(SETTINGS_PATH)) return { changed: false, summary: `${SETTINGS_PATH} is unparseable — repair it by hand first, a merge would destroy it` }
|
|
186
189
|
const s: Settings = existing ?? {}
|
|
187
190
|
const changes: string[] = []
|
|
188
|
-
const
|
|
191
|
+
const old = s.permissions?.deny ?? []
|
|
192
|
+
const deny = old.filter((r) => r !== LEGACY_STASH_RULE)
|
|
193
|
+
if (deny.length < old.length) changes.push(`the blanket ${LEGACY_STASH_RULE} row replaced by one row per stash write`)
|
|
189
194
|
const added = want.filter((r) => !deny.includes(r))
|
|
190
|
-
if (added.length > 0) {
|
|
195
|
+
if (added.length > 0 || deny.length < old.length) {
|
|
191
196
|
s.permissions = { ...s.permissions, deny: [...deny, ...added] }
|
|
192
|
-
changes.push(`${added.length} deny rule(s) added`)
|
|
197
|
+
if (added.length > 0) changes.push(`${added.length} deny rule(s) added`)
|
|
193
198
|
}
|
|
194
199
|
const wantGuard = await guardCommandFor(d)
|
|
195
200
|
if (!hasGuardHook(s)) {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// `<cli> calls`: the call log (cli/kit/calls.ts) read back. shipkit logs every
|
|
2
|
+
// run machine wide, because one sk serves every repo; a project CLI logs under
|
|
3
|
+
// its own root. One command class for both: only the log file differs.
|
|
4
|
+
import { Command, Option } from 'clipanion'
|
|
5
|
+
import { homedir } from 'os'
|
|
6
|
+
import { callsFile, callsReport, envPrefix, renderCallsReport, sinceOf } from './kit/calls.ts'
|
|
7
|
+
|
|
8
|
+
export class CallsCommand extends Command {
|
|
9
|
+
static override paths = [['calls']]
|
|
10
|
+
|
|
11
|
+
static override usage = Command.Usage({
|
|
12
|
+
category: 'Health',
|
|
13
|
+
description: 'every sk run logged with its time: per command how often, how long, and the slowest runs',
|
|
14
|
+
details: 'reads ~/.shipkit/logs/sk-calls.jsonl, where every sk run writes a start line and an end line (time, cpu, memory, exit code). A run with no end line was killed or still runs. Runs another sk spawned are left out unless --nested. SK_LOG=off writes nothing; SK_AGENT names who ran it.',
|
|
15
|
+
examples: [
|
|
16
|
+
['what agents waited on today', 'sk calls --since 1d'],
|
|
17
|
+
['one command family', 'sk calls --cmd ci'],
|
|
18
|
+
['only the runs started in this repo', 'sk calls --here'],
|
|
19
|
+
['as json', 'sk calls --json'],
|
|
20
|
+
],
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
since = Option.String('--since', { description: '30m, 2h, 3d or a date: only the runs that started after' })
|
|
24
|
+
cmd = Option.String('--cmd', { description: 'a command path prefix' })
|
|
25
|
+
top = Option.String('--top', '10', { description: 'how many slowest runs to list' })
|
|
26
|
+
nested = Option.Boolean('--nested', false, { description: 'keep the runs another run spawned' })
|
|
27
|
+
here = Option.Boolean('--here', false, { description: 'only the runs started inside the current folder' })
|
|
28
|
+
json = Option.Boolean('--json', false, { description: 'the report as json' })
|
|
29
|
+
|
|
30
|
+
/** the log this command reads */
|
|
31
|
+
logFile(): string {
|
|
32
|
+
return callsFile(homedir(), 'sk')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async execute(): Promise<number> {
|
|
36
|
+
const bin = this.cli.binaryName
|
|
37
|
+
const file = this.logFile()
|
|
38
|
+
const since = this.since == null ? undefined : sinceOf(this.since, Date.now())
|
|
39
|
+
if (since === null) { this.context.stderr.write(`FAIL: --since ${this.since}: 30m, 2h, 3d or a date\n`); return 2 }
|
|
40
|
+
const { existsSync, readFileSync } = await import('fs')
|
|
41
|
+
if (!existsSync(file)) { this.context.stdout.write(`no call yet: ${file}\n`); return 0 }
|
|
42
|
+
const r = callsReport(readFileSync(file, 'utf8'), {
|
|
43
|
+
since,
|
|
44
|
+
cmd: this.cmd,
|
|
45
|
+
top: Number(this.top),
|
|
46
|
+
nested: this.nested,
|
|
47
|
+
under: this.here ? process.cwd() : undefined,
|
|
48
|
+
self: process.env[`${envPrefix(bin)}_CALL`],
|
|
49
|
+
})
|
|
50
|
+
this.context.stdout.write(this.json ? `${JSON.stringify({ file, ...r })}\n` : renderCallsReport(bin, r, file))
|
|
51
|
+
return 0
|
|
52
|
+
}
|
|
53
|
+
}
|