rvlib-shipkit 1.8.0 → 2.0.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 +66 -0
- package/package.json +1 -1
- package/src/assets/gen/comfyts-gen.ts +31 -14
- package/src/assets/gen/generate-roll.ts +20 -7
- package/src/assets/kit-config.ts +9 -0
- package/src/assets/kits/identity-kit.ts +7 -1
- package/src/assets/run-assets.ts +3 -3
- package/src/assets/server/studio-server.ts +3 -3
- package/src/check.ts +5 -2
- package/src/checks/SK001-package-json-shape.check.ts +47 -12
- package/src/checks/SK013-one-way-in-policy.check.ts +8 -7
- package/src/checks/SK017-vscode-hub-task.check.ts +109 -106
- package/src/checks/SK019-tests-run-in-units.check.ts +2 -1
- package/src/checks/SK027-agent-wiki-links.check.ts +5 -2
- package/src/checks/SK029-explicit-check-files.check.ts +25 -8
- package/src/checks/SK034-commit-gates-armed.check.ts +90 -0
- package/src/cli/cmd_Ci.ts +6 -5
- package/src/cli/cmd_Commands.ts +21 -5
- package/src/cli/cmd_Hooks.ts +6 -2
- package/src/cli/cmd_Init.ts +1 -1
- package/src/cli/cmd_Serve.ts +1 -2
- package/src/cli/cmd_Start.ts +1 -1
- package/src/cli.ts +2 -4
- package/src/collections/providers.ts +1 -1
- package/src/core/budget.ts +6 -8
- package/src/core/bun-builtins.ts +36 -0
- package/src/core/bun-tool-cli.ts +23 -0
- package/src/core/canonical-scripts.ts +6 -4
- package/src/core/check-ctx.ts +104 -10
- package/src/core/check-file-names.ts +2 -3
- package/src/core/check-pass.ts +17 -1
- package/src/core/check-runner.ts +32 -10
- package/src/core/config-rewrite.ts +27 -6
- package/src/core/discover-checks.ts +64 -15
- package/src/core/fix-scope.ts +5 -0
- package/src/core/floor-resolve.ts +18 -8
- package/src/core/git-root.ts +22 -12
- package/src/core/job-run.ts +2 -2
- package/src/core/layout.ts +4 -1
- package/src/core/legacy-shapes.ts +40 -0
- package/src/core/one-lib.ts +5 -3
- package/src/core/one-way-in-policy.ts +106 -13
- package/src/core/pre-commit-discovery.ts +106 -0
- package/src/core/retired-checks.ts +2 -0
- package/src/core/run-ci.ts +23 -16
- package/src/core/run-fix.ts +9 -0
- package/src/core/run-fixers.ts +91 -35
- package/src/core/runs-matrix.ts +7 -0
- package/src/core/slots.ts +36 -6
- package/src/core/vendored-read.ts +39 -0
- package/src/core/vendored.ts +82 -0
- package/src/core/waivers.ts +125 -0
- package/src/core/when-block.ts +25 -185
- package/src/core/when.ts +30 -13
- package/src/core/wire.ts +17 -1
- package/src/git-bootstrap.ts +1 -1
- package/src/home/attach.ts +3 -5
- package/src/home/gui-app/ShipkitGui.swift +96 -0
- package/src/home/insights.ts +6 -1
- package/src/home/pm-config.ts +2 -2
- package/src/home/resources.ts +3 -0
- package/src/home/run-daemon.ts +2 -2
- package/src/home/run-unit-action.ts +2 -2
- package/src/hooks/commit-message.ts +2 -14
- package/src/hooks/hook-gates.ts +38 -0
- package/src/hooks/leak-guard.ts +8 -3
- package/src/hooks/one-way-in-guard.ts +8 -2
- package/src/hooks/root-identity.ts +9 -6
- package/src/hooks/run-hooks.ts +72 -41
- package/src/hooks/staged.ts +49 -0
- package/src/hooks/tree-shrink.ts +41 -0
- package/src/init-repo.ts +0 -42
- package/src/ledger/check-table.ts +1 -1
- package/src/ledger/gate-checks.ts +45 -9
- package/src/ledger/ledger.ts +6 -1
- package/src/ledger/repo-view.ts +14 -4
- package/src/ledger/toolchain-identity.ts +4 -2
- package/src/mv/mv-md.ts +6 -1
- package/src/plan/planner.ts +21 -4
- package/src/plan/repo-source.ts +7 -3
- package/src/problems/extract.ts +27 -5
- package/src/problems/problem-line.ts +2 -3
- package/src/release/run-release.ts +30 -16
- package/src/release/unit-gate.ts +12 -5
- package/src/repo-config.ts +51 -27
- package/src/scaffold/files/bunfig.toml +11 -0
- package/src/scaffold/files/vscode-settings.json +1 -2
- package/src/scaffold/jsonc-spans.ts +16 -0
- package/src/scaffold/project-scaffold.ts +43 -77
- package/src/serve/hub-app/check-view.tsx +4 -4
- package/src/serve/hub-app/check-words.ts +1 -0
- package/src/serve/hub-app/checks-svc.ts +1 -2
- package/src/serve/hub-app/home.html +0 -1
- package/src/serve/hub-app/hub-types.ts +0 -2
- package/src/serve/hub-app/index.html +0 -1
- package/src/serve/hub-app/project-list.css +1 -2
- package/src/serve/hub-app/tree-build.ts +3 -2
- package/src/serve/hub-app/tree-svc.ts +2 -2
- package/src/serve/hub-port.ts +3 -3
- package/src/serve/hub-runtime.ts +3 -5
- package/src/serve/project-gutter.ts +5 -2
- package/src/serve/run-lanes.ts +7 -3
- package/src/serve/serve-hub.ts +1 -16
- package/src/serve/tracked-watch.ts +1 -1
- package/src/smoke/run-smoke.ts +13 -6
- package/src/start-repo.ts +15 -3
- package/src/suite/judge-suites.ts +81 -32
- package/src/suite/select.ts +2 -0
- package/src/suite/suite-cli.ts +27 -16
- package/src/suite/suite-cpu-preload.ts +7 -11
- package/src/suite/test-units.ts +7 -4
- package/src/ts/config.ts +12 -5
- package/src/ts/imports.ts +3 -1
- package/src/ts/typecheck-cache.ts +11 -0
- package/src/ts/typecheck-cli.ts +2 -1
- package/src/tui/build-steps-panel.ts +1 -1
- package/src/wiki/scan-md.ts +16 -9
- package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
- package/src/checks/SK014-prose-ste.check.ts +0 -78
- package/src/cli/cmd_Prose.ts +0 -74
- package/src/prose/prose-view.ts +0 -90
- package/src/prose/run-prose.ts +0 -187
- package/src/prose/ste.ts +0 -552
- package/src/scaffold/files/vscode-tasks.json +0 -24
- package/src/serve/hub-app/prose-hits-st.ts +0 -125
- package/src/serve/hub-app/prose-hits-view.tsx +0 -176
- package/src/serve/hub-app/prose-hits.css +0 -21
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// are told apart by reading the source first (`isLegacyCheckSource`).
|
|
17
17
|
//
|
|
18
18
|
// Skipped dirs: node_modules, dist, tmp, references (templates for other
|
|
19
|
-
// repos),
|
|
19
|
+
// repos), every dot-dir, and the repo's vendored dirs (core/vendored.ts).
|
|
20
20
|
import { RETIRED_CHECK_IDS } from './retired-checks.ts'
|
|
21
21
|
import { readdirSync, readFileSync, realpathSync, statSync } from 'fs'
|
|
22
22
|
import { basename, join, relative } from 'path'
|
|
@@ -24,6 +24,7 @@ import { check, type CheckDef, type CheckFrom } from '../check.ts'
|
|
|
24
24
|
|
|
25
25
|
import { CHECK_SUFFIX as SUFFIX, fallbackNames, isLegacyCheckSource, SKIPPED_DIRS as IGNORED_DIRS, UNIT_SUFFIX, unitBase } from './check-file-names.ts'
|
|
26
26
|
import { unitKind } from '../shipkit-files.ts'
|
|
27
|
+
import { readVendored } from './vendored-read.ts'
|
|
27
28
|
|
|
28
29
|
export { fallbackNames, isLegacyCheckSource } from './check-file-names.ts'
|
|
29
30
|
|
|
@@ -56,23 +57,27 @@ export type DiscoveredCheck = {
|
|
|
56
57
|
from: CheckFrom
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
function walk(dir: string, out: string[], suffix = SUFFIX, dirs?: string[]): void {
|
|
60
|
+
function walk(dir: string, out: string[], suffix = SUFFIX, dirs?: string[], prune?: ReadonlySet<string>): void {
|
|
60
61
|
dirs?.push(dir)
|
|
61
62
|
let entries
|
|
62
63
|
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
63
64
|
for (const e of entries) {
|
|
64
65
|
if (e.isDirectory()) {
|
|
65
|
-
|
|
66
|
+
const sub = join(dir, e.name)
|
|
67
|
+
if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.') && prune?.has(sub) !== true) walk(sub, out, suffix, dirs, prune)
|
|
66
68
|
} else if (e.name.endsWith(suffix)) out.push(join(dir, e.name))
|
|
67
69
|
}
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
/** the absolute vendored dirs of a repo, which no repo walk enters */
|
|
73
|
+
const vendoredPrune = (root: string): ReadonlySet<string> => new Set(readVendored(root).map((d) => join(root, d)))
|
|
74
|
+
|
|
70
75
|
/** every `*.shipkit.ts` file under a repo root, by the same walk. Sorted.
|
|
71
76
|
* `dirs` collects every dir the walk read: a file added to one moves its
|
|
72
77
|
* mtime, which is how a reader tells the list moved without walking again */
|
|
73
78
|
export function repoUnitFiles(root: string, dirs?: string[]): string[] {
|
|
74
79
|
const out: string[] = []
|
|
75
|
-
walk(root, out, UNIT_SUFFIX, dirs)
|
|
80
|
+
walk(root, out, UNIT_SUFFIX, dirs, vendoredPrune(root))
|
|
76
81
|
return out.sort()
|
|
77
82
|
}
|
|
78
83
|
|
|
@@ -97,7 +102,7 @@ export async function importFresh(path: string): Promise<unknown> {
|
|
|
97
102
|
* Sorted. */
|
|
98
103
|
export function repoCheckFiles(root: string): string[] {
|
|
99
104
|
const out: string[] = []
|
|
100
|
-
walk(root, out)
|
|
105
|
+
walk(root, out, SUFFIX, undefined, vendoredPrune(root))
|
|
101
106
|
return out.sort()
|
|
102
107
|
}
|
|
103
108
|
|
|
@@ -158,50 +163,94 @@ function unloadable(path: string, message: string): CheckDef {
|
|
|
158
163
|
}
|
|
159
164
|
}
|
|
160
165
|
|
|
166
|
+
/** a built-in that does not load, as a repo that only CONSUMES the toolkit
|
|
167
|
+
* sees it: one warning, never a red gate, since only the toolkit's own repo
|
|
168
|
+
* can fix the file, and its gate stays red on it */
|
|
169
|
+
function brokenBuiltin(path: string, message: string): CheckDef {
|
|
170
|
+
return {
|
|
171
|
+
...unloadable(path, message),
|
|
172
|
+
blocking: false,
|
|
173
|
+
run: (c) => c.warn(`${basename(path)} does not load, so this gate skips it: ${message.replace(`${path}: `, '')}`, 'the toolkit repo owns the file, and its own gate fails on it'),
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const BUILTIN_OWNER = 'build/shipkit/built-in-checks.md'
|
|
178
|
+
|
|
161
179
|
/** every check this repo can run: its own files, plus the toolkit's. Throws
|
|
162
180
|
* on a registration nobody can act on (a duplicate id, a repo check squatting
|
|
163
181
|
* the reserved prefix, a built-in with no owner page), so a broken
|
|
164
|
-
* declaration dies at load rather than half way through a gate.
|
|
165
|
-
|
|
182
|
+
* declaration dies at load rather than half way through a gate. A broken
|
|
183
|
+
* built-in is that only in the toolkit's own repo: elsewhere it warns. */
|
|
184
|
+
export type DiscoverOpts = {
|
|
185
|
+
/** a file not to import at all: the pre-commit gate skips the files its
|
|
186
|
+
* declarations table already knows run no onPreCommit body */
|
|
187
|
+
skip?: (path: string) => boolean
|
|
188
|
+
/** every import, timed: `def` is null for a unit file of another kind,
|
|
189
|
+
* and `loaded` is false for a file that did not load */
|
|
190
|
+
imported?: (path: string, ms: number, def: CheckDef | null, loaded: boolean) => void
|
|
191
|
+
/** the toolkit's checks dir, a parameter for tests only */
|
|
192
|
+
builtinDir?: string
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function discoverChecks(root: string, o: DiscoverOpts = {}): Promise<DiscoveredCheck[]> {
|
|
196
|
+
const builtinDir = o.builtinDir ?? BUILTIN_CHECK_DIR
|
|
166
197
|
const builtins: string[] = []
|
|
167
|
-
walk(
|
|
198
|
+
walk(builtinDir, builtins)
|
|
168
199
|
const found: string[] = []
|
|
169
|
-
walk(root, found)
|
|
200
|
+
walk(root, found, SUFFIX, undefined, vendoredPrune(root))
|
|
170
201
|
const units = repoUnitFiles(root)
|
|
171
202
|
// the repo that HOSTS the toolkit walks over `src/checks/` too, and a file
|
|
172
203
|
// discovered twice is a duplicate id rather than a second check. Real paths,
|
|
173
204
|
// because a consumer reaches the same files through a linked node_modules
|
|
174
205
|
const real = (f: string): string => { try { return realpathSync(f) } catch { return f } }
|
|
175
206
|
const shipped = new Set(builtins.map(real))
|
|
207
|
+
// its own source, never an install of it
|
|
208
|
+
const inRoot = relative(real(root), real(builtinDir))
|
|
209
|
+
const hostsToolkit = !inRoot.startsWith('..') && !inRoot.split(/[\\/]/).includes('node_modules')
|
|
176
210
|
const all = found.filter((f) => !shipped.has(real(f))).sort()
|
|
177
211
|
builtins.sort()
|
|
178
212
|
const names = fallbackNames(root, all)
|
|
179
213
|
// a legacy script is left to its own reader, and never imported here
|
|
180
|
-
const own = all.filter((f) => !isLegacyCheckFile(f))
|
|
214
|
+
const own = all.filter((f) => o.skip?.(f) === true || !isLegacyCheckFile(f))
|
|
181
215
|
const base = (f: string): string => basename(f).slice(0, -SUFFIX.length)
|
|
182
216
|
|
|
183
217
|
const out: DiscoveredCheck[] = []
|
|
184
218
|
const ids = new Set<string>()
|
|
185
219
|
for (const f of [...builtins, ...own, ...units]) {
|
|
186
220
|
const isBuiltin = builtins.includes(f)
|
|
221
|
+
if (o.skip?.(f) === true) continue
|
|
187
222
|
let def: CheckDef | null
|
|
223
|
+
let loadError: string | null = null
|
|
224
|
+
const t = performance.now()
|
|
188
225
|
try {
|
|
189
226
|
def = await load(f)
|
|
227
|
+
o.imported?.(f, performance.now() - t, def, true)
|
|
190
228
|
} catch (e) {
|
|
229
|
+
o.imported?.(f, performance.now() - t, null, false)
|
|
191
230
|
// a file that does not load is ONE red row, never a dead inventory:
|
|
192
231
|
// every other check still runs, and this one says why in every run
|
|
193
|
-
|
|
232
|
+
loadError = (e as Error).message
|
|
233
|
+
def = unloadable(f, loadError)
|
|
194
234
|
}
|
|
195
235
|
if (def == null) continue
|
|
196
236
|
const rel = isBuiltin ? null : relative(root, f).replace(/\\/g, '/')
|
|
197
237
|
const fallback = isBuiltin
|
|
198
238
|
? (BUILTIN_ID_RE.exec(base(f))?.[1] ?? base(f))
|
|
199
239
|
: f.endsWith(UNIT_SUFFIX) ? unitBase(f) : names.get(f)!
|
|
200
|
-
|
|
240
|
+
let id = def.id ?? fallback
|
|
201
241
|
if (isBuiltin) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
242
|
+
const refused = loadError != null ? null
|
|
243
|
+
: id !== fallback ? `${f}: declares id '${id}' but its file name says '${fallback}' — a built-in id is its file name`
|
|
244
|
+
: (def.owner ?? '') === '' ? `${f}: a built-in check names the page that OWNS the invariant`
|
|
245
|
+
: RETIRED_CHECK_IDS[id] != null ? `${f}: id '${id}' is retired (${RETIRED_CHECK_IDS[id]}), and an id is never reused`
|
|
246
|
+
: null
|
|
247
|
+
// the toolkit's own repo dies on a refused built-in, a consumer
|
|
248
|
+
// only warns: it cannot fix the file, and must not lose its gate
|
|
249
|
+
if (refused != null && hostsToolkit) throw new Error(refused)
|
|
250
|
+
if (refused != null || loadError != null) {
|
|
251
|
+
def = hostsToolkit ? { ...def, owner: BUILTIN_OWNER } : { ...brokenBuiltin(f, refused ?? loadError!), owner: BUILTIN_OWNER }
|
|
252
|
+
id = fallback
|
|
253
|
+
}
|
|
205
254
|
} else if (id.startsWith('SK')) {
|
|
206
255
|
throw new Error(`${rel}: id '${id}' uses the reserved SK prefix — a repo check picks its own (e.g. RV${id.slice(-3)}) or plain words`)
|
|
207
256
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// does a gate run its repair pass (core/run-fixers.ts). A leaf, so the hub's
|
|
2
|
+
// run lanes read the same answer the gate acts on without loading the gate.
|
|
3
|
+
|
|
4
|
+
/** `fix` named wins. Unnamed: a whole gate repairs, a narrowed run does not */
|
|
5
|
+
export const fixPassRuns = (o: { fix?: boolean; onlyIds?: readonly string[] }): boolean => o.fix ?? (o.onlyIds ?? []).length === 0
|
|
@@ -20,10 +20,14 @@ import type { CheckCtx } from './check-ctx.ts'
|
|
|
20
20
|
import { checkUnitReader, type UnitReader } from '../release/unit-resolve.ts'
|
|
21
21
|
import { parseJsonc } from '../ts/jsonc.ts'
|
|
22
22
|
import { resolveTypecheckProjects } from '../ts/tsconfig-resolve.ts'
|
|
23
|
+
import { gateBuildInfoArgs } from '../ts/typecheck-cache.ts'
|
|
23
24
|
import { parseBunTestScript, suiteFlags } from '../suite/judge-suites.ts'
|
|
24
25
|
import { declaredTestUnits, siblingUnits, testCheckId, unitIgnores, unitTestBody, unitTsconfig, type TestUnitDecl } from '../suite/test-units.ts'
|
|
25
26
|
import { describeFirst, type TaskDef } from '../check.ts'
|
|
26
27
|
import type { RepoConfig } from '../repo-config.ts'
|
|
28
|
+
import { repoOff } from './when.ts'
|
|
29
|
+
import { bunfigDeclares, bunLintCmd } from './bun-builtins.ts'
|
|
30
|
+
import { normalizeVendored } from './vendored.ts'
|
|
27
31
|
|
|
28
32
|
/** how the floor reads the repo, by repo-relative path, plus the one lookup
|
|
29
33
|
* outside it: a binary on PATH */
|
|
@@ -64,10 +68,10 @@ export const unitTestDescription = (u: { id: string; rel: string }): string =>
|
|
|
64
68
|
export const unitTypecheckDescription = (u: { id: string; rel: string }): string =>
|
|
65
69
|
describeFirst(`tsc finds no type error under ${u.rel}`, `tsc finds no type error in unit ${u.id}`)
|
|
66
70
|
|
|
67
|
-
export type FloorSource = 'package.json script' | 'repo.config override' | 'built-in (shipkit)' | 'built-in (tsc)' | 'unit'
|
|
71
|
+
export type FloorSource = 'package.json script' | 'repo.config override' | 'built-in (shipkit)' | 'built-in (tsc)' | 'built-in (bun)' | 'unit'
|
|
68
72
|
export type FloorResolution = {
|
|
69
73
|
tasks: TaskDef[]
|
|
70
|
-
/** slots
|
|
74
|
+
/** slots the repo turned off (core/when.ts repoOff), always printed */
|
|
71
75
|
disabled: string[]
|
|
72
76
|
/** slots nothing could fill — always printed, never silent */
|
|
73
77
|
unfilled: { id: string; reason: string }[]
|
|
@@ -160,9 +164,14 @@ function tsconfigChain(rel: string, io: FloorReader): string[] {
|
|
|
160
164
|
}
|
|
161
165
|
|
|
162
166
|
// zero-config floor (O6): with no script, imports:check/cycles resolve to
|
|
163
|
-
// shipkit's own rvlib-ts-tools dep, typecheck to the repo's tsc
|
|
164
|
-
//
|
|
165
|
-
|
|
167
|
+
// shipkit's own rvlib-ts-tools dep, typecheck to the repo's tsc, lint to
|
|
168
|
+
// `bun lint` once bunfig.toml declares [lint]. Returns the task, or the
|
|
169
|
+
// reason the slot stays empty.
|
|
170
|
+
function builtinFloorTask(id: string, root: string, io: FloorReader, vendored: readonly string[]): { task: TaskDef; source: FloorSource } | string {
|
|
171
|
+
if (id === 'lint') {
|
|
172
|
+
if (!bunfigDeclares(io.text('bunfig.toml'), 'lint')) return `no 'lint' script and no [lint] section in bunfig.toml`
|
|
173
|
+
return { task: { id, cmd: bunLintCmd(vendored) }, source: 'built-in (bun)' }
|
|
174
|
+
}
|
|
166
175
|
if (id === 'imports:check' || id === 'cycles') {
|
|
167
176
|
let cli: string
|
|
168
177
|
// shipkit's OWN module, resolved from this file: no dependency to be
|
|
@@ -194,7 +203,7 @@ function builtinFloorTask(id: string, root: string, io: FloorReader): { task: Ta
|
|
|
194
203
|
const projects = resolveTypecheckProjects(tsconfig, io, root)
|
|
195
204
|
plain = projects.projects.length === 1 && projects.projects[0] === tsconfig && projects.missing.length === 0
|
|
196
205
|
} catch { plain = false }
|
|
197
|
-
if (plain) return { task: { id, cmd: [tsc, '--noEmit'] }, source: 'built-in (tsc)' }
|
|
206
|
+
if (plain) return { task: { id, cmd: [tsc, '--noEmit', ...gateBuildInfoArgs(tsconfig)] }, source: 'built-in (tsc)' }
|
|
198
207
|
const cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/typecheck-cli.ts'))
|
|
199
208
|
return { task: { id, cmd: ['bun', cli, tsc, tsconfig] }, source: 'built-in (tsc)' }
|
|
200
209
|
}
|
|
@@ -233,7 +242,8 @@ export function resolveFloor(c: RepoConfig, root: string, io: FloorReader): Floo
|
|
|
233
242
|
const notes: string[] = []
|
|
234
243
|
const subs = declaredTestUnits(c.units)
|
|
235
244
|
for (const id of FLOOR_IDS) {
|
|
236
|
-
|
|
245
|
+
// a slot the repo turned off resolves to no task, whichever spelling said so
|
|
246
|
+
const override = repoOff(c, id) != null ? false : c.defaults?.[id]
|
|
237
247
|
const description = slotDescription(id, subs.length > 0)
|
|
238
248
|
const slot = slotBody(id, override, scripts)
|
|
239
249
|
if (slot.kind === 'off') { disabled.push(id); continue }
|
|
@@ -249,7 +259,7 @@ export function resolveFloor(c: RepoConfig, root: string, io: FloorReader): Floo
|
|
|
249
259
|
return override == null || override === false ? t : { ...t, ...override, id }
|
|
250
260
|
}
|
|
251
261
|
if (slot.kind === 'script') { tasks.push(decorate({ id, cmd: scriptCmd(c, id, slot.name, slot.body, notes, subs) })); sources[id] = 'package.json script'; continue }
|
|
252
|
-
const b = builtinFloorTask(id, root, io)
|
|
262
|
+
const b = builtinFloorTask(id, root, io, (c.vendored ?? []).map(normalizeVendored))
|
|
253
263
|
// a routed script with nothing behind it measures NOTHING, and the
|
|
254
264
|
// reason must say that rather than `no '<id>' script`, which is false
|
|
255
265
|
if (typeof b === 'string') unfilled.push({ id, reason: slot.kind === 'routed-empty' ? `script '${id}' routes to shipkit and there is no '${rawName(id)}' body to run` : b })
|
package/src/core/git-root.ts
CHANGED
|
@@ -58,14 +58,23 @@ export function probeGitRoot(at: string): Probe {
|
|
|
58
58
|
return p
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
/** the variables that let git find a repo with no `.git` in reach
|
|
61
|
+
/** the variables that let git find a repo with no `.git` in reach. They name
|
|
62
|
+
* ONE repo for the whole process (git sets GIT_DIR for every hook in a linked
|
|
63
|
+
* worktree), so they only answer for a directory with no `.git` of its own:
|
|
64
|
+
* any other directory is asked with them stripped, or the hook's repo would
|
|
65
|
+
* claim the house and every repo the process looks at */
|
|
62
66
|
const LOCATING_ENV = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR']
|
|
67
|
+
const locatingEnvSet = (): boolean => LOCATING_ENV.some((k) => (process.env[k] ?? '') !== '')
|
|
68
|
+
const discoveryEnv = (): Record<string, string | undefined> => {
|
|
69
|
+
const env = { ...process.env }
|
|
70
|
+
for (const k of LOCATING_ENV) delete env[k]
|
|
71
|
+
return env
|
|
72
|
+
}
|
|
63
73
|
|
|
64
|
-
/** git's discovery walks up looking for `.git`. With none at or above
|
|
65
|
-
*
|
|
66
|
-
*
|
|
74
|
+
/** git's discovery walks up looking for `.git`. With none at or above, its
|
|
75
|
+
* answer is already known, and a gate or a test suite that probes fresh temp
|
|
76
|
+
* dirs would spawn a process per dir for it */
|
|
67
77
|
function nothingInReach(dir: string): boolean {
|
|
68
|
-
if (LOCATING_ENV.some((k) => (process.env[k] ?? '') !== '')) return false
|
|
69
78
|
let cur: string
|
|
70
79
|
try {
|
|
71
80
|
cur = realpathSync(dir)
|
|
@@ -81,12 +90,11 @@ function nothingInReach(dir: string): boolean {
|
|
|
81
90
|
}
|
|
82
91
|
|
|
83
92
|
/** the common case answered from the disk: `.git` is a plain directory right
|
|
84
|
-
* at `dir`, which git describes as exactly that. Anything unusual (a
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
93
|
+
* at `dir`, which git describes as exactly that. Anything unusual (a gitfile
|
|
94
|
+
* or a symlinked `.git`, a shared common dir, a config that relocates the
|
|
95
|
+
* work tree, marks it bare or includes another file, a `.git` owned by
|
|
96
|
+
* someone else) is left to git */
|
|
88
97
|
function plainRoot(dir: string): GitRoot | null {
|
|
89
|
-
if (LOCATING_ENV.some((k) => (process.env[k] ?? '') !== '')) return null
|
|
90
98
|
try {
|
|
91
99
|
const top = realpathSync(dir)
|
|
92
100
|
const gitDir = join(top, '.git')
|
|
@@ -102,8 +110,9 @@ function plainRoot(dir: string): GitRoot | null {
|
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
function ask(dir: string): Probe {
|
|
105
|
-
|
|
106
|
-
|
|
113
|
+
const none = nothingInReach(dir)
|
|
114
|
+
if (none && !locatingEnvSet()) return { root: null, error: 'fatal: not a git repository (or any of the parent directories): .git' }
|
|
115
|
+
const plain = none ? null : plainRoot(dir)
|
|
107
116
|
if (plain != null) return { root: plain, error: '' }
|
|
108
117
|
let p: ReturnType<typeof Bun.spawnSync>
|
|
109
118
|
try {
|
|
@@ -111,6 +120,7 @@ function ask(dir: string): Probe {
|
|
|
111
120
|
cwd: dir,
|
|
112
121
|
stdout: 'pipe',
|
|
113
122
|
stderr: 'pipe',
|
|
123
|
+
env: none ? { ...process.env } : discoveryEnv(),
|
|
114
124
|
})
|
|
115
125
|
} catch (e) {
|
|
116
126
|
return { root: null, error: e instanceof Error ? e.message : String(e) }
|
package/src/core/job-run.ts
CHANGED
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
// the machine's one background turn here, so the turn outlives a daemon
|
|
5
5
|
// reload the same as the run does.
|
|
6
6
|
import { basename } from 'path'
|
|
7
|
-
import {
|
|
7
|
+
import { installOneLib } from './one-lib.ts'
|
|
8
8
|
import { appendJobCheck, type JobKind, type JobOpts } from './jobs.ts'
|
|
9
9
|
import { autoGatePool, takeSlot } from './slots.ts'
|
|
10
10
|
import type { CheckEvent } from './check-runner.ts'
|
|
11
11
|
|
|
12
12
|
export async function runJob(root: string, id: string, kind: JobKind, job: JobOpts, print: (l: string) => void): Promise<number> {
|
|
13
13
|
// before the repo config loads: it imports this copy of the lib, never its own install
|
|
14
|
-
if (job.oneLib === true)
|
|
14
|
+
if (job.oneLib === true) installOneLib()
|
|
15
15
|
const turn = job.turn === true ? await takeSlot(autoGatePool(), { repo: basename(root), check: 'auto gate' }, { pollMs: 250 }) : null
|
|
16
16
|
try {
|
|
17
17
|
const onCheck = (e: CheckEvent): void => appendJobCheck(root, id, e)
|
package/src/core/layout.ts
CHANGED
|
@@ -19,6 +19,9 @@ export const LAYOUT = {
|
|
|
19
19
|
identity: sk('identity'),
|
|
20
20
|
assets: sk('assets'),
|
|
21
21
|
comfy: sk('comfy'),
|
|
22
|
+
// a bug's repro test once its fix lands: kept for the record, never run
|
|
23
|
+
// (bun test skips dot dirs), moved into the suite only when it earns its wait
|
|
24
|
+
repros: sk('repros'),
|
|
22
25
|
// local: drafts, private notes, venue listings until they go live
|
|
23
26
|
private: sk('private'),
|
|
24
27
|
reflections: sk('reflections'),
|
|
@@ -41,7 +44,7 @@ export const LAYOUT = {
|
|
|
41
44
|
} as const
|
|
42
45
|
|
|
43
46
|
/** the children the SK004 block re-includes: everything else under `.shipkit/` stays local */
|
|
44
|
-
export const TRACKED_DIRS = [LAYOUT.asks, LAYOUT.hooks, LAYOUT.identity, LAYOUT.assets, LAYOUT.comfy] as const
|
|
47
|
+
export const TRACKED_DIRS = [LAYOUT.asks, LAYOUT.hooks, LAYOUT.identity, LAYOUT.assets, LAYOUT.comfy, LAYOUT.repros] as const
|
|
45
48
|
|
|
46
49
|
/** the machine-global private folder, under $HOME: one keyword list and one
|
|
47
50
|
* allowlist per machine, never copied into a repo */
|
|
@@ -221,3 +221,43 @@ export function dropConfigKey(text: string, key: string): string | null {
|
|
|
221
221
|
while (from > 0 && lines[from - 1]!.trim().startsWith('//') && !/^\/\/ (>>>|<<<) shipkit/.test(lines[from - 1]!.trim())) from--
|
|
222
222
|
return [...lines.slice(0, from), ...lines.slice(end + 1)].join('\n')
|
|
223
223
|
}
|
|
224
|
+
|
|
225
|
+
const esc = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
226
|
+
|
|
227
|
+
/** the config text without one entry of a top level object key, as in
|
|
228
|
+
* `disable` inside `hooks` or `lint: false` inside `defaults`, plus the words
|
|
229
|
+
* of the comment written right above the entry, which are the closest thing
|
|
230
|
+
* to a reason the entry ever had. `value` is the entry's value as a regex
|
|
231
|
+
* source, so only the shape the caller expects is touched. The parent goes
|
|
232
|
+
* too once nothing is left in it. Null when the parent or the entry is not
|
|
233
|
+
* there exactly once, or sits in a shape this reader does not follow */
|
|
234
|
+
export function dropConfigEntry(text: string, parent: string, entry: string, value: string): { text: string; comment: string | null } | null {
|
|
235
|
+
const lines = text.split('\n')
|
|
236
|
+
const at = lines.map((l, i) => (new RegExp(`^\\s*${esc(parent)}\\s*:`).test(l) ? i : -1)).filter((i) => i !== -1)
|
|
237
|
+
if (at.length !== 1) return null
|
|
238
|
+
const start = at[0]!
|
|
239
|
+
const end = statementEnd(lines, start)
|
|
240
|
+
const key = `(['"]?)${esc(entry)}\\1\\s*:\\s*(?:${value})`
|
|
241
|
+
if (start === end) {
|
|
242
|
+
// one line: `hooks: { blockedPaths: [...], disable: ['leak-guard'] },`
|
|
243
|
+
const line = lines[start]!
|
|
244
|
+
const hits = line.match(new RegExp(`(?<![\\w$])${key}`, 'g')) ?? []
|
|
245
|
+
if (hits.length !== 1) return null
|
|
246
|
+
let next = line.replace(new RegExp(`,\\s*${key}`), '')
|
|
247
|
+
if (next === line) next = line.replace(new RegExp(`(?<![\\w$])${key}\\s*,\\s*`), '')
|
|
248
|
+
if (next === line) next = line.replace(new RegExp(`(?<![\\w$])${key}`), '')
|
|
249
|
+
const out = [...lines.slice(0, start), next, ...lines.slice(start + 1)].join('\n')
|
|
250
|
+
return { text: /:\s*\{\s*\}\s*,?\s*(\/\/.*)?$/.test(next) ? (dropConfigKey(out, parent) ?? out) : out, comment: null }
|
|
251
|
+
}
|
|
252
|
+
const inner = lines.map((l, i) => (i > start && i <= end && new RegExp(`^\\s*${key}\\s*,?\\s*$`).test(l) ? i : -1)).filter((i) => i !== -1)
|
|
253
|
+
if (inner.length !== 1) return null
|
|
254
|
+
const i = inner[0]!
|
|
255
|
+
let from = i
|
|
256
|
+
while (from - 1 > start && lines[from - 1]!.trim().startsWith('//')) from--
|
|
257
|
+
const comment = lines.slice(from, i).map((l) => l.trim().replace(/^\/\/\s?/, '')).join(' ').trim()
|
|
258
|
+
const kept = [...lines.slice(0, from), ...lines.slice(i + 1)]
|
|
259
|
+
const body = kept.slice(start + 1, end - (i - from + 1))
|
|
260
|
+
const empty = body.every((l) => l.trim() === '' || l.trim().startsWith('//'))
|
|
261
|
+
const out = kept.join('\n')
|
|
262
|
+
return { text: empty ? (dropConfigKey(out, parent) ?? out) : out, comment: comment === '' ? null : comment }
|
|
263
|
+
}
|
package/src/core/one-lib.ts
CHANGED
|
@@ -102,7 +102,7 @@ const LOADERS: Record<string, 'ts' | 'tsx' | 'js' | 'jsx'> = { '.ts': 'ts', '.ts
|
|
|
102
102
|
let installed = false
|
|
103
103
|
|
|
104
104
|
/** route every import of the package to this process's copy. Idempotent */
|
|
105
|
-
export function
|
|
105
|
+
export function installOneLib(root: string = LIB_ROOT): void {
|
|
106
106
|
if (installed) return
|
|
107
107
|
installed = true
|
|
108
108
|
const exports = readExports(root)
|
|
@@ -119,7 +119,9 @@ export function useOneLib(root: string = LIB_ROOT): void {
|
|
|
119
119
|
build.onLoad({ filter: /\/(node_modules\/rvlib-shipkit|install\/cache\/rvlib-shipkit@[^/]*)\// }, (a) => {
|
|
120
120
|
const loader = LOADERS[extname(a.path)] ?? 'ts'
|
|
121
121
|
const sub = foreignLibPath(a.path, root)
|
|
122
|
-
|
|
122
|
+
// the host copy itself lives under node_modules when it is an
|
|
123
|
+
// install: its path can carry importFresh's `?v=` stamp
|
|
124
|
+
if (sub == null) return { contents: readFileSync(a.path.replace(/\?.*$/, ''), 'utf8'), loader }
|
|
123
125
|
return reexport(join(root, sub))
|
|
124
126
|
})
|
|
125
127
|
for (const name of SHARED) {
|
|
@@ -131,7 +133,7 @@ export function useOneLib(root: string = LIB_ROOT): void {
|
|
|
131
133
|
const skip = [...new Set([host, real(host)])].map((h) => `(?!${esc(h)}/)`).join('')
|
|
132
134
|
build.onLoad({ filter: new RegExp(`^${skip}.*/node_modules/${esc(name)}/`) }, (a) => {
|
|
133
135
|
const sub = foreignSharedPath(a.path, name, host)
|
|
134
|
-
if (sub == null) return { contents: readFileSync(a.path, 'utf8'), loader: LOADERS[extname(a.path)] ?? 'js' }
|
|
136
|
+
if (sub == null) return { contents: readFileSync(a.path.replace(/\?.*$/, ''), 'utf8'), loader: LOADERS[extname(a.path)] ?? 'js' }
|
|
135
137
|
return reexport(sharedTarget(a.path, sub, name, host))
|
|
136
138
|
})
|
|
137
139
|
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// The deny is the floor and the hook is the messenger. When the hook cannot
|
|
15
15
|
// run, enforcement degrades to terse rather than to nothing.
|
|
16
16
|
//
|
|
17
|
-
// Subprocesses are NOT intercepted (verified): shipkit spawning
|
|
17
|
+
// Subprocesses are NOT intercepted (verified): shipkit spawning tsc or
|
|
18
18
|
// `bun run test:raw` from inside a permitted `shipkit ci` is invisible to
|
|
19
19
|
// these rules, which is exactly what makes the gate itself still work.
|
|
20
20
|
|
|
@@ -24,7 +24,7 @@ export type RawRunner = {
|
|
|
24
24
|
/** the floor slot it answers, so ONE exemption governs the script name and
|
|
25
25
|
* the shell command together: a repo that keeps `test` raw is not then
|
|
26
26
|
* refused `bun test` */
|
|
27
|
-
slot: 'test' | '
|
|
27
|
+
slot: 'test' | 'typecheck'
|
|
28
28
|
/** the shipkit command that answers the same question, cache and all */
|
|
29
29
|
use: string
|
|
30
30
|
/** the narrower way in, when there is one: iterating on a single repro
|
|
@@ -47,25 +47,118 @@ export const RAW_RUNNERS: readonly RawRunner[] = [
|
|
|
47
47
|
{ head: 'bunx tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
|
|
48
48
|
{ head: 'npx tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
|
|
49
49
|
{ head: 'tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
|
|
50
|
-
{ head: 'bunx oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
|
|
51
|
-
{ head: 'npx oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
|
|
52
|
-
{ head: 'oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
|
|
53
|
-
{ head: 'npx eslint', slot: 'lint', use: 'shipkit ci --only lint' },
|
|
54
|
-
{ head: 'eslint', slot: 'lint', use: 'shipkit ci --only lint' },
|
|
55
50
|
]
|
|
56
51
|
|
|
57
|
-
// the FORMATTER
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// formats before it gates, which is the only reason the reflex matters at all.
|
|
52
|
+
// the LINTER and the FORMATTER are deliberately absent. `bun lint` and
|
|
53
|
+
// `bun fmt` are the house way in: bun's built-ins cover a whole repo in well
|
|
54
|
+
// under a second, so a cache has nothing to save, and the formatter is a
|
|
55
|
+
// manual tool the gate never runs.
|
|
62
56
|
|
|
63
57
|
/** the `permissions.deny` entries, in the syntax Claude Code matches:
|
|
64
58
|
* `Bash(<head>*)` is a prefix match, and a compound command is checked one
|
|
65
59
|
* segment at a time, so `cd x && bun test` is caught too */
|
|
66
60
|
export const denyRules = (exemptSlots: Iterable<string> = []): string[] => {
|
|
67
61
|
const off = new Set(exemptSlots)
|
|
68
|
-
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_RULE]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// `git stash` in a tree that other agents share takes every agent's
|
|
66
|
+
// uncommitted work, not just the caller's (identity/how-we-commit.md: never
|
|
67
|
+
// stash as a side effect). The deny row is the floor. Claude Code matches it
|
|
68
|
+
// per subcommand of a compound and inside `$(...)`, and never inside
|
|
69
|
+
// `bash -c '...'`, after git's own global options (`git -C dir stash`), or
|
|
70
|
+
// through `eval`: the guard closes those (stashCallIn).
|
|
71
|
+
export const GIT_STASH_HEAD = 'git stash'
|
|
72
|
+
export const GIT_STASH_RULE = `Bash(${GIT_STASH_HEAD}*)`
|
|
73
|
+
|
|
74
|
+
/** the stash subcommands that only read, which the guard lets through */
|
|
75
|
+
const STASH_READS = new Set(['list', 'show'])
|
|
76
|
+
|
|
77
|
+
/** git's global options that take their value as the NEXT word */
|
|
78
|
+
const GIT_VALUE_OPTS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--exec-path'])
|
|
79
|
+
|
|
80
|
+
const unquote = (w: string): string => w.replace(/^(['"])(.*)\1$/, '$2')
|
|
81
|
+
|
|
82
|
+
/** a segment's words, a quoted run kept as one word, subshell and group
|
|
83
|
+
* brackets dropped */
|
|
84
|
+
const quotedWords = (seg: string): string[] =>
|
|
85
|
+
(seg.match(/(?:'[^']*'|"(?:[^"\\]|\\.)*"|[^\s'"])+/g) ?? []).map((w) => w.replace(/^[({]+|[)}]+$/g, '')).filter((w) => w !== '')
|
|
86
|
+
|
|
87
|
+
/** the bodies of `$(...)` and backtick substitutions outside single quotes,
|
|
88
|
+
* where a shell runs them */
|
|
89
|
+
function substitutions(seg: string): string[] {
|
|
90
|
+
const out: string[] = []
|
|
91
|
+
let single = false
|
|
92
|
+
for (let i = 0; i < seg.length; i++) {
|
|
93
|
+
const ch = seg[i]!
|
|
94
|
+
if (ch === "'") { single = !single; continue }
|
|
95
|
+
if (single) continue
|
|
96
|
+
if (ch === '`') {
|
|
97
|
+
const end = seg.indexOf('`', i + 1)
|
|
98
|
+
if (end < 0) break
|
|
99
|
+
out.push(seg.slice(i + 1, end))
|
|
100
|
+
i = end
|
|
101
|
+
} else if (ch === '$' && seg[i + 1] === '(') {
|
|
102
|
+
let depth = 0
|
|
103
|
+
let j = i + 1
|
|
104
|
+
for (; j < seg.length; j++) {
|
|
105
|
+
if (seg[j] === '(') depth++
|
|
106
|
+
else if (seg[j] === ')' && --depth === 0) break
|
|
107
|
+
}
|
|
108
|
+
out.push(seg.slice(i + 2, j))
|
|
109
|
+
i = j
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** the script a segment hands to another shell: `bash -c '<script>'`,
|
|
116
|
+
* `sh -c`, `zsh -c`, or `eval <words>` */
|
|
117
|
+
function innerScript(words: string[], seg: string): string | null {
|
|
118
|
+
const program = words[0]?.split('/').pop() ?? ''
|
|
119
|
+
if (program === 'eval') {
|
|
120
|
+
const all = quotedWords(seg)
|
|
121
|
+
return all.slice(all.indexOf(words[0]!) + 1).map(unquote).join(' ')
|
|
122
|
+
}
|
|
123
|
+
if (!/^(?:ba|z|da|k)?sh$/.test(program)) return null
|
|
124
|
+
const m = /\s-[a-z]*c[a-z]*\s+(?:'([^']*)'|"((?:[^"\\]|\\.)*)"|(\S+))/.exec(seg)
|
|
125
|
+
return m == null ? null : (m[1] ?? m[2] ?? m[3] ?? null)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** the segment of a command that runs a stash which is not a read, or null.
|
|
129
|
+
* Like rawRunnerHit it reads only what a shell would RUN: a heredoc body and
|
|
130
|
+
* a quoted argument are data, so a commit message that mentions the command
|
|
131
|
+
* is never refused. */
|
|
132
|
+
export function stashCallIn(command: string, depth = 0): string | null {
|
|
133
|
+
if (depth > 3) return null
|
|
134
|
+
for (const seg of shellSegments(command)) {
|
|
135
|
+
for (const inner of substitutions(seg)) {
|
|
136
|
+
const hit = stashCallIn(inner, depth + 1)
|
|
137
|
+
if (hit != null) return hit
|
|
138
|
+
}
|
|
139
|
+
const words = programWords(seg)
|
|
140
|
+
const script = innerScript(words, seg)
|
|
141
|
+
if (script != null) {
|
|
142
|
+
const hit = stashCallIn(script, depth + 1)
|
|
143
|
+
if (hit != null) return hit
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
if ((words[0]?.split('/').pop() ?? '') !== 'git') continue
|
|
147
|
+
// re-split with quotes kept whole: `git -C "a dir" stash`
|
|
148
|
+
const all = quotedWords(seg)
|
|
149
|
+
const g = all.findIndex((w) => w.split('/').pop() === 'git')
|
|
150
|
+
const args = all.slice(g + 1)
|
|
151
|
+
let i = 0
|
|
152
|
+
while (i < args.length && args[i]!.startsWith('-')) i += GIT_VALUE_OPTS.has(args[i]!) ? 2 : 1
|
|
153
|
+
if (unquote(args[i] ?? '') !== 'stash') continue
|
|
154
|
+
if (!STASH_READS.has(unquote(args[i + 1] ?? ''))) return seg.trim()
|
|
155
|
+
}
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** what a stash refusal says: why, what to do instead, how to lift it */
|
|
160
|
+
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).`
|
|
69
162
|
}
|
|
70
163
|
|
|
71
164
|
/** what a refusal says. Never just the ban: a refusal with no replacement
|