rvlib-shipkit 2.1.0 → 2.2.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 +18 -0
- package/package.json +2 -1
- 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_CacheAudit.ts +3 -2
- package/src/cli/cmd_Calls.ts +53 -0
- package/src/cli/cmd_Ci.ts +4 -3
- package/src/cli/cmd_Commands.ts +158 -43
- package/src/cli/cmd_Fleet.ts +3 -1
- package/src/cli/cmd_GlobalProcesses.ts +7 -2
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Trailer.ts +108 -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 +157 -0
- package/src/cli/list-flag.ts +3 -0
- package/src/cli.ts +13 -2
- package/src/core/layout.ts +10 -2
- package/src/core/migrate-layout.ts +31 -8
- package/src/core/one-way-in-policy.ts +12 -4
- package/src/mv/mv-md.ts +2 -1
- package/src/reflect/prompt-corpus.ts +5 -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/shipkit-files.ts +8 -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 +23 -0
- package/src/trailer/build.ts +344 -0
- package/src/trailer/ffmpeg.ts +50 -0
- package/src/trailer/footage.ts +104 -0
- package/src/trailer/grammar.ts +915 -0
- package/src/trailer/graph.ts +323 -0
- package/src/trailer/load-unit.ts +14 -0
- package/src/trailer/look.ts +110 -0
- package/src/trailer/plan.ts +133 -0
- package/src/trailer/stitch.ts +150 -0
- package/src/trailer/titles.ts +274 -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 +40 -0
- package/src/ts/rename.ts +9 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// a project CLI command over code that already exists is a ROW, never a second
|
|
2
|
+
// implementation: `verbs([...])` turns rows into clipanion commands that spawn
|
|
3
|
+
// the script, ask the warm tool, or run the command line, from the repo root.
|
|
4
|
+
// A family file imports this module and nothing heavy at its top: the bare
|
|
5
|
+
// listing loads every family.
|
|
6
|
+
import { type BaseContext, Command, type CommandClass as ClipanionClass, Option } from 'clipanion'
|
|
7
|
+
import { existsSync } from 'fs'
|
|
8
|
+
import { isAbsolute, resolve } from 'path'
|
|
9
|
+
|
|
10
|
+
/** what a kit command sees in `this.context`, beside clipanion's streams */
|
|
11
|
+
export type KitContext = BaseContext & {
|
|
12
|
+
/** the repo root: every row runs from it */
|
|
13
|
+
root: string
|
|
14
|
+
/** where the caller stood: a filmable row's `--out` resolves against it */
|
|
15
|
+
cwd: string
|
|
16
|
+
/** every command class registered in this process (the docs read their marks) */
|
|
17
|
+
classes: CommandClass[]
|
|
18
|
+
/** the warm path of a `{ tool }` row, when the project has a warm host */
|
|
19
|
+
askTool?: AskTool
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type CommandClass = ClipanionClass<KitContext>
|
|
23
|
+
|
|
24
|
+
/** asks a warm process for a tool's answer; null = no warm host, run the cold script */
|
|
25
|
+
export type AskTool = (tool: string, args: string[]) => Promise<number | null>
|
|
26
|
+
|
|
27
|
+
/** how a row runs */
|
|
28
|
+
export type Run =
|
|
29
|
+
/** `bun <script> ...pre ...args` */
|
|
30
|
+
| { bun: string; pre?: string[] }
|
|
31
|
+
/** the warm host's answer when it runs (`askTool`), else `bun <cold> ...pre ...args` */
|
|
32
|
+
| { tool: string; cold: string; pre?: string[] }
|
|
33
|
+
/** any command line, the args appended */
|
|
34
|
+
| { cmd: string[] }
|
|
35
|
+
|
|
36
|
+
/** a row that shoots a state: `still` writes one image at `--out`, `clip` a video, its length `--seconds` */
|
|
37
|
+
export type Film = 'still' | 'clip'
|
|
38
|
+
|
|
39
|
+
/** an everyday command, listed on the essentials page every session loads */
|
|
40
|
+
export type Essential = {
|
|
41
|
+
/** its arguments as an agent types them: '<x> <y>' */
|
|
42
|
+
args?: string
|
|
43
|
+
/** how many of its examples the page shows under its line */
|
|
44
|
+
examples?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type Verb = {
|
|
48
|
+
/** the words after the binary: 'world at' */
|
|
49
|
+
path: string
|
|
50
|
+
/** the group the listing prints it under */
|
|
51
|
+
category: string
|
|
52
|
+
/** one line, under 100 characters */
|
|
53
|
+
description: string
|
|
54
|
+
/** the longer text `--help` prints */
|
|
55
|
+
details?: string
|
|
56
|
+
/** [label, command line], each line starting with the binary and the row's words */
|
|
57
|
+
examples: [string, string][]
|
|
58
|
+
run: Run
|
|
59
|
+
/** 'own': `--help` also runs the tool with `--help`, which prints its flags and exits 0 */
|
|
60
|
+
help?: 'own'
|
|
61
|
+
essential?: Essential
|
|
62
|
+
/** filmable: takes `--out <path>`, a clip also `--seconds <n>` (a trailer `film` line calls it) */
|
|
63
|
+
film?: Film
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** the argv a row runs */
|
|
67
|
+
export function argvOf(run: Run, args: readonly string[]): string[] {
|
|
68
|
+
if ('bun' in run) return ['bun', run.bun, ...(run.pre ?? []), ...args]
|
|
69
|
+
if ('tool' in run) return ['bun', run.cold, ...(run.pre ?? []), ...args]
|
|
70
|
+
return [...run.cmd, ...args]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** what a row runs, as the commands page prints it */
|
|
74
|
+
export function runText(run: Run): string {
|
|
75
|
+
if ('bun' in run) return [run.bun, ...(run.pre ?? [])].join(' ')
|
|
76
|
+
if ('tool' in run) return `tool ${run.tool} (cold: ${[run.cold, ...(run.pre ?? [])].join(' ')})`
|
|
77
|
+
return run.cmd.join(' ')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** the value after `flag` in args (`--out x` or `--out=x`), with its index */
|
|
81
|
+
function flagAt(args: readonly string[], flag: string): { i: number; value: string; joined: boolean } | null {
|
|
82
|
+
for (let i = 0; i < args.length; i++) {
|
|
83
|
+
const a = args[i]!
|
|
84
|
+
if (a === flag && i + 1 < args.length) return { i, value: args[i + 1]!, joined: false }
|
|
85
|
+
if (a.startsWith(`${flag}=`)) return { i, value: a.slice(flag.length + 1), joined: true }
|
|
86
|
+
}
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** the film contract on one call: a usage error, or the args with `--out`
|
|
91
|
+
* made absolute against the caller's cwd and the file the run must leave */
|
|
92
|
+
export function filmCall(film: Film | undefined, args: readonly string[], cwd: string): { error: string } | { args: string[]; out: string | null } {
|
|
93
|
+
if (film == null) return { args: [...args], out: null }
|
|
94
|
+
if (film === 'still' && flagAt(args, '--seconds') != null) return { error: '--seconds films a clip, and this row films a still' }
|
|
95
|
+
const out = flagAt(args, '--out')
|
|
96
|
+
if (out == null) return { args: [...args], out: null }
|
|
97
|
+
const abs = isAbsolute(out.value) ? out.value : resolve(cwd, out.value)
|
|
98
|
+
const next = [...args]
|
|
99
|
+
if (out.joined) next[out.i] = `--out=${abs}`
|
|
100
|
+
else next[out.i + 1] = abs
|
|
101
|
+
return { args: next, out: abs }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const isHelp = (a: string): boolean => a === '--help' || a === '-h'
|
|
105
|
+
|
|
106
|
+
/** runs an argv from `cwd` with the terminal handed over; its exit code */
|
|
107
|
+
export async function spawnHere(argv: string[], cwd: string): Promise<number> {
|
|
108
|
+
const p = Bun.spawn(argv, { cwd, env: process.env, stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' })
|
|
109
|
+
return await p.exited
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** the row each class was made from, so the docs print what it runs */
|
|
113
|
+
export const ROW_OF = new WeakMap<object, Verb>()
|
|
114
|
+
|
|
115
|
+
/** one clipanion command per row: the args after the path go to the code as typed */
|
|
116
|
+
export function verbs(rows: Verb[]): CommandClass[] {
|
|
117
|
+
return rows.map((v) => {
|
|
118
|
+
class VerbCommand extends Command<KitContext> {
|
|
119
|
+
static override paths = [v.path.split(' ')]
|
|
120
|
+
static override usage = Command.Usage({ category: v.category, description: v.description, details: v.details, examples: v.examples })
|
|
121
|
+
static essential = v.essential
|
|
122
|
+
static film = v.film
|
|
123
|
+
args = Option.Proxy()
|
|
124
|
+
async execute(): Promise<number> {
|
|
125
|
+
const { root, cwd } = this.context
|
|
126
|
+
if (this.args.some(isHelp)) {
|
|
127
|
+
this.context.stdout.write(this.cli.usage(VerbCommand, { detailed: true }))
|
|
128
|
+
if (v.help !== 'own') return 0
|
|
129
|
+
this.context.stdout.write(`\nthe tool's own help:\n\n`)
|
|
130
|
+
return spawnHere(argvOf(v.run, this.args), root)
|
|
131
|
+
}
|
|
132
|
+
const call = filmCall(v.film, this.args, cwd)
|
|
133
|
+
if ('error' in call) { this.context.stderr.write(`FAIL: ${v.path}: ${call.error}\n`); return 2 }
|
|
134
|
+
let code: number | null = null
|
|
135
|
+
if ('tool' in v.run && this.context.askTool != null) code = await this.context.askTool(v.run.tool, [...(v.run.pre ?? []), ...call.args])
|
|
136
|
+
code ??= await spawnHere(argvOf(v.run, call.args), root)
|
|
137
|
+
if (code === 0 && call.out != null && !existsSync(call.out)) {
|
|
138
|
+
this.context.stderr.write(`FAIL: ${v.path} exited 0 and wrote no ${call.out}\n fix: a filmable row writes its --out path\n`)
|
|
139
|
+
return 1
|
|
140
|
+
}
|
|
141
|
+
return code
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
ROW_OF.set(VerbCommand, v)
|
|
145
|
+
return VerbCommand
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** the marks a command class carries: set by `verbs()` from its row, or
|
|
150
|
+
* declared as statics on a hand written class */
|
|
151
|
+
export function marksOf(c: object): { essential?: Essential; film?: Film } {
|
|
152
|
+
const o = c as { essential?: unknown; film?: unknown }
|
|
153
|
+
const out: { essential?: Essential; film?: Film } = {}
|
|
154
|
+
if (typeof o.essential === 'object' && o.essential !== null) out.essential = o.essential as Essential
|
|
155
|
+
if (o.film === 'still' || o.film === 'clip') out.film = o.film
|
|
156
|
+
return out
|
|
157
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { Builtins, Cli } from 'clipanion'
|
|
|
7
7
|
import { AskNewCommand, AsksCheckCommand, AsksCommand } from './cli/cmd_Asks.ts'
|
|
8
8
|
import { AssetsAddCommand, AssetsCheckCommand, AssetsFillCommand, AssetsGenCommand, AssetsIdentityCommand } from './cli/cmd_Assets.ts'
|
|
9
9
|
import { BumpCommand } from './cli/cmd_Bump.ts'
|
|
10
|
+
import { CallsCommand } from './cli/cmd_Calls.ts'
|
|
10
11
|
import { CheckCommand } from './cli/cmd_Check.ts'
|
|
11
12
|
import { CommandsCommand } from './cli/cmd_Commands.ts'
|
|
12
13
|
import { aliasArgs, CrossRepoActivityCommand } from './cli/cmd_CrossRepoActivity.ts'
|
|
@@ -35,8 +36,10 @@ import { CacheAuditCommand } from './cli/cmd_CacheAudit.ts'
|
|
|
35
36
|
import { TraceAuditCommand } from './cli/cmd_TraceAudit.ts'
|
|
36
37
|
import { ServeCommand } from './cli/cmd_Serve.ts'
|
|
37
38
|
import { SmokeCommand } from './cli/cmd_Smoke.ts'
|
|
39
|
+
import { ShotCommand } from './cli/cmd_Shot.ts'
|
|
38
40
|
import { StatusCommand } from './cli/cmd_Status.ts'
|
|
39
41
|
import { StartCommand } from './cli/cmd_Start.ts'
|
|
42
|
+
import { TrailerCheckCommand, TrailerCommand, TrailerShotCommand } from './cli/cmd_Trailer.ts'
|
|
40
43
|
import { TuiCommand } from './cli/cmd_Tui.ts'
|
|
41
44
|
import { WikiLintCommand, WikiServeCommand } from './cli/cmd_Wiki.ts'
|
|
42
45
|
|
|
@@ -67,6 +70,9 @@ export function buildCli(): Cli {
|
|
|
67
70
|
cli.register(MvCommand)
|
|
68
71
|
cli.register(CheckCommand)
|
|
69
72
|
cli.register(TuiCommand)
|
|
73
|
+
cli.register(TrailerCommand)
|
|
74
|
+
cli.register(TrailerCheckCommand)
|
|
75
|
+
cli.register(TrailerShotCommand)
|
|
70
76
|
cli.register(ServeCommand)
|
|
71
77
|
cli.register(PmCommand)
|
|
72
78
|
cli.register(DaemonCommand)
|
|
@@ -91,6 +97,7 @@ export function buildCli(): Cli {
|
|
|
91
97
|
cli.register(FleetCommand)
|
|
92
98
|
cli.register(PbCollectionCommand)
|
|
93
99
|
cli.register(SmokeCommand)
|
|
100
|
+
cli.register(ShotCommand)
|
|
94
101
|
cli.register(ReflectCommand)
|
|
95
102
|
cli.register(KeysCommand)
|
|
96
103
|
cli.register(GlobalProcessesCommand)
|
|
@@ -109,6 +116,7 @@ export function buildCli(): Cli {
|
|
|
109
116
|
cli.register(TraceAuditCommand)
|
|
110
117
|
cli.register(UnitsAliasCommand)
|
|
111
118
|
cli.register(CommandsCommand)
|
|
119
|
+
cli.register(CallsCommand)
|
|
112
120
|
cli.register(ConfigKeysCommand)
|
|
113
121
|
|
|
114
122
|
return cli
|
|
@@ -117,14 +125,17 @@ export function buildCli(): Cli {
|
|
|
117
125
|
// only when this file IS the process, so importing it costs nothing. A repo's
|
|
118
126
|
// installed copy first hands the command to the house source (handoff.ts). One copy
|
|
119
127
|
// of the lib per process: a repo config that imports its own install lands on
|
|
120
|
-
// this one (one-lib.ts), so the machine's version runs in every repo.
|
|
121
|
-
// is
|
|
128
|
+
// this one (one-lib.ts), so the machine's version runs in every repo. The run
|
|
129
|
+
// is logged (cli/kit/calls.ts, read back by `sk calls`). Then pm is started
|
|
130
|
+
// when it is down (autostart.ts), before the command runs
|
|
122
131
|
if (import.meta.main) {
|
|
123
132
|
const argv = aliasArgs(process.argv.slice(2))
|
|
124
133
|
const { handoff } = await import('./home/handoff.ts')
|
|
125
134
|
handoff(argv)
|
|
126
135
|
const { installOneLib } = await import('./core/one-lib.ts')
|
|
127
136
|
installOneLib()
|
|
137
|
+
const { callsFile, logCall } = await import('./cli/kit/calls.ts')
|
|
138
|
+
logCall(callsFile((await import('os')).homedir(), 'sk'), 'sk', argv)
|
|
128
139
|
const { autostart } = await import('./home/autostart.ts')
|
|
129
140
|
await autostart(argv)
|
|
130
141
|
void buildCli().runExit(argv)
|
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,10 @@ 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:
|
|
39
|
+
// gitignored scratch, never a plan home: logs, leases, detached runs, screenshots
|
|
37
40
|
tmp: sk('tmp'),
|
|
41
|
+
// a CLI's call log, `<cli>-calls.jsonl`: local, never tracked
|
|
42
|
+
logs: sk('logs'),
|
|
38
43
|
// built app bundles, one per global process opened as an app (home/gui-app.ts)
|
|
39
44
|
wrapper: sk('wrapper'),
|
|
40
45
|
// generated on every run
|
|
@@ -44,7 +49,10 @@ export const LAYOUT = {
|
|
|
44
49
|
} as const
|
|
45
50
|
|
|
46
51
|
/** 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
|
|
52
|
+
export const TRACKED_DIRS = [LAYOUT.asks, LAYOUT.hooks, LAYOUT.identity, LAYOUT.assets, LAYOUT.comfy, LAYOUT.repros, LAYOUT.plans] as const
|
|
53
|
+
|
|
54
|
+
/** a plan file name: `YYYYMMDD-HHMMSS-<slug>.md` */
|
|
55
|
+
export const PLAN_FILE = /^\d{8}-\d{6}-.+\.md$/
|
|
48
56
|
|
|
49
57
|
/** the machine-global private folder, under $HOME: one keyword list and one
|
|
50
58
|
* 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
|
package/src/mv/mv-md.ts
CHANGED
|
@@ -154,7 +154,8 @@ export function movePage(root: string, oldRel: string, newRel: string, io: MvIo,
|
|
|
154
154
|
let touched = 0
|
|
155
155
|
for (const f of tracked) {
|
|
156
156
|
const p = join(root, f)
|
|
157
|
-
|
|
157
|
+
// a submodule is one ls-files entry and a directory: nothing to read
|
|
158
|
+
if (!isFile(p)) continue
|
|
158
159
|
const before = readFileSync(p, 'utf8')
|
|
159
160
|
let after = retargetRelativeRefs(root, before, dirOf(f), dirOf(moves.get(f) ?? f), moves)
|
|
160
161
|
for (const [oldTok, newTok] of pairs) after = after.replace(bound(oldTok), newTok)
|
|
@@ -67,6 +67,11 @@ const NOISE_PREFIXES = [
|
|
|
67
67
|
'Caveat: The messages below',
|
|
68
68
|
'[Request interrupted',
|
|
69
69
|
'This session is being continued from a previous conversation',
|
|
70
|
+
// model output parked in the queue (or wrapped as a meta entry): a
|
|
71
|
+
// sub-agent's hand-back, and a message another Claude session sent
|
|
72
|
+
'<agent-message',
|
|
73
|
+
'<cross-session-message',
|
|
74
|
+
'Another Claude session sent a message',
|
|
70
75
|
]
|
|
71
76
|
|
|
72
77
|
export function isTypedPrompt(text: string): boolean {
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// the project CLI a started repo gets on day one: `src/cli.ts` on the kit
|
|
2
|
+
// (cli/kit/), named for the project, one example family, and the package.json
|
|
3
|
+
// `cli` script plus a `bin` of the project's name. Pure text here; the scaffold
|
|
4
|
+
// writes it create-only, so a repo that already has any of it keeps its own.
|
|
5
|
+
import { Cli } from 'clipanion'
|
|
6
|
+
import { commandRows, type Definition, newCommandsPage, renderEssentials, renderMarkdown, spliceBlock } from '../cli/cmd_Commands.ts'
|
|
7
|
+
import { builtinCommands } from '../cli/kit/builtins.ts'
|
|
8
|
+
import { type Verb, verbs } from '../cli/kit/verb.ts'
|
|
9
|
+
|
|
10
|
+
/** the entry: the family table and nothing else. `absolute`: the repo's
|
|
11
|
+
* tsconfig maps `src/*`, the house import form; else a relative import */
|
|
12
|
+
export function cliEntry(name: string, absolute = true): string {
|
|
13
|
+
return `#!/usr/bin/env bun
|
|
14
|
+
// ${name}: the project CLI, one command for every act an agent or a human does in this repo.
|
|
15
|
+
// A family is src/cli/cmd_<Family>.ts exporting \`commands\`; the table names the first words
|
|
16
|
+
// each family owns, so a call loads its family alone. \`${name}\` lists every command.
|
|
17
|
+
import { resolve } from 'path'
|
|
18
|
+
import { runProjectCli } from 'rvlib-shipkit/cli-kit'
|
|
19
|
+
|
|
20
|
+
await runProjectCli({
|
|
21
|
+
name: '${name}',
|
|
22
|
+
root: resolve(import.meta.dir, '..'),
|
|
23
|
+
families: [
|
|
24
|
+
{ roots: ['dev', 'shot'], load: () => import('${absolute ? 'src/cli/cmd_Dev.ts' : './cli/cmd_Dev.ts'}') },
|
|
25
|
+
],
|
|
26
|
+
})
|
|
27
|
+
`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** the example family's live row, as data: the source text and the day-one
|
|
31
|
+
* pages are both made from it, so they cannot disagree */
|
|
32
|
+
export function exampleRow(name: string, devScript = false): Verb {
|
|
33
|
+
return {
|
|
34
|
+
path: 'dev',
|
|
35
|
+
category: 'Run',
|
|
36
|
+
description: devScript ? 'the dev process (package.json dev)' : 'the dev process: src/index.ts, restarted on save',
|
|
37
|
+
examples: [['start it', `${name} dev`]],
|
|
38
|
+
run: devScript ? { cmd: ['bun', 'run', 'dev'] } : { cmd: ['bun', '--watch', 'src/index.ts'] },
|
|
39
|
+
essential: {},
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const q = (s: string): string => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
|
|
44
|
+
|
|
45
|
+
/** the example family: a row over code that exists, and the shot row a visual
|
|
46
|
+
* project uncomments once it has a renderer */
|
|
47
|
+
export function exampleFamily(name: string, devScript = false): string {
|
|
48
|
+
const v = exampleRow(name, devScript)
|
|
49
|
+
const run = 'cmd' in v.run ? `{ cmd: [${v.run.cmd.map(q).join(', ')}] }` : ''
|
|
50
|
+
return `// a command over code that exists is a row: run is { bun: <script> }, { cmd: [...] }
|
|
51
|
+
// or { tool, cold } (a warm host, else the cold script). Mark an everyday row
|
|
52
|
+
// \`essential: {}\`. \`sk ci\` rewrites the agent pages (cli-pages.check.shipkit.ts), or: ${pagesCommand(name)}
|
|
53
|
+
import { verbs } from 'rvlib-shipkit/cli-kit'
|
|
54
|
+
|
|
55
|
+
export const commands = verbs([
|
|
56
|
+
{
|
|
57
|
+
path: ${q(v.path)},
|
|
58
|
+
category: ${q(v.category)},
|
|
59
|
+
description: ${q(v.description)},
|
|
60
|
+
examples: [[${v.examples[0]!.map(q).join(', ')}]],
|
|
61
|
+
run: ${run},
|
|
62
|
+
essential: {},
|
|
63
|
+
},
|
|
64
|
+
// a visual project: any state as a png, the same command filmed by a trailer line.
|
|
65
|
+
// film: 'still' means the script takes --out <path>; 'clip' also takes --seconds <n>.
|
|
66
|
+
// {
|
|
67
|
+
// path: 'shot',
|
|
68
|
+
// category: 'Look',
|
|
69
|
+
// description: 'any state of the app as a png',
|
|
70
|
+
// examples: [['the home screen', '${name} shot home --out shot.png']],
|
|
71
|
+
// run: { bun: 'scripts/shot.ts' },
|
|
72
|
+
// film: 'still',
|
|
73
|
+
// },
|
|
74
|
+
])
|
|
75
|
+
`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** where a project CLI's two agent pages live */
|
|
79
|
+
export const cliPages = (name: string): { all: string; essentials: string } => ({ all: `agent/${name}.md`, essentials: `agent/${name}-essentials.md` })
|
|
80
|
+
|
|
81
|
+
/** the command that writes both pages */
|
|
82
|
+
export const pagesCommand = (name: string): string => `${name} commands --out ${cliPages(name).all} --essentials ${cliPages(name).essentials}`
|
|
83
|
+
|
|
84
|
+
/** the day-one pages, exactly as \`<name> commands --out … --essentials …\` writes them */
|
|
85
|
+
export function skeletonPages(name: string, devScript = false): { all: string; essentials: string } {
|
|
86
|
+
const cli = new Cli({ binaryName: name })
|
|
87
|
+
const builtins = builtinCommands(name)
|
|
88
|
+
const classes = [...verbs([exampleRow(name, devScript)]), ...builtins.docs, ...builtins.calls]
|
|
89
|
+
for (const c of classes) cli.register(c)
|
|
90
|
+
const defs = cli.definitions() as Definition[]
|
|
91
|
+
const rows = commandRows(defs, (cli.definitions as (o?: { detailed?: boolean }) => Definition[])({ detailed: true }), name, classes)
|
|
92
|
+
return {
|
|
93
|
+
all: spliceBlock(newCommandsPage(name, cliPages(name).all), renderMarkdown(rows, name), name)!,
|
|
94
|
+
essentials: renderEssentials(rows, name, pagesCommand(name)),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** the package.json keys the project CLI needs, only where the key is free.
|
|
99
|
+
* Returns the keys it filled */
|
|
100
|
+
export function addCliKeys(pkg: { scripts?: Record<string, string>; bin?: Record<string, string> | string }, name: string): string[] {
|
|
101
|
+
const filled: string[] = []
|
|
102
|
+
pkg.scripts ??= {}
|
|
103
|
+
if (pkg.scripts.cli == null) { pkg.scripts.cli = 'bun src/cli.ts'; filled.push('scripts.cli') }
|
|
104
|
+
if (pkg.bin == null) { pkg.bin = { [name]: './src/cli.ts' }; filled.push(`bin.${name}`) }
|
|
105
|
+
return filled
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** what says a repo already has a CLI, its own or its product's: a `cli`
|
|
109
|
+
* script, any `bin`, any script that runs a cli.ts or cli.js of the repo, a
|
|
110
|
+
* src/cli.ts or a src/cli/ folder. null = the skeleton has room */
|
|
111
|
+
export function existingCli(pkg: { scripts?: Record<string, string>; bin?: unknown } | null, files: { entry: boolean; cliDir: boolean }): string | null {
|
|
112
|
+
if (pkg?.scripts?.cli != null) return `package.json scripts.cli runs \`${pkg.scripts.cli}\``
|
|
113
|
+
if (pkg?.bin != null) return `package.json has a bin (${typeof pkg.bin === 'string' ? pkg.bin : Object.keys(pkg.bin as object).join(', ')})`
|
|
114
|
+
// a script running an installed tool's cli (the house `hub` runs shipkit's) is not the repo's own
|
|
115
|
+
const runner = Object.entries(pkg?.scripts ?? {}).find(([, body]) => body.split(/\s+/).some((w) => /\bcli\.(ts|js)$/.test(w) && !/node_modules\/|rvlib-shipkit/.test(w)))
|
|
116
|
+
if (runner != null) return `package.json scripts.${runner[0]} runs \`${runner[1]}\``
|
|
117
|
+
if (files.entry) return 'src/cli.ts exists'
|
|
118
|
+
if (files.cliDir) return 'src/cli/ exists'
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** whether an installed rvlib-shipkit carries the kit: null = none installed */
|
|
123
|
+
export function kitInstalled(installedPkg: { exports?: Record<string, unknown> } | null): boolean | null {
|
|
124
|
+
if (installedPkg == null) return null
|
|
125
|
+
return installedPkg.exports?.['./cli-kit'] != null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** the check file that keeps both agent pages equal to the registry: the gate
|
|
129
|
+
* fails a stale page and its fix pass writes it again */
|
|
130
|
+
export function pagesCheckFile(name: string): string {
|
|
131
|
+
return `// agent/${name}.md and agent/${name}-essentials.md list the ${name} commands as the registry has them.
|
|
132
|
+
// A stale page sends an agent at a command that does not exist; the gate's fix pass writes both again
|
|
133
|
+
import { check } from 'rvlib-shipkit/src/check.ts'
|
|
134
|
+
import { cliPagesCheck } from 'rvlib-shipkit/src/cli/kit/pages-check.ts'
|
|
135
|
+
|
|
136
|
+
const pages = cliPagesCheck('${name}')
|
|
137
|
+
|
|
138
|
+
export default check({
|
|
139
|
+
description: pages.description,
|
|
140
|
+
blocking: true,
|
|
141
|
+
runs: { onPreCommit: false, onCi: true, onFull: true, onRelease: true, onHealth: true, onSave: 'moved', onClock: false },
|
|
142
|
+
cache: { by: 'files', inputs: pages.inputs, key: null },
|
|
143
|
+
fix: 'ci',
|
|
144
|
+
applies: 'always',
|
|
145
|
+
run: pages.run,
|
|
146
|
+
repair: pages.repair,
|
|
147
|
+
})
|
|
148
|
+
`
|
|
149
|
+
}
|
|
@@ -12,14 +12,14 @@ BEFORE the change it describes.
|
|
|
12
12
|
|
|
13
13
|
**Always loaded every session:**
|
|
14
14
|
|
|
15
|
-
@coding.md
|
|
15
|
+
@coding.md{{CLI_INCLUDE}}
|
|
16
16
|
|
|
17
17
|
Read the matching page before touching a surface:
|
|
18
18
|
|
|
19
19
|
| read when touching… | page |
|
|
20
20
|
|---|---|
|
|
21
21
|
| any source file | `agent/coding.md` |
|
|
22
|
-
| recent changes and why | `agent/changelog.md` |
|
|
22
|
+
| recent changes and why | `agent/changelog.md` |{{CLI_ROW}}
|
|
23
23
|
|
|
24
24
|
Grow this table: a subsystem, a service, a runbook each earn their own page,
|
|
25
25
|
cross-linked from here.
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
"scripts/*": ["./scripts/*"]
|
|
24
24
|
},
|
|
25
25
|
|
|
26
|
+
// no @types package loads unless named here: bun's globals (Bun, import.meta.dir)
|
|
27
|
+
"types": ["bun"],
|
|
28
|
+
|
|
26
29
|
// every file is a module even with no import/export (standalone scripts)
|
|
27
30
|
"moduleDetection": "force",
|
|
28
31
|
|
|
@@ -12,7 +12,7 @@ description: Prove a change in THIS project actually works — drive the real fl
|
|
|
12
12
|
## The rules (house-wide)
|
|
13
13
|
- Failing repro test BEFORE the fix. Green checks ≠ verification.
|
|
14
14
|
- Logic → fast headless bun tests, run through `sk ci --only test` (tiny ~10-line test files reusing fixtures). While you fix one bug, `bun run test:raw <test file>` is the narrow way in, and one test by name is `bun run test:raw -- -t "<name>"`.
|
|
15
|
-
- Look & feel →
|
|
15
|
+
- Look & feel → your own screenshot is a check before you report, never the verdict; the repo owner playtests it.
|
|
16
16
|
- Verify as the end user would: drive the real flow, observe the real signal.
|
|
17
17
|
|
|
18
18
|
## This project
|