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.
Files changed (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +2 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/assets/kits/identity-kit.ts +7 -3
  12. package/src/assets/run-assets.ts +18 -4
  13. package/src/checks/SK004-gitignore-policy.check.ts +11 -6
  14. package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
  15. package/src/cli/cmd_Calls.ts +53 -0
  16. package/src/cli/cmd_Commands.ts +163 -43
  17. package/src/cli/cmd_Review.ts +948 -0
  18. package/src/cli/cmd_Shot.ts +63 -0
  19. package/src/cli/cmd_Start.ts +8 -4
  20. package/src/cli/cmd_Time.ts +112 -0
  21. package/src/cli/cmd_Trailer.ts +361 -0
  22. package/src/cli/kit/builtins.ts +40 -0
  23. package/src/cli/kit/calls.ts +177 -0
  24. package/src/cli/kit/families.ts +67 -0
  25. package/src/cli/kit/index.ts +8 -0
  26. package/src/cli/kit/pages-check.ts +36 -0
  27. package/src/cli/kit/project-cli.ts +73 -0
  28. package/src/cli/kit/verb.ts +167 -0
  29. package/src/cli.ts +15 -2
  30. package/src/collections/collection-types.ts +6 -1
  31. package/src/collections/providers.ts +3 -0
  32. package/src/collections/review.ts +70 -0
  33. package/src/core/layout.ts +12 -2
  34. package/src/core/migrate-layout.ts +31 -8
  35. package/src/core/one-way-in-policy.ts +12 -4
  36. package/src/reflect/prompt-corpus.ts +15 -114
  37. package/src/reflect/transcript.ts +254 -0
  38. package/src/review/anim.ts +228 -0
  39. package/src/review/auto.ts +141 -0
  40. package/src/review/format.ts +150 -0
  41. package/src/review/http.ts +23 -0
  42. package/src/review/marks.ts +43 -0
  43. package/src/review/media.ts +45 -0
  44. package/src/review/model.ts +854 -0
  45. package/src/review/project.ts +110 -0
  46. package/src/review/role.ts +48 -0
  47. package/src/review/routes.ts +228 -0
  48. package/src/review/store.ts +1002 -0
  49. package/src/review/threads.ts +237 -0
  50. package/src/review/trailer-desk.ts +385 -0
  51. package/src/review/trailer-model.ts +183 -0
  52. package/src/review/trailer-routes.ts +136 -0
  53. package/src/review/unit.ts +165 -0
  54. package/src/review/watchers.ts +54 -0
  55. package/src/scaffold/cli-skeleton.ts +149 -0
  56. package/src/scaffold/files/agent-index.md +2 -2
  57. package/src/scaffold/files/tsconfig.json +3 -0
  58. package/src/scaffold/files/verify-skill.md +1 -1
  59. package/src/scaffold/project-scaffold.ts +90 -12
  60. package/src/serve/embed.ts +35 -0
  61. package/src/serve/hub-app/activity-view.tsx +1 -1
  62. package/src/serve/hub-app/activity.css +0 -4
  63. package/src/serve/hub-app/dock-view.tsx +5 -4
  64. package/src/serve/hub-app/hash-route.ts +28 -1
  65. package/src/serve/hub-app/header-bar.tsx +3 -1
  66. package/src/serve/hub-app/home-st.ts +10 -2
  67. package/src/serve/hub-app/home-strip.tsx +3 -2
  68. package/src/serve/hub-app/home.html +1 -0
  69. package/src/serve/hub-app/hub-app.tsx +80 -11
  70. package/src/serve/hub-app/hub.css +45 -13
  71. package/src/serve/hub-app/icons.tsx +9 -0
  72. package/src/serve/hub-app/index.html +1 -0
  73. package/src/serve/hub-app/insights-view.tsx +4 -3
  74. package/src/serve/hub-app/keys.ts +14 -10
  75. package/src/serve/hub-app/layout-st.ts +63 -2
  76. package/src/serve/hub-app/planned-view.tsx +3 -2
  77. package/src/serve/hub-app/planned.css +0 -1
  78. package/src/serve/hub-app/project-list.css +22 -4
  79. package/src/serve/hub-app/project-list.tsx +41 -8
  80. package/src/serve/hub-app/projects-svc.ts +4 -0
  81. package/src/serve/hub-app/repo-sections.ts +15 -3
  82. package/src/serve/hub-app/repo-st.ts +45 -3
  83. package/src/serve/hub-app/review/clip-st.ts +208 -0
  84. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  85. package/src/serve/hub-app/review/kit.tsx +113 -0
  86. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  87. package/src/serve/hub-app/review/review-board.tsx +46 -0
  88. package/src/serve/hub-app/review/review-list.tsx +156 -0
  89. package/src/serve/hub-app/review/review-page.tsx +101 -0
  90. package/src/serve/hub-app/review/review-st.ts +779 -0
  91. package/src/serve/hub-app/review/review-views.tsx +503 -0
  92. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  93. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  94. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  95. package/src/serve/hub-app/review.css +329 -0
  96. package/src/serve/hub-app/theme-st.ts +56 -0
  97. package/src/serve/hub-app/tree-build.ts +4 -0
  98. package/src/serve/hub-app/tree-row.tsx +1 -1
  99. package/src/serve/hub-app/tree-svc.ts +4 -0
  100. package/src/serve/hub-runtime.ts +5 -3
  101. package/src/serve/same-origin.ts +5 -1
  102. package/src/serve/serve-hub.ts +8 -0
  103. package/src/shipkit-files.ts +12 -4
  104. package/src/shot/client.ts +67 -0
  105. package/src/shot/clip-encoder.ts +146 -0
  106. package/src/shot/dom-clip.ts +124 -0
  107. package/src/shot/serve-shot.ts +54 -0
  108. package/src/shot/shooter.ts +343 -0
  109. package/src/shot/shot-ask.ts +189 -0
  110. package/src/shot/shot.shipkit.ts +14 -0
  111. package/src/smoke/run-smoke.ts +2 -2
  112. package/src/start-repo.ts +29 -14
  113. package/src/suite/judge-suites.ts +56 -10
  114. package/src/suite/select.ts +2 -5
  115. package/src/suite/suite-cli.ts +3 -2
  116. package/src/trailer/adapter.ts +78 -0
  117. package/src/trailer/build.ts +603 -0
  118. package/src/trailer/ffmpeg.ts +72 -0
  119. package/src/trailer/footage.ts +173 -0
  120. package/src/trailer/grammar.ts +1290 -0
  121. package/src/trailer/graph.ts +508 -0
  122. package/src/trailer/holes.ts +72 -0
  123. package/src/trailer/load-unit.ts +31 -0
  124. package/src/trailer/look.ts +124 -0
  125. package/src/trailer/passage.ts +129 -0
  126. package/src/trailer/plan.ts +192 -0
  127. package/src/trailer/prune.ts +193 -0
  128. package/src/trailer/score/compose.ts +240 -0
  129. package/src/trailer/score/dsp.ts +272 -0
  130. package/src/trailer/score/palette.ts +188 -0
  131. package/src/trailer/score/pattern.ts +91 -0
  132. package/src/trailer/score/score.ts +214 -0
  133. package/src/trailer/score/sound.ts +112 -0
  134. package/src/trailer/score/synth.ts +361 -0
  135. package/src/trailer/states.ts +76 -0
  136. package/src/trailer/stitch.ts +233 -0
  137. package/src/trailer/titles.ts +379 -0
  138. package/src/trailer/url.ts +108 -0
  139. package/src/trailer/vertical-draw.ts +286 -0
  140. package/src/trailer/vertical.ts +148 -0
  141. package/src/trailer/vocab.ts +59 -0
  142. 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
+ }
@@ -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
- // scratch: plans, logs, leases, detached runs, screenshots
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, the
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
- export type MoveKind = 'contract' | 'typo' | 'asks' | 'hooks' | 'cache' | 'tmp'
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 add(LEGACY_TMP, LAYOUT.tmp, 'tmp')
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
- const both = twin || !isDir(from) || !isDir(to) ? null : filesUnder(from).filter((f) => existsSync(join(to, f)))
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}*)`), GIT_STASH_RULE]
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, which the guard lets through */
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>\`. Lifting this: delete the "${GIT_STASH_RULE}" line from .claude/settings.json permissions.deny (identity/how-we-commit.md).`
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
- // 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.
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
- // 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
- /** 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 queued = queuedTextOf(line)
162
- const hit = queued ?? promptTextOf(line)
163
- if (hit == null) continue
164
- const timestamp = typeof hit.entry.timestamp === 'string' ? hit.entry.timestamp : ''
165
- const fallbackId = `${queued == null ? 'typed' : 'queue'}:${ctx.sessionId}:${timestamp}`
166
- out.push({
167
- uuid: typeof hit.entry.uuid === 'string' ? hit.entry.uuid : fallbackId,
168
- timestamp,
169
- sessionId: typeof hit.entry.sessionId === 'string' ? hit.entry.sessionId : ctx.sessionId,
170
- account: ctx.account,
171
- text: hit.text,
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
  }