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,70 @@
|
|
|
1
|
+
// the review store and the trailer cuts as tree rows (wiki build/shipkit/review.md). Under the review section, one row a
|
|
2
|
+
// card that waits on HIM (the owner's verdict, his answer to a question): yellow, so `sk needs` lists it too. Under the trailer
|
|
3
|
+
// section, one row a cut, its open frame notes in the slot: they wait on agents, never on him, so a cut is never yellow.
|
|
4
|
+
// Each collection exists only where the repo has its folder, so a repo with none draws the section greyed
|
|
5
|
+
import { existsSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { STATUS_LABEL, isFrame, stateOf } from '../review/model.ts'
|
|
8
|
+
import { readAllCached, storeDir } from '../review/store.ts'
|
|
9
|
+
import { statusOf, waitingOnOwner } from '../review/threads.ts'
|
|
10
|
+
import { cutsOf, trailerIds } from '../review/trailer-desk.ts'
|
|
11
|
+
import { REVIEW_COL, TRAILER_COL, type Collection, type Instance } from './collection-types.ts'
|
|
12
|
+
|
|
13
|
+
export function reviewCollection(root: string): Collection | null {
|
|
14
|
+
const dir = storeDir(root)
|
|
15
|
+
if (!existsSync(dir)) return null
|
|
16
|
+
return {
|
|
17
|
+
id: REVIEW_COL,
|
|
18
|
+
title: 'review',
|
|
19
|
+
kind: 'card',
|
|
20
|
+
media: 'image',
|
|
21
|
+
list: async () => {
|
|
22
|
+
const { items } = readAllCached(dir)
|
|
23
|
+
return waitingOnOwner(items).map(({ head: x }): Instance => ({
|
|
24
|
+
id: `card:${x.item.id}`,
|
|
25
|
+
text: `${x.item.title} (${STATUS_LABEL[statusOf(x, items)]})`,
|
|
26
|
+
label: x.item.title,
|
|
27
|
+
slot: x.item.origin.by === 'agent' ? (x.item.origin.lane ?? 'card') : 'question',
|
|
28
|
+
type: 'card',
|
|
29
|
+
path: `.shipkit/review/${x.item.id}`,
|
|
30
|
+
severity: 'yellow',
|
|
31
|
+
verbs: ['approve'],
|
|
32
|
+
}))
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function trailersCollection(root: string): Collection | null {
|
|
38
|
+
const ids = trailerIds(root)
|
|
39
|
+
if (ids.length === 0) return null
|
|
40
|
+
return {
|
|
41
|
+
id: TRAILER_COL,
|
|
42
|
+
title: 'trailer',
|
|
43
|
+
kind: 'cut',
|
|
44
|
+
media: 'image',
|
|
45
|
+
list: async () => {
|
|
46
|
+
const dir = storeDir(root)
|
|
47
|
+
const items = existsSync(dir) ? readAllCached(dir).items : []
|
|
48
|
+
const open = new Map<string, number>()
|
|
49
|
+
for (const x of items) {
|
|
50
|
+
const s = x.item.subject
|
|
51
|
+
if (isFrame(s) && stateOf(x, false).state !== 'closed') open.set(`${s.trailer}/${s.cut}`, (open.get(`${s.trailer}/${s.cut}`) ?? 0) + 1)
|
|
52
|
+
}
|
|
53
|
+
return ids.flatMap((id) =>
|
|
54
|
+
cutsOf(join(root, 'trailer', id)).map((c): Instance => {
|
|
55
|
+
const n = open.get(`${id}/${c.cut}`) ?? 0
|
|
56
|
+
const rendered = existsSync(join(root, 'trailer', id, 'out', `${c.name}.mp4`))
|
|
57
|
+
return {
|
|
58
|
+
id: `cut:${id}/${c.cut}`,
|
|
59
|
+
text: `${id} ${c.cut}${rendered ? '' : ' (not rendered)'}${n > 0 ? `, ${n} open note${n > 1 ? 's' : ''}` : ''}`,
|
|
60
|
+
label: ids.length > 1 ? `${id} · ${c.cut}` : c.cut,
|
|
61
|
+
slot: n > 0 ? `${n} open` : rendered ? '' : 'no render',
|
|
62
|
+
type: 'cut',
|
|
63
|
+
path: c.trailer === null ? `trailer/${id}/out/${c.name}.mp4` : `trailer/${id}/${c.cut}.trailer`,
|
|
64
|
+
state: rendered ? 'idle' : 'quiet',
|
|
65
|
+
}
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/core/layout.ts
CHANGED
|
@@ -22,6 +22,9 @@ export const LAYOUT = {
|
|
|
22
22
|
// a bug's repro test once its fix lands: kept for the record, never run
|
|
23
23
|
// (bun test skips dot dirs), moved into the suite only when it earns its wait
|
|
24
24
|
repros: sk('repros'),
|
|
25
|
+
// plans, arc boards and lane files: live state a crashed session resumes
|
|
26
|
+
// from. Tracked in a private repo only, since a board quotes the owner
|
|
27
|
+
plans: sk('plans'),
|
|
25
28
|
// local: drafts, private notes, venue listings until they go live
|
|
26
29
|
private: sk('private'),
|
|
27
30
|
reflections: sk('reflections'),
|
|
@@ -33,8 +36,12 @@ export const LAYOUT = {
|
|
|
33
36
|
discord: sk('discord'),
|
|
34
37
|
// a review with no output file named by its caller (YYYYMMDD-HHMMSS-<name>.md)
|
|
35
38
|
reviews: sk('reviews'),
|
|
36
|
-
//
|
|
39
|
+
// the one feedback store: review cards and frame notes, one folder an item (review/store.ts)
|
|
40
|
+
review: sk('review'),
|
|
41
|
+
// gitignored scratch, never a plan home: logs, leases, detached runs, screenshots
|
|
37
42
|
tmp: sk('tmp'),
|
|
43
|
+
// a CLI's call log, `<cli>-calls.jsonl`: local, never tracked
|
|
44
|
+
logs: sk('logs'),
|
|
38
45
|
// built app bundles, one per global process opened as an app (home/gui-app.ts)
|
|
39
46
|
wrapper: sk('wrapper'),
|
|
40
47
|
// generated on every run
|
|
@@ -44,7 +51,10 @@ export const LAYOUT = {
|
|
|
44
51
|
} as const
|
|
45
52
|
|
|
46
53
|
/** the children the SK004 block re-includes: everything else under `.shipkit/` stays local */
|
|
47
|
-
export const TRACKED_DIRS = [LAYOUT.asks, LAYOUT.hooks, LAYOUT.identity, LAYOUT.assets, LAYOUT.comfy, LAYOUT.repros] as const
|
|
54
|
+
export const TRACKED_DIRS = [LAYOUT.asks, LAYOUT.hooks, LAYOUT.identity, LAYOUT.assets, LAYOUT.comfy, LAYOUT.repros, LAYOUT.plans] as const
|
|
55
|
+
|
|
56
|
+
/** a plan file name: `YYYYMMDD-HHMMSS-<slug>.md` */
|
|
57
|
+
export const PLAN_FILE = /^\d{8}-\d{6}-.+\.md$/
|
|
48
58
|
|
|
49
59
|
/** the machine-global private folder, under $HOME: one keyword list and one
|
|
50
60
|
* allowlist per machine, never copied into a repo */
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// repair a contract folder typed under a wrong name, in ONE pass and ONE commit.
|
|
4
4
|
//
|
|
5
5
|
// Nothing here is a decision, so no human is asked: `.rv-<x>/` became
|
|
6
|
-
// `.shipkit/<x>/`, `asks/`, `hooks/` and the `tmp/` scratch moved in
|
|
7
|
-
// generated files moved out of the root. A typo (`rv-reflections`, `.rv-reflection`) is the same
|
|
6
|
+
// `.shipkit/<x>/`, `asks/`, `hooks/` and the `tmp/` scratch moved in (its dated
|
|
7
|
+
// plans into `.shipkit/plans/`), the generated files moved out of the root. A typo (`rv-reflections`, `.rv-reflection`) is the same
|
|
8
8
|
// move, because a folder under a name no scanner reads is silence.
|
|
9
9
|
//
|
|
10
10
|
// The invariant a reader breaks first: A MOVE NEVER CHANGES WHAT GIT TRACKS.
|
|
@@ -27,7 +27,7 @@ import { dirname, join, posix } from 'path'
|
|
|
27
27
|
import type { RepoConfig } from '../repo-config.ts'
|
|
28
28
|
import { gitBin } from './git-bin.ts'
|
|
29
29
|
import { probeGitRoot } from './git-root.ts'
|
|
30
|
-
import { LAYOUT, MACHINE_PRIVATE, SHIPKIT_DIR } from './layout.ts'
|
|
30
|
+
import { LAYOUT, MACHINE_PRIVATE, PLAN_FILE, SHIPKIT_DIR } from './layout.ts'
|
|
31
31
|
import { machineCommit } from './machine-commit.ts'
|
|
32
32
|
import { nestedIn } from './nesting.ts'
|
|
33
33
|
|
|
@@ -41,9 +41,11 @@ const CONFIG_FILE = 'repo.config.ts'
|
|
|
41
41
|
* hole where the list should be */
|
|
42
42
|
export function layoutSummary(r: { moved: LayoutMove[]; removed: string[] }): string {
|
|
43
43
|
const list = (xs: string[]): string => (xs.length <= 1 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs.at(-1)}`)
|
|
44
|
-
const folders = r.moved.filter((m) => m.kind !== 'cache').map((m) => `${m.from}/`)
|
|
44
|
+
const folders = r.moved.filter((m) => m.kind !== 'cache' && m.kind !== 'plan').map((m) => `${m.from}/`)
|
|
45
|
+
const plans = r.moved.filter((m) => m.kind === 'plan').length
|
|
45
46
|
const parts = [
|
|
46
47
|
...(folders.length > 0 ? [`move ${list(folders)} under ${SHIPKIT_DIR}/`] : []),
|
|
48
|
+
...(plans > 0 ? [`file ${plans} plan${plans === 1 ? '' : 's'} under ${LAYOUT.plans}/`] : []),
|
|
47
49
|
...(r.removed.length > 0 ? [`drop the root ${list(r.removed)}, which shipkit writes under ${SHIPKIT_DIR}/`] : []),
|
|
48
50
|
]
|
|
49
51
|
return parts.join(', and ')
|
|
@@ -76,7 +78,8 @@ const shapeOf = (s: string): string => s.toLowerCase().replace(/^\.+/, '').repla
|
|
|
76
78
|
|
|
77
79
|
const LEGACY_NAME = /^\.rv-([a-z0-9][a-z0-9-]*)$/
|
|
78
80
|
|
|
79
|
-
|
|
81
|
+
/** 'plan' moves one FILE, every other kind a folder */
|
|
82
|
+
export type MoveKind = 'contract' | 'typo' | 'asks' | 'hooks' | 'cache' | 'tmp' | 'plan'
|
|
80
83
|
/** a move onto a folder that already exists, whose files collide nowhere.
|
|
81
84
|
* `drop`: stale leases left behind rather than moved over the live ones */
|
|
82
85
|
export type MergeMove = LayoutMove & { merge: true; drop?: string[] }
|
|
@@ -97,6 +100,10 @@ export type PlanInput = {
|
|
|
97
100
|
legacyCache?: boolean
|
|
98
101
|
/** a clone or a worktree sits somewhere under `tmp/` */
|
|
99
102
|
tmpHoldsRepo?: boolean
|
|
103
|
+
/** file names at the top of `tmp/` */
|
|
104
|
+
tmpFiles?: string[]
|
|
105
|
+
/** names already under `.shipkit/plans/` */
|
|
106
|
+
planEntries?: string[]
|
|
100
107
|
}
|
|
101
108
|
|
|
102
109
|
/** a `.git` anywhere under a folder: a clone or a linked worktree, whose
|
|
@@ -146,7 +153,15 @@ export function planLayoutMigration(i: PlanInput): LayoutPlan {
|
|
|
146
153
|
// every repo's scratch, shipkit's own logs and the repo's plans alike
|
|
147
154
|
if (i.rootDirs.includes(LEGACY_TMP)) {
|
|
148
155
|
if (i.tmpHoldsRepo === true) refusals.push({ from: LEGACY_TMP, to: LAYOUT.tmp, reason: 'a git checkout sits under it, and a moved worktree loses its registration' })
|
|
149
|
-
else
|
|
156
|
+
else {
|
|
157
|
+
// a dated plan is live state, so it lands where plans live, never in
|
|
158
|
+
// the scratch any agent may empty. A name already taken there stays
|
|
159
|
+
// with the scratch rather than overwrite a plan
|
|
160
|
+
const taken = new Set(i.planEntries ?? [])
|
|
161
|
+
for (const f of (i.tmpFiles ?? []).filter((f) => PLAN_FILE.test(f) && !taken.has(f)).sort())
|
|
162
|
+
moves.push({ from: `${LEGACY_TMP}/${f}`, to: `${LAYOUT.plans}/${f}`, kind: 'plan' })
|
|
163
|
+
add(LEGACY_TMP, LAYOUT.tmp, 'tmp')
|
|
164
|
+
}
|
|
150
165
|
}
|
|
151
166
|
return { moves, refusals }
|
|
152
167
|
}
|
|
@@ -293,7 +308,9 @@ function mergeWhereNothingCollides(root: string, plan: LayoutPlan): LayoutPlan {
|
|
|
293
308
|
let clash = true
|
|
294
309
|
let drop: string[] = []
|
|
295
310
|
try {
|
|
296
|
-
|
|
311
|
+
// a plan leaves the scratch before it moves, so it collides nowhere
|
|
312
|
+
const filed = new Set(moves.filter((m) => m.kind === 'plan').map((m) => m.from.slice(r.from.length + 1)))
|
|
313
|
+
const both = twin || !isDir(from) || !isDir(to) ? null : filesUnder(from).filter((f) => !filed.has(f) && existsSync(join(to, f)))
|
|
297
314
|
drop = scratch && both != null ? both.filter((f) => STALE_LEASE.test(f)) : []
|
|
298
315
|
clash = both == null || both.length > drop.length
|
|
299
316
|
} catch { clash = true }
|
|
@@ -361,6 +378,10 @@ const dirsIn = (root: string): string[] => {
|
|
|
361
378
|
|
|
362
379
|
const isDir = (p: string): boolean => { try { return statSync(p).isDirectory() } catch { return false } }
|
|
363
380
|
|
|
381
|
+
const filesIn = (dir: string): string[] => {
|
|
382
|
+
try { return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name) } catch { return [] }
|
|
383
|
+
}
|
|
384
|
+
|
|
364
385
|
/** does core.hooksPath point at the legacy `hooks/`, holding a shipkit pre-commit */
|
|
365
386
|
export function legacyHooksOurs(root: string): boolean {
|
|
366
387
|
const r = git(root, ['config', 'core.hooksPath'])
|
|
@@ -405,13 +426,15 @@ export function readPlanInput(root: string, config?: RepoConfig): PlanInput {
|
|
|
405
426
|
hooksOurs: existsSync(join(root, 'hooks')) && legacyHooksOurs(root),
|
|
406
427
|
legacyCache: isDir(join(root, LEGACY_CACHE)),
|
|
407
428
|
tmpHoldsRepo: isDir(join(root, LEGACY_TMP)) && holdsRepo(join(root, LEGACY_TMP)),
|
|
429
|
+
tmpFiles: filesIn(join(root, LEGACY_TMP)),
|
|
430
|
+
planEntries: isDir(join(root, LAYOUT.plans)) ? readdirSync(join(root, LAYOUT.plans)) : [],
|
|
408
431
|
}
|
|
409
432
|
}
|
|
410
433
|
|
|
411
434
|
/** a repo still carrying the old layout: the names a check reports */
|
|
412
435
|
export function legacyLayoutLeft(root: string, config?: RepoConfig): string[] {
|
|
413
436
|
const plan = planLayoutMigration(readPlanInput(root, config))
|
|
414
|
-
return [...plan.moves.filter((m) => m.kind !== 'cache').map((m) => `${m.from}/`), ...plan.refusals.map((r) => `${r.from}/`)]
|
|
437
|
+
return [...plan.moves.filter((m) => m.kind !== 'cache' && m.kind !== 'plan').map((m) => `${m.from}/`), ...plan.refusals.map((r) => `${r.from}/`)]
|
|
415
438
|
}
|
|
416
439
|
|
|
417
440
|
const lsFiles = (root: string, paths: string[]): string[] => {
|
|
@@ -59,7 +59,7 @@ export const RAW_RUNNERS: readonly RawRunner[] = [
|
|
|
59
59
|
* segment at a time, so `cd x && bun test` is caught too */
|
|
60
60
|
export const denyRules = (exemptSlots: Iterable<string> = []): string[] => {
|
|
61
61
|
const off = new Set(exemptSlots)
|
|
62
|
-
return [...RAW_RUNNERS.filter((r) => !off.has(r.slot)).map((r) => `Bash(${r.head}*)`),
|
|
62
|
+
return [...RAW_RUNNERS.filter((r) => !off.has(r.slot)).map((r) => `Bash(${r.head}*)`), ...GIT_STASH_RULES]
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
// `git stash` in a tree that other agents share takes every agent's
|
|
@@ -69,11 +69,19 @@ export const denyRules = (exemptSlots: Iterable<string> = []): string[] => {
|
|
|
69
69
|
// `bash -c '...'`, after git's own global options (`git -C dir stash`), or
|
|
70
70
|
// through `eval`: the guard closes those (stashCallIn).
|
|
71
71
|
export const GIT_STASH_HEAD = 'git stash'
|
|
72
|
-
export const GIT_STASH_RULE = `Bash(${GIT_STASH_HEAD}*)`
|
|
73
72
|
|
|
74
|
-
/** the stash subcommands that only read
|
|
73
|
+
/** the stash subcommands that only read: no deny row and no guard refusal touches them */
|
|
75
74
|
const STASH_READS = new Set(['list', 'show'])
|
|
76
75
|
|
|
76
|
+
/** the stash forms that move work, one deny row each. Claude Code matches a row by prefix, so one blanket
|
|
77
|
+
* `Bash(git stash*)` row also refused `git stash list` and `git stash show`. `Bash(git stash)` (no star) is the bare
|
|
78
|
+
* form, `git stash -*` the option led ones (`git stash -u`, `-m msg`), both meaning push */
|
|
79
|
+
const STASH_WRITES = ['push', 'pop', 'apply', 'drop', 'clear', 'save', 'store', 'create', 'branch'] as const
|
|
80
|
+
export const GIT_STASH_RULES: readonly string[] = [`Bash(${GIT_STASH_HEAD})`, `Bash(${GIT_STASH_HEAD} -*)`, ...STASH_WRITES.map((w) => `Bash(${GIT_STASH_HEAD} ${w}*)`)]
|
|
81
|
+
|
|
82
|
+
/** the blanket row older repos carry: it blocks the reads too, SK013 replaces it with GIT_STASH_RULES */
|
|
83
|
+
export const LEGACY_STASH_RULE = `Bash(${GIT_STASH_HEAD}*)`
|
|
84
|
+
|
|
77
85
|
/** git's global options that take their value as the NEXT word */
|
|
78
86
|
const GIT_VALUE_OPTS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--exec-path'])
|
|
79
87
|
|
|
@@ -158,7 +166,7 @@ export function stashCallIn(command: string, depth = 0): string | null {
|
|
|
158
166
|
|
|
159
167
|
/** what a stash refusal says: why, what to do instead, how to lift it */
|
|
160
168
|
export function stashRefusal(segment: string): string {
|
|
161
|
-
return `\`${segment.slice(0, 80)}\` stashes: in a tree that other agents share, a stash takes every agent's uncommitted work, not just yours. Set your own work aside with a commit by pathspec (git commit -m … -- <paths>), and read an existing stash by revision: \`git log -g --oneline refs/stash\`, \`git show stash@{0}:<file>\`.
|
|
169
|
+
return `\`${segment.slice(0, 80)}\` stashes: in a tree that other agents share, a stash takes every agent's uncommitted work, not just yours. Set your own work aside with a commit by pathspec (git commit -m … -- <paths>), and read an existing stash by revision: \`git log -g --oneline refs/stash\`, \`git show stash@{0}:<file>\`. Reading a stash (\`git stash list\`, \`git stash show\`) is never refused. SK013 writes these rows in every shipkit repo (identity/how-we-commit.md): lifting them is a change to that rule, not a settings edit.`
|
|
162
170
|
}
|
|
163
171
|
|
|
164
172
|
/** what a refusal says. Never just the ban: a refusal with no replacement
|
|
@@ -2,13 +2,12 @@
|
|
|
2
2
|
// Transcripts live at <configRoot>/projects/<slug>/*.jsonl where <slug> is the
|
|
3
3
|
// repo's absolute path with EVERY non-alphanumeric char replaced by '-'.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// compaction summaries, subagent prompts). Every filter below exists because
|
|
8
|
-
// one of them polluted a real extraction pass.
|
|
5
|
+
// What counts as typed is the shared transcript reader's call (transcript.ts):
|
|
6
|
+
// this module finds the files, dedupes and renders.
|
|
9
7
|
import { existsSync, readFileSync, readdirSync } from 'fs'
|
|
10
8
|
import { homedir } from 'os'
|
|
11
9
|
import { basename, join, resolve } from 'path'
|
|
10
|
+
import { isTypedPrompt, readTranscriptLine } from './transcript.ts'
|
|
12
11
|
|
|
13
12
|
export type PromptEntry = {
|
|
14
13
|
uuid: string
|
|
@@ -56,121 +55,23 @@ export function discoverConfigRoots(home: string = homedir()): ConfigRoot[] {
|
|
|
56
55
|
/** Claude Code slugs EVERY non-alphanumeric char to '-', not just '/' */
|
|
57
56
|
export const projectSlug = (repoPath: string): string => resolve(repoPath).replace(/[^a-zA-Z0-9]/g, '-')
|
|
58
57
|
|
|
59
|
-
|
|
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
|
-
/** the typed prompt out of one transcript line, or null when the line is not one
|
|
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
|
-
export function queuedTextOf(line: string): { text: string; entry: RawEntry } | null {
|
|
142
|
-
let parsed: unknown
|
|
143
|
-
try {
|
|
144
|
-
parsed = JSON.parse(line)
|
|
145
|
-
} catch {
|
|
146
|
-
return null
|
|
147
|
-
}
|
|
148
|
-
if (parsed == null || typeof parsed !== 'object') return null
|
|
149
|
-
const entry = parsed as RawEntry
|
|
150
|
-
if (entry.type !== 'queue-operation' || entry.operation !== 'enqueue') return null
|
|
151
|
-
if (typeof entry.content !== 'string') return null
|
|
152
|
-
const text = entry.content.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
|
|
153
|
-
if (!isTypedPrompt(text)) return null
|
|
154
|
-
return { text, entry }
|
|
155
|
-
}
|
|
58
|
+
export { isTypedPrompt } from './transcript.ts'
|
|
156
59
|
|
|
157
60
|
export function extractPrompts(jsonl: string, ctx: { sessionId: string; account: string }): PromptEntry[] {
|
|
158
61
|
const out: PromptEntry[] = []
|
|
159
62
|
for (const line of jsonl.split('\n')) {
|
|
160
63
|
if (line.trim() === '') continue
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
origin: queued == null ? 'typed' : 'queued',
|
|
173
|
-
})
|
|
64
|
+
for (const ev of readTranscriptLine(line)) {
|
|
65
|
+
if (ev.kind !== 'prompt') continue
|
|
66
|
+
out.push({
|
|
67
|
+
uuid: ev.uuid ?? `${ev.origin === 'typed' ? 'typed' : 'queue'}:${ctx.sessionId}:${ev.timestamp}`,
|
|
68
|
+
timestamp: ev.timestamp,
|
|
69
|
+
sessionId: ev.sessionId ?? ctx.sessionId,
|
|
70
|
+
account: ctx.account,
|
|
71
|
+
text: ev.text,
|
|
72
|
+
origin: ev.origin,
|
|
73
|
+
})
|
|
74
|
+
}
|
|
174
75
|
}
|
|
175
76
|
return out
|
|
176
77
|
}
|