rvlib-shipkit 0.13.0 → 0.16.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 +163 -0
- package/README.md +6 -22
- package/package.json +2 -2
- package/src/assets/kits/identity-kit.ts +1 -1
- package/src/assets/panels/geometry-rects.ts +3 -17
- package/src/assets/server/studio-server.ts +1 -1
- package/src/cli/cmd_Bump.ts +52 -0
- package/src/cli/cmd_Check.ts +2 -2
- package/src/cli/cmd_Ci.ts +10 -2
- package/src/cli/cmd_Commands.ts +126 -0
- package/src/cli/cmd_Hooks.ts +66 -0
- package/src/cli/cmd_Mv.ts +29 -6
- package/src/cli/cmd_Release.ts +41 -0
- package/src/cli/cmd_Releases.ts +42 -0
- package/src/cli/cmd_Rules.ts +20 -6
- package/src/cli/cmd_Start.ts +2 -2
- package/src/cli.ts +14 -1
- package/src/collections/posts.ts +1 -1
- package/src/collections/providers.ts +1 -1
- package/src/contracts/contract-types.ts +5 -2
- package/src/contracts/contracts-engine.ts +13 -3
- package/src/contracts/manifests/discord.contract.ts +2 -2
- package/src/contracts/manifests/identity.contract.ts +1 -1
- package/src/contracts/manifests/itch.contract.ts +17 -2
- package/src/core/canonical-scripts.ts +9 -14
- package/src/core/check-suite.ts +1 -1
- package/src/core/discover-checks.ts +21 -6
- package/src/core/run-check.ts +69 -21
- package/src/core/run-ci.ts +75 -9
- package/src/core/run-fixers.ts +144 -0
- package/src/core/write-status.ts +14 -5
- package/src/hooks/commit-message.ts +42 -0
- package/src/hooks/leak-guard.ts +0 -0
- package/src/hooks/run-hooks.ts +131 -0
- package/src/init-repo.ts +13 -5
- package/src/reflect/prompt-corpus.ts +3 -4
- package/src/release/changelog.ts +53 -0
- package/src/release/git-facts.ts +133 -0
- package/src/release/run-bump.ts +419 -0
- package/src/release/run-release.ts +267 -0
- package/src/release/run-releases.ts +230 -0
- package/src/release/unit-gate.ts +67 -0
- package/src/release/units.ts +246 -0
- package/src/repo-config.ts +66 -2
- package/src/rules/SK001-package-json-shape.ts +39 -17
- package/src/rules/SK002-contracts-fulfillness.ts +1 -0
- package/src/rules/SK003-claude-md-shape.ts +2 -1
- package/src/rules/SK004-gitignore-policy.ts +1 -0
- package/src/rules/SK005-rvlib-versions.ts +22 -3
- package/src/rules/SK006-npm-publish-safety.ts +1 -0
- package/src/rules/SK007-github-visibility.ts +5 -1
- package/src/rules/SK008-reviewer-agent.ts +99 -0
- package/src/rules/SK009-commit-ai-tells.ts +66 -0
- package/src/rules/SK010-undeclared-release-unit.ts +114 -0
- package/src/rules/rule-registry.ts +28 -6
- package/src/rules/rule.ts +8 -1
- package/src/rules/run-fix.ts +15 -8
- package/src/serve/hub-app/col-card.tsx +2 -3
- package/src/serve/hub-app/hub-app.tsx +6 -8
- package/src/serve/hub-app/hub-st.ts +4 -7
- package/src/serve/hub-app/media-view.tsx +2 -2
- package/src/serve/run-serve.ts +4 -5
- package/src/serve/serve-hub.ts +2 -3
- package/src/shipkit.ts +9 -1
- package/src/tui/checks-panel.ts +1 -1
- package/src/tui/run-tui.ts +2 -3
- package/src/tui/tasks-panel.ts +10 -4
- package/src/tui/tui-store.ts +2 -2
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// SK008 — the mandated reviewer agent type. A repo that declares
|
|
2
|
+
// `reviewer: { name, brief }` must carry `.claude/agents/<name>.md`: an agent
|
|
3
|
+
// definition whose system prompt POINTS at the brief file (ONE home for the
|
|
4
|
+
// text, so review framing can never drift per repo) and whose `tools:` line
|
|
5
|
+
// stays inside a read-only ALLOWLIST — a reviewer's product is findings, never
|
|
6
|
+
// fixes, and a denylist of today's edit-tool names would wave through anything
|
|
7
|
+
// that edits by proxy. An absent `tools:` line is the worst case of all: the
|
|
8
|
+
// harness then inherits every tool. The brief itself must exist. No
|
|
9
|
+
// declaration = not a reviewer repo, and the rule passes silently.
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
11
|
+
import { homedir } from 'os'
|
|
12
|
+
import { dirname, isAbsolute, join } from 'path'
|
|
13
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
14
|
+
import type { FixResult, Rule, RuleEnv } from './rule.ts'
|
|
15
|
+
|
|
16
|
+
/** default read-only toolset — an ALLOWLIST, not a denylist: a tool that can
|
|
17
|
+
* edit by proxy (spawning a subagent, an MCP writer) is exactly what a
|
|
18
|
+
* denylist of today's edit-tool names would wave through */
|
|
19
|
+
export const REVIEWER_TOOLS = ['Read', 'Grep', 'Glob', 'Bash']
|
|
20
|
+
|
|
21
|
+
export const reviewerAgentPath = (name: string): string => `.claude/agents/${name}.md`
|
|
22
|
+
|
|
23
|
+
// the invariants, as a pure function over the file text — the rule below and
|
|
24
|
+
// any repo-local linter call THIS, so there is one definition of "a valid
|
|
25
|
+
// reviewer agent file" and not one per caller
|
|
26
|
+
export function reviewerAgentIssues(text: string, reviewer: NonNullable<RepoConfig['reviewer']>): string[] {
|
|
27
|
+
const issues: string[] = []
|
|
28
|
+
const allowed = reviewer.tools ?? REVIEWER_TOOLS
|
|
29
|
+
const declared = text.match(/^name:\s*(\S+)\s*$/m)?.[1]
|
|
30
|
+
if (declared !== reviewer.name)
|
|
31
|
+
issues.push(`frontmatter name is ${declared == null ? 'missing' : `'${declared}'`} — it must be '${reviewer.name}', the type spawners ask for`)
|
|
32
|
+
if (!text.includes(reviewer.brief))
|
|
33
|
+
issues.push(`never points at the brief '${reviewer.brief}' — the agent's whole job is to load it before judging anything`)
|
|
34
|
+
const tools = text.match(/^tools:\s*(.+)$/m)?.[1]
|
|
35
|
+
if (tools == null) issues.push(`has no inline 'tools:' allowlist — omitting it inherits every tool, edit tools included`)
|
|
36
|
+
else
|
|
37
|
+
for (const t of tools.split(',').map((x) => x.trim()).filter(Boolean))
|
|
38
|
+
if (!allowed.includes(t)) issues.push(`grants '${t}', outside the allowlist (${allowed.join(', ')}) — a reviewer reports findings, it never edits, directly or by proxy`)
|
|
39
|
+
return issues
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** the brief must EXIST: a reviewer whose mandated first action is reading a
|
|
43
|
+
* missing file flounders silently, which is the one thing this rule prevents */
|
|
44
|
+
export function briefMissing(root: string, brief: string): boolean {
|
|
45
|
+
const p = brief.startsWith('~/') ? join(homedir(), brief.slice(2)) : isAbsolute(brief) ? brief : join(root, brief)
|
|
46
|
+
return !existsSync(p)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function canonicalAgent(reviewer: NonNullable<RepoConfig['reviewer']>): string {
|
|
50
|
+
return `---
|
|
51
|
+
name: ${reviewer.name}
|
|
52
|
+
description: The mandated critical reviewer for this repo. Use before every commit, and whenever a diff needs judging against what was actually asked for. Judges whether a change should exist at all, not just whether it is correct.
|
|
53
|
+
tools: ${(reviewer.tools ?? REVIEWER_TOOLS).join(', ')}
|
|
54
|
+
model: opus
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
You are this repo's critical reviewer. You have no Edit or Write tool on
|
|
58
|
+
purpose: your product is findings, never fixes. Bash is for READING the state
|
|
59
|
+
of things (\`git diff\`, the gates, running a claim to check it) — never for
|
|
60
|
+
writing files by another route.
|
|
61
|
+
|
|
62
|
+
FIRST ACTION, always: read \`${reviewer.brief}\` and follow it exactly. It tells
|
|
63
|
+
you what else to read, when to refuse the job, the order of the questions, and
|
|
64
|
+
the output shape.
|
|
65
|
+
|
|
66
|
+
That brief overrides any habit of reviewing a diff for correctness alone.
|
|
67
|
+
`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const reviewerAgent: Rule = {
|
|
71
|
+
id: 'SK008',
|
|
72
|
+
blocking: true,
|
|
73
|
+
title: 'reviewer agent',
|
|
74
|
+
owner: 'identity/how-we-work.md',
|
|
75
|
+
lint: (t, env) => {
|
|
76
|
+
const r = env.config.reviewer
|
|
77
|
+
if (r == null) return t.ok('no reviewer agent declared (repo.config reviewer:) — skipped')
|
|
78
|
+
const rel = reviewerAgentPath(r.name)
|
|
79
|
+
const p = join(env.root, rel)
|
|
80
|
+
if (briefMissing(env.root, r.brief)) t.fail(`the declared brief '${r.brief}' does not exist — every review's mandated first action would fail`)
|
|
81
|
+
if (!existsSync(p)) return t.fail(`${rel} missing — the mandated reviewer type is unavailable, so every review runs on whatever framing the spawner improvised`)
|
|
82
|
+
const issues = reviewerAgentIssues(readFileSync(p, 'utf8'), r)
|
|
83
|
+
for (const i of issues) t.fail(`${rel}: ${i}`)
|
|
84
|
+
if (issues.length === 0 && !t.failed) t.ok(`${rel} → ${r.brief}`)
|
|
85
|
+
},
|
|
86
|
+
// create-if-missing only: an existing definition may carry a repo's own
|
|
87
|
+
// tuned prompt, and silently rewriting that would cost more than the
|
|
88
|
+
// finding it repairs. Violations in a present file stay a loud FAIL.
|
|
89
|
+
fix: (env: RuleEnv): FixResult => {
|
|
90
|
+
const r = env.config.reviewer
|
|
91
|
+
if (r == null) return { changed: false, summary: 'no reviewer declared — nothing to write' }
|
|
92
|
+
const rel = reviewerAgentPath(r.name)
|
|
93
|
+
const p = join(env.root, rel)
|
|
94
|
+
if (existsSync(p)) return { changed: false, summary: `${rel} exists — remaining findings are about its content, fix them by hand` }
|
|
95
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
96
|
+
writeFileSync(p, canonicalAgent(r))
|
|
97
|
+
return { changed: true, summary: `${rel} written — points at ${r.brief}, no edit tools` }
|
|
98
|
+
},
|
|
99
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// SK009 — commit AI tells (identity/how-we-commit.md): a repo that will be
|
|
2
|
+
// seen must not read as AI-driven. Prevention lives at the source (the
|
|
3
|
+
// attribution setting + the standing from-commit-one ban, which binds every
|
|
4
|
+
// repo because history follows a repo public and is never rewritten); this
|
|
5
|
+
// rule is the declared-public tripwire: the FULL git log scanned for AI
|
|
6
|
+
// attribution and dash punctuation, plus the tracked settings file that
|
|
7
|
+
// silences attribution. Declared-private repos idle with an ok that RESTATES
|
|
8
|
+
// the ban — a green line must never teach the wrong rule. The remedy for
|
|
9
|
+
// pushed history is the owner's decision, never an automatic rewrite; only
|
|
10
|
+
// an unpushed tip commit may be amended, and the fails say so.
|
|
11
|
+
import { readFileSync } from 'fs'
|
|
12
|
+
import { join } from 'path'
|
|
13
|
+
import { hasAiAttribution, hasDashPunctuation } from '../hooks/commit-message.ts'
|
|
14
|
+
import type { Rule } from './rule.ts'
|
|
15
|
+
|
|
16
|
+
// the documented silencer: .claude/settings.json attribution.commit/.pr both
|
|
17
|
+
// empty — the harness then never writes the trailer or the PR footer
|
|
18
|
+
const attributionSilenced = (root: string): boolean => {
|
|
19
|
+
try {
|
|
20
|
+
const s = JSON.parse(readFileSync(join(root, '.claude', 'settings.json'), 'utf8')) as { attribution?: { commit?: string; pr?: string } }
|
|
21
|
+
return s.attribution != null && s.attribution.commit === '' && s.attribution.pr === ''
|
|
22
|
+
} catch {
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const commitAiTells: Rule = {
|
|
28
|
+
id: 'SK009',
|
|
29
|
+
blocking: true,
|
|
30
|
+
title: 'commit ai tells',
|
|
31
|
+
owner: 'identity/how-we-commit.md',
|
|
32
|
+
lint: async (t, env) => {
|
|
33
|
+
const declared = env.config.visibility ?? 'private'
|
|
34
|
+
if (declared !== 'public')
|
|
35
|
+
return t.ok(`declared ${declared} — the no-AI-attribution ban still applies from commit one (identity/how-we-commit.md); the full-history scan arms at visibility: 'public'`)
|
|
36
|
+
// %x1f separates hash from body, %x1e terminates the record — commit
|
|
37
|
+
// bodies are free text, so only unprintable separators are safe
|
|
38
|
+
const log = await t.exec(['git', 'log', '--format=%h%x1f%B%x1e'], { cwd: env.root, timeoutMs: 30_000 })
|
|
39
|
+
if (log.code !== 0) {
|
|
40
|
+
const first = log.out.trim().split('\n').find((l) => l.trim() !== '')?.slice(0, 120) ?? `exit ${log.code}`
|
|
41
|
+
return void t.lines.push({ kind: 'skip', text: `commit history unverifiable — git says: ${first}` })
|
|
42
|
+
}
|
|
43
|
+
const commits = log.out
|
|
44
|
+
.split('\x1e')
|
|
45
|
+
.map((r) => r.trim())
|
|
46
|
+
.filter((r) => r !== '')
|
|
47
|
+
.map((r) => {
|
|
48
|
+
const cut = r.indexOf('\x1f')
|
|
49
|
+
return { hash: r.slice(0, cut), body: r.slice(cut + 1) }
|
|
50
|
+
})
|
|
51
|
+
if (commits.length === 0) return void t.lines.push({ kind: 'skip', text: 'commit history unverifiable — no commits yet' })
|
|
52
|
+
const remedy = 'an unpushed TIP commit may be amended; anything deeper is the owner\'s decision (history is never rewritten as a side effect)'
|
|
53
|
+
const sample = (hits: { hash: string }[]): string => hits.slice(0, 5).map((h) => h.hash).join(' ') + (hits.length > 5 ? ' …' : '')
|
|
54
|
+
const attributed = commits.filter((c) => hasAiAttribution(c.body))
|
|
55
|
+
const dashed = commits.filter((c) => hasDashPunctuation(c.body))
|
|
56
|
+
if (attributed.length > 0)
|
|
57
|
+
t.fail(`${attributed.length}/${commits.length} commits carry AI attribution (a trailer naming an assistant / "Generated with" / 🤖) in a declared-public repo — e.g. ${sample(attributed)}; ${remedy}`)
|
|
58
|
+
if (dashed.length > 0)
|
|
59
|
+
t.fail(`${dashed.length}/${commits.length} commit messages carry dash punctuation (em/en dash or inline " - ") in a declared-public repo — e.g. ${sample(dashed)}; ${remedy} (identity/writing-voice.md owns the punctuation rule)`)
|
|
60
|
+
const silenced = attributionSilenced(env.root)
|
|
61
|
+
if (!silenced)
|
|
62
|
+
t.fail(`.claude/settings.json does not silence AI attribution — track it with {"attribution": {"commit": "", "pr": ""}} so no agent's harness ever writes the trailer or the PR footer`)
|
|
63
|
+
if (attributed.length === 0 && dashed.length === 0 && silenced)
|
|
64
|
+
t.ok(`${commits.length} commits scanned, attribution silenced in .claude/settings.json — no AI tells`)
|
|
65
|
+
},
|
|
66
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// SK010 — undeclared release unit (build/shipkit.md). Release units are
|
|
2
|
+
// DECLARED, never inferred, so resolution is deterministic. This rule is the
|
|
3
|
+
// other half of that deal: a package.json sitting in the repo that no unit
|
|
4
|
+
// claims would be invisible to every release surface, and its version would
|
|
5
|
+
// drift forever with nothing reporting it. Two declarations clear it, so the
|
|
6
|
+
// green is always reachable: `units: [{ dir }]` for a releasable package,
|
|
7
|
+
// `notReleasable: [dir]` for one that ships inside something else. That second
|
|
8
|
+
// escape has teeth on purpose, because it is the one an agent staring at a red
|
|
9
|
+
// gate reaches for: an entry must cover at least one real package and never the
|
|
10
|
+
// whole repo, and the green counts the PACKAGES it excused, so a wide entry
|
|
11
|
+
// cannot read as a narrow one.
|
|
12
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
|
|
13
|
+
import { join, relative } from 'path'
|
|
14
|
+
import { resolveUnits } from '../release/units.ts'
|
|
15
|
+
import type { Rule } from './rule.ts'
|
|
16
|
+
|
|
17
|
+
const SKIP = new Set(['node_modules', 'references', 'tmp', 'stats', 'dist', 'build'])
|
|
18
|
+
|
|
19
|
+
/** package dirs git knows about (tracked + untracked-not-ignored), so a
|
|
20
|
+
* gitignored vendor tree or a python venv never trips the gate. Null when
|
|
21
|
+
* git cannot answer, and the caller falls back to walking. */
|
|
22
|
+
function gitPackageDirs(root: string): string[] | null {
|
|
23
|
+
const p = Bun.spawnSync(['git', 'ls-files', '--cached', '--others', '--exclude-standard', '-z'], { cwd: root })
|
|
24
|
+
if (p.exitCode !== 0) return null
|
|
25
|
+
return p.stdout.toString().split('\0')
|
|
26
|
+
.filter((f) => f === 'package.json' || f.endsWith('/package.json'))
|
|
27
|
+
.map((f) => join(root, f, '..'))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** the fs fallback: an unreadable entry (a dangling symlink, an unmounted
|
|
31
|
+
* volume) is skipped, never thrown — a broken link is not a release fact */
|
|
32
|
+
function walkPackageDirs(root: string): string[] {
|
|
33
|
+
const out: string[] = [root]
|
|
34
|
+
const walk = (dir: string): void => {
|
|
35
|
+
let entries: string[]
|
|
36
|
+
try { entries = readdirSync(dir) } catch { return }
|
|
37
|
+
for (const e of entries) {
|
|
38
|
+
if (SKIP.has(e) || e.startsWith('.')) continue
|
|
39
|
+
const p = join(dir, e)
|
|
40
|
+
try { if (!statSync(p).isDirectory()) continue } catch { continue }
|
|
41
|
+
if (existsSync(join(p, 'package.json'))) out.push(p)
|
|
42
|
+
walk(p)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
walk(root)
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** every package.json below the root that carries a name, root excluded */
|
|
50
|
+
export function packageDirs(root: string): string[] {
|
|
51
|
+
const dirs = gitPackageDirs(root) ?? walkPackageDirs(root)
|
|
52
|
+
const named = dirs.filter((d) => {
|
|
53
|
+
if (d === root) return false
|
|
54
|
+
if (relative(root, d).split('/').some((seg) => SKIP.has(seg) || seg.startsWith('.'))) return false
|
|
55
|
+
try {
|
|
56
|
+
const pkg = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')) as { name?: string }
|
|
57
|
+
return typeof pkg.name === 'string' && pkg.name !== ''
|
|
58
|
+
} catch { return false } // a broken package.json is SK001's story, not this rule's
|
|
59
|
+
})
|
|
60
|
+
return [...new Set(named)].sort()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const undeclaredReleaseUnit: Rule = {
|
|
64
|
+
id: 'SK010',
|
|
65
|
+
blocking: true,
|
|
66
|
+
title: 'release units declared',
|
|
67
|
+
owner: 'build/shipkit.md',
|
|
68
|
+
lint: (t, env) => {
|
|
69
|
+
const { units, errors } = resolveUnits(env.root, env.config)
|
|
70
|
+
// a DECLARED unit that does not resolve is a declaration bug and
|
|
71
|
+
// belongs here. A root package that is not release-shaped (no version
|
|
72
|
+
// yet) is not: the release path refuses on its own, the day it matters.
|
|
73
|
+
const declared = errors.filter((e) => e.id !== 'root')
|
|
74
|
+
if (declared.length > 0) {
|
|
75
|
+
for (const e of declared) t.fail(`declared unit '${e.id}' does not resolve: ${e.message}`, 'repo.config units[]')
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
// an entry is a PREFIX over the packages under it, so `examples/` can
|
|
79
|
+
// excuse examples/a and examples/b without enumerating them. Two shapes
|
|
80
|
+
// are refused, both being the kill switch this rule exists to prevent:
|
|
81
|
+
// an entry covering the whole repo, and one covering nothing today that
|
|
82
|
+
// stands ready to excuse whatever lands there tomorrow. defineRepo
|
|
83
|
+
// refuses the root too, and a repo.config.ts that never calls it is
|
|
84
|
+
// exactly why the check also lives in the blocking rule.
|
|
85
|
+
const decls = env.config.notReleasable ?? []
|
|
86
|
+
const covered = (list: string[], d: string): boolean => list.some((c) => d === c || d.startsWith(`${c}/`))
|
|
87
|
+
const pkgs = packageDirs(env.root)
|
|
88
|
+
const wide = decls.filter((d) => join(env.root, d) === env.root)
|
|
89
|
+
if (wide.length > 0) {
|
|
90
|
+
for (const d of wide)
|
|
91
|
+
t.fail(`notReleasable '${d}' resolves to the repo root, so it excuses every package at once`, `repo.config notReleasable: name one package dir (or one container dir) per entry`)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
const empty = decls.filter((d) => !pkgs.some((p) => covered([join(env.root, d)], p)))
|
|
95
|
+
if (empty.length > 0) {
|
|
96
|
+
for (const d of empty)
|
|
97
|
+
t.fail(`notReleasable '${d}' covers no package.json, so it excuses no package today and every package that lands there tomorrow`, `repo.config notReleasable: point it at a package dir (or a dir holding some), or drop the entry`)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
const excused = decls.map((d) => join(env.root, d))
|
|
101
|
+
const claimed = [...units.filter((u) => u.rel !== '').map((u) => u.dir), ...excused]
|
|
102
|
+
const orphans = pkgs.filter((d) => !covered(claimed, d))
|
|
103
|
+
if (orphans.length === 0) {
|
|
104
|
+
// PACKAGES, not entries: one entry over a nested tree excuses more
|
|
105
|
+
// than one, and the count is the only place that shows it
|
|
106
|
+
const n = pkgs.filter((d) => covered(excused, d)).length
|
|
107
|
+
return t.ok(`${units.length} release unit(s) declared${units.length === 1 ? ' (the repo root)' : ''}${n > 0 ? ` + ${n} package(s) declared not releasable` : ''} — no package hides from a release`)
|
|
108
|
+
}
|
|
109
|
+
for (const d of orphans) {
|
|
110
|
+
const rel = relative(env.root, d)
|
|
111
|
+
t.fail(`${rel}/package.json is claimed by nothing — declare what it is, or its version drifts with nothing watching`, `repo.config units: [{ dir: '${rel}' }] if it releases, else notReleasable: ['${rel}']`)
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
}
|
|
@@ -10,6 +10,9 @@ import { gitignorePolicy } from './SK004-gitignore-policy.ts'
|
|
|
10
10
|
import { rvlibVersions } from './SK005-rvlib-versions.ts'
|
|
11
11
|
import { npmPublishSafety } from './SK006-npm-publish-safety.ts'
|
|
12
12
|
import { githubVisibility } from './SK007-github-visibility.ts'
|
|
13
|
+
import { reviewerAgent } from './SK008-reviewer-agent.ts'
|
|
14
|
+
import { commitAiTells } from './SK009-commit-ai-tells.ts'
|
|
15
|
+
import { undeclaredReleaseUnit } from './SK010-undeclared-release-unit.ts'
|
|
13
16
|
|
|
14
17
|
export const RULES: readonly Rule[] = [
|
|
15
18
|
packageJsonShape,
|
|
@@ -19,14 +22,33 @@ export const RULES: readonly Rule[] = [
|
|
|
19
22
|
rvlibVersions,
|
|
20
23
|
npmPublishSafety,
|
|
21
24
|
githubVisibility,
|
|
25
|
+
reviewerAgent,
|
|
26
|
+
commitAiTells,
|
|
27
|
+
undeclaredReleaseUnit,
|
|
22
28
|
]
|
|
23
29
|
|
|
24
|
-
|
|
25
|
-
for
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
// THE id shape, one place: 2-4 uppercase letters + exactly 3 digits. `SK` is
|
|
31
|
+
// reserved for the built-ins above so a repo rule can never shadow one.
|
|
32
|
+
export const RULE_ID_RE = /^[A-Z]{2,4}\d{3}$/
|
|
33
|
+
|
|
34
|
+
/** validate a rule set and its ids against everything already registered.
|
|
35
|
+
* Throws on a malformed id, a duplicate, a missing owner page, or a repo
|
|
36
|
+
* rule squatting the reserved SK prefix. Shared by the built-in registry
|
|
37
|
+
* and by `defineRepo`, so both die loud at import time. */
|
|
38
|
+
export function validateRules(rules: readonly Rule[], o: { label: string; builtin?: boolean; taken?: Iterable<string> } = { label: 'rule-registry' }): void {
|
|
39
|
+
const seen = new Set<string>(o.taken ?? [])
|
|
40
|
+
for (const r of rules) {
|
|
41
|
+
if (!RULE_ID_RE.test(r.id)) throw new Error(`${o.label}: bad id '${r.id}' — rules are PREFIX+3 digits (e.g. SK001, RV001)`)
|
|
42
|
+
if (o.builtin !== true && r.id.startsWith('SK'))
|
|
43
|
+
throw new Error(`${o.label}: id '${r.id}' uses the reserved SK prefix — repo rules pick their own (e.g. RV${r.id.slice(-3)})`)
|
|
44
|
+
if (seen.has(r.id)) throw new Error(`${o.label}: duplicate id ${r.id}`)
|
|
45
|
+
if (r.owner === '') throw new Error(`${o.label}: ${r.id} has no owner page`)
|
|
46
|
+
seen.add(r.id)
|
|
47
|
+
}
|
|
30
48
|
}
|
|
31
49
|
|
|
50
|
+
validateRules(RULES, { label: 'rule-registry', builtin: true })
|
|
51
|
+
|
|
52
|
+
export const BUILTIN_RULE_IDS: readonly string[] = RULES.map((r) => r.id)
|
|
53
|
+
|
|
32
54
|
export const ruleById = (id: string): Rule | undefined => RULES.find((r) => r.id === id)
|
package/src/rules/rule.ts
CHANGED
|
@@ -13,10 +13,17 @@ export type RuleEnv = { root: string; config: RepoConfig }
|
|
|
13
13
|
export type FixResult = { changed: boolean; summary: string }
|
|
14
14
|
|
|
15
15
|
export type Rule = {
|
|
16
|
-
/** stable
|
|
16
|
+
/** stable PREFIX + 3 digits, never reused or renumbered (the registry throws).
|
|
17
|
+
* `SK###` is reserved for shipkit's built-ins; a repo registering its own
|
|
18
|
+
* rules through `repo.config.ts rules: []` picks its own prefix (`RV###`). */
|
|
17
19
|
id: string
|
|
18
20
|
/** check-suite display name — stable: the check name in STATUS.md, the TUI row, and the suite cache */
|
|
19
21
|
title: string
|
|
22
|
+
/** can this stop you? REQUIRED, no default. true = a failure is about THIS
|
|
23
|
+
* repo and blocks. false = it reports the world (a box that is off, a cred
|
|
24
|
+
* nobody minted), so a failure prints as a warning and never blocks.
|
|
25
|
+
* The only question worth asking about a check, asked once, in one place. */
|
|
26
|
+
blocking: boolean
|
|
20
27
|
/** wiki page owning the invariant — the run-check framework appends `docsBase + owner`
|
|
21
28
|
* as the fix: pointer whenever a failing/warning lint attached none */
|
|
22
29
|
owner: string
|
package/src/rules/run-fix.ts
CHANGED
|
@@ -6,11 +6,14 @@
|
|
|
6
6
|
import { CheckCtx } from '../core/check-ctx.ts'
|
|
7
7
|
import { formatLine } from '../core/wire.ts'
|
|
8
8
|
import { loadRepoConfig } from '../repo-config.ts'
|
|
9
|
-
import { RULES
|
|
9
|
+
import { RULES } from './rule-registry.ts'
|
|
10
10
|
import { runRuleLint, type RuleEnv } from './rule.ts'
|
|
11
11
|
|
|
12
12
|
export type FixOpts = {
|
|
13
13
|
ids?: string[]
|
|
14
|
+
/** drop the closing advisory: inside the ci fix pass it is noise, and it is
|
|
15
|
+
* not wire-shaped, which the frozen output contract forbids */
|
|
16
|
+
quiet?: boolean
|
|
14
17
|
print?: (line: string) => void
|
|
15
18
|
}
|
|
16
19
|
|
|
@@ -19,11 +22,13 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
|
|
|
19
22
|
const config = await loadRepoConfig(root)
|
|
20
23
|
const env: RuleEnv = { root, config }
|
|
21
24
|
const color = process.stdout.isTTY && process.env.NO_COLOR == null
|
|
25
|
+
// the repo's own rules fix exactly like the built-ins, one code path
|
|
26
|
+
const all = [...RULES, ...(config.rules ?? [])]
|
|
22
27
|
|
|
23
|
-
let targets =
|
|
28
|
+
let targets = all.filter((r) => r.fix != null)
|
|
24
29
|
if (o.ids != null && o.ids.length > 0) {
|
|
25
30
|
for (const id of o.ids) {
|
|
26
|
-
const r =
|
|
31
|
+
const r = all.find((x) => x.id === id)
|
|
27
32
|
if (r == null) { print(`FAIL: unknown rule '${id}' — see: shipkit rules`); return 1 }
|
|
28
33
|
if (r.fix == null) { print(`FAIL: [${id}] has no autofix — fix by hand: ${config.docsBase ?? ''}${r.owner}`); return 1 }
|
|
29
34
|
}
|
|
@@ -36,7 +41,9 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
|
|
|
36
41
|
const before = new CheckCtx(new Map())
|
|
37
42
|
await runRuleLint(rule, before, env)
|
|
38
43
|
if (!before.failed && !before.warned) {
|
|
39
|
-
|
|
44
|
+
// inside the ci fix pass, a line per already-clean rule is a wall of
|
|
45
|
+
// nothing above the run that reports those same rules for real
|
|
46
|
+
if (o.quiet !== true) print(`ok: [${rule.id}] already clean — nothing to fix`)
|
|
40
47
|
continue
|
|
41
48
|
}
|
|
42
49
|
const f = await rule.fix!(env)
|
|
@@ -47,11 +54,11 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
|
|
|
47
54
|
await runRuleLint(rule, after, env)
|
|
48
55
|
// the fix already ran: advertising it again on surviving findings
|
|
49
56
|
// would send an agent in a loop
|
|
50
|
-
for (const l of after.lines) print(formatLine({ ...l, text: l.text.replace(/ · autofix: shipkit check --fix
|
|
57
|
+
for (const l of after.lines) print(formatLine({ ...l, text: l.text.replace(/ · autofix: shipkit check --fix [A-Z]{2,4}\d{3}$/, '') }, color))
|
|
51
58
|
if (after.failed) exit = 1
|
|
52
59
|
}
|
|
53
|
-
const unfixable =
|
|
54
|
-
if (unfixable > 0 && (o.ids == null || o.ids.length === 0))
|
|
55
|
-
print(`${unfixable} rule(s) have no autofix —
|
|
60
|
+
const unfixable = all.filter((r) => r.fix == null).length
|
|
61
|
+
if (unfixable > 0 && o.quiet !== true && (o.ids == null || o.ids.length === 0))
|
|
62
|
+
print(`${unfixable} rule(s) have no autofix — they report in the run below`)
|
|
56
63
|
return exit
|
|
57
64
|
}
|
|
@@ -104,7 +104,7 @@ export const ColCard = observer(({ app, col, id }: { app: HubSt; col: string; id
|
|
|
104
104
|
})
|
|
105
105
|
|
|
106
106
|
// collection view — one card per visible instance, the stage-side twin of a
|
|
107
|
-
// sidebar section (
|
|
107
|
+
// sidebar section (every section gets both views)
|
|
108
108
|
export const ColGrid = observer(({ app, id }: { app: HubSt; id: string }) => {
|
|
109
109
|
const c = app.cols
|
|
110
110
|
const col = c.sidebarCols.find((x) => x.id === id)
|
|
@@ -145,8 +145,7 @@ export const ColGrid = observer(({ app, id }: { app: HubSt; id: string }) => {
|
|
|
145
145
|
)
|
|
146
146
|
})
|
|
147
147
|
|
|
148
|
-
// graph item view — the graph ITSELF fills the stage
|
|
149
|
-
// "if I select a graph, I want to see it"), lint line as a slim header
|
|
148
|
+
// graph item view — the graph ITSELF fills the stage, lint line as a slim header
|
|
150
149
|
export const GraphView = observer(({ app, id }: { app: HubSt; id: string }) => {
|
|
151
150
|
const c = app.cols
|
|
152
151
|
const i = c.find('graphs', id)
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// THE page (Bun.serve HTML import — no vite, no build step). One surface
|
|
2
|
-
// per repo
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// collection view (grid of cards), row = item view. mobx+react per
|
|
2
|
+
// per repo: media atelier + asks + creds + posts + health + graphs as
|
|
3
|
+
// foldable sections, one severity filter persisted at /?f=needs · /?f=red.
|
|
4
|
+
// Section title = collection view (grid of cards), row = item view. mobx+react per
|
|
6
5
|
// build/app-state-tree: HubSt is the single root (window.app), services
|
|
7
6
|
// own state, components read and call.
|
|
8
7
|
import { runInAction } from 'mobx'
|
|
@@ -66,8 +65,7 @@ const ColSections = observer(() => {
|
|
|
66
65
|
{c.sidebarCols.map((col) => {
|
|
67
66
|
const items = c.visibleInstances(col)
|
|
68
67
|
// every section header ALWAYS renders, 0 items included — a
|
|
69
|
-
// quiet section must stay reachable (
|
|
70
|
-
// done asks, present secrets)
|
|
68
|
+
// quiet section must stay reachable (done asks, present secrets)
|
|
71
69
|
const open = c.isOpen(col)
|
|
72
70
|
const health = col.id === 'health'
|
|
73
71
|
return (
|
|
@@ -121,8 +119,8 @@ const Sidebar = observer(() => (
|
|
|
121
119
|
</aside>
|
|
122
120
|
))
|
|
123
121
|
|
|
124
|
-
// ── the icon rail: quick jump to a section header (
|
|
125
|
-
//
|
|
122
|
+
// ── the icon rail: quick jump to a section header (scroll + unfold,
|
|
123
|
+
// NEVER a navigation) — red dot = the section holds a red ──
|
|
126
124
|
const RAIL_ICON: Record<string, string> = { asks: '🙋', creds: '🔑', posts: '📣', health: '📊', graphs: '🕸' }
|
|
127
125
|
const Rail = observer(() => {
|
|
128
126
|
const c = app.cols
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// HubSt — the ONE root store (build/app-state-tree: one central tree,
|
|
2
2
|
// instantiated once at boot, services with backrefs, exposed as
|
|
3
3
|
// window.app). Owns cross-service state: selection, the severity filter
|
|
4
|
-
// (an ADDRESS: /?f=needs · /?f=red
|
|
5
|
-
//
|
|
4
|
+
// (an ADDRESS: /?f=needs · /?f=red), hash deep-links (#<asset-id> ·
|
|
5
|
+
// #cat-<Category>).
|
|
6
6
|
import { makeAutoObservable } from 'mobx'
|
|
7
7
|
import { ColsSvc } from './cols-svc.ts'
|
|
8
8
|
import { HealthSvc } from './health-svc.ts'
|
|
@@ -22,10 +22,8 @@ export type Jump = { id: string; label: string; section: string; run: () => void
|
|
|
22
22
|
/** all · needs (yellow+red) · red — one severity ladder for every section */
|
|
23
23
|
export type Filter = 'all' | 'needs' | 'red'
|
|
24
24
|
const parseFilter = (search: string): Filter => {
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
if (f === 'needs' || f === 'red') return f
|
|
28
|
-
return sp.get('needs') === '1' ? 'needs' : 'all' // legacy address keeps working
|
|
25
|
+
const f = new URLSearchParams(search).get('f')
|
|
26
|
+
return f === 'needs' || f === 'red' ? f : 'all'
|
|
29
27
|
}
|
|
30
28
|
|
|
31
29
|
export class HubSt {
|
|
@@ -64,7 +62,6 @@ export class HubSt {
|
|
|
64
62
|
setFilter(v: Filter): void {
|
|
65
63
|
this.filter = v
|
|
66
64
|
const u = new URL(location.href)
|
|
67
|
-
u.searchParams.delete('needs') // legacy param never survives a click
|
|
68
65
|
v === 'all' ? u.searchParams.delete('f') : u.searchParams.set('f', v)
|
|
69
66
|
history.replaceState(null, '', u)
|
|
70
67
|
}
|
|
@@ -197,8 +197,8 @@ export const CatGrid = observer(({ app }: { app: HubSt }) => {
|
|
|
197
197
|
{m.catRows.map((row) => {
|
|
198
198
|
const fl = row.rect?.flip === true
|
|
199
199
|
return (
|
|
200
|
-
// the WHOLE card jumps into the asset
|
|
201
|
-
//
|
|
200
|
+
// the WHOLE card jumps into the asset —
|
|
201
|
+
// inner controls stopPropagation
|
|
202
202
|
<div className="card" key={row.id} onClick={() => app.selectAsset(row.id)}>
|
|
203
203
|
{isAud(row.name)
|
|
204
204
|
? <div className="aud">🔊<audio controls src={rollUrl(row.id, row.name)} onClick={(e) => e.stopPropagation()} />{m.pending.includes(row.id) ? <div className="spin" /> : null}</div>
|
package/src/serve/run-serve.ts
CHANGED
|
@@ -34,7 +34,7 @@ export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o
|
|
|
34
34
|
const agentGraph = existsSync(agentRoot)
|
|
35
35
|
? { title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }) }
|
|
36
36
|
: null
|
|
37
|
-
// ONE page for every repo
|
|
37
|
+
// ONE page for every repo: the studio ALWAYS
|
|
38
38
|
// mounts — kit-less repos get an empty media section, never a second
|
|
39
39
|
// page. A broken kit degrades to the same empty kit, loudly.
|
|
40
40
|
let kit: ResolvedKit | null = null
|
|
@@ -86,8 +86,8 @@ export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
// the hub must KNOW when it is outdated
|
|
90
|
-
// process
|
|
89
|
+
// the hub must KNOW when it is outdated — a stale morning
|
|
90
|
+
// process serves broken-looking picks all day: version read at
|
|
91
91
|
// boot vs on disk per attention load; mismatch = red line telling him to
|
|
92
92
|
// restart the task
|
|
93
93
|
const libVersion = (): string => {
|
|
@@ -96,8 +96,7 @@ const libVersion = (): string => {
|
|
|
96
96
|
const BOOT_VERSION = libVersion()
|
|
97
97
|
|
|
98
98
|
// the / dashboard's data now comes from the collection providers
|
|
99
|
-
// (src/collections/providers.ts) —
|
|
100
|
-
// when they landed; never reintroduce a parallel projection.
|
|
99
|
+
// (src/collections/providers.ts) — never a parallel projection.
|
|
101
100
|
|
|
102
101
|
export async function runServe(root: string, io: ServeIo, o?: { port?: number }): Promise<number> {
|
|
103
102
|
const c = await loadRepoConfig(root)
|
package/src/serve/serve-hub.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// shipkit serve — the repo's ONE Bun.serve, serving the ONE page (
|
|
2
|
-
// rules 2026-07-23: one server per repo, one surface, mobx+react). `/` is
|
|
1
|
+
// shipkit serve — the repo's ONE Bun.serve, serving the ONE page (one server per repo, one surface, mobx+react). `/` is
|
|
3
2
|
// the hub app (Bun HTML import — media studio + asks + creds + posts +
|
|
4
3
|
// health + graphs as sections); the studio route table mounts as the media
|
|
5
4
|
// API under /media/*; graph canvases stay as drill-down pages. Route logic
|
|
@@ -32,7 +31,7 @@ export type HubDeps = {
|
|
|
32
31
|
* collection providers (run-serve builds it) */
|
|
33
32
|
needs: () => Promise<NeedItem[]>
|
|
34
33
|
/** BROWSE-ALL: the collections with every instance incl. healthy —
|
|
35
|
-
* the page shows everything that exists
|
|
34
|
+
* the page shows everything that exists, dimmed never hidden */
|
|
36
35
|
collections: () => Collection[]
|
|
37
36
|
/** asks dir (repo-root-relative) for the act-in-place ask verbs */
|
|
38
37
|
asksDir: string
|
package/src/shipkit.ts
CHANGED
|
@@ -11,6 +11,14 @@ export { projectNeeds, redCount } from './collections/collection-types.ts'
|
|
|
11
11
|
export type { Collection, Instance, NeedItem, NeedSeverity, CurationVerb } from './collections/collection-types.ts'
|
|
12
12
|
export { buildCollections } from './collections/providers.ts'
|
|
13
13
|
export { socialPostsCollection, listPosts } from './collections/posts.ts'
|
|
14
|
+
// release units — the ONE discovery every release surface reads (drift
|
|
15
|
+
// probes, per-unit tests, per-unit typecheck, the stranded-tag probe)
|
|
16
|
+
export { resolveUnits, releaseOrder, unitById, renderTag, tagGlobs, prepublishScripts, unitPrepublishScripts } from './release/units.ts'
|
|
17
|
+
export type { ReleaseUnit, ReleaseUnitDecl, UnitResolution } from './release/units.ts'
|
|
18
|
+
export { deriveRemote, dirtyPaths, remoteTags, strandedTags, tagExistsLocally } from './release/git-facts.ts'
|
|
19
|
+
export { collectReports, renderReports, attentionLines } from './release/run-releases.ts'
|
|
20
|
+
export { extractChangelogSection, promoteUnreleased, hasUnreleasedNotes, hasVersionSection } from './release/changelog.ts'
|
|
21
|
+
export type { UnitReport } from './release/run-releases.ts'
|
|
14
22
|
export { discoverChecks } from './core/discover-checks.ts'
|
|
15
23
|
export type { DiscoveredCheck } from './core/discover-checks.ts'
|
|
16
24
|
export { runTasks, selectTasks } from './core/task-runner.ts'
|
|
@@ -50,7 +58,7 @@ export { studioRoutes } from './assets/server/studio-server.ts'
|
|
|
50
58
|
export type { StudioDeps, StudioRoutes } from './assets/server/studio-server.ts'
|
|
51
59
|
export { StudioQueue } from './assets/server/studio-queue.ts'
|
|
52
60
|
export type { Job, QueueState } from './assets/server/studio-queue.ts'
|
|
53
|
-
export { cleanRect, liveRect, defaultLegs
|
|
61
|
+
export { cleanRect, liveRect, defaultLegs } from './assets/panels/geometry-rects.ts'
|
|
54
62
|
export type { GroundRect, LegRect } from './assets/panels/geometry-rects.ts'
|
|
55
63
|
export { generate, submit, waitFor, applyInputs, expectNode, randomSeed, batchFolderName } from './assets/gen/comfy-gen.ts'
|
|
56
64
|
export type { ComfyOpts } from './assets/gen/comfy-gen.ts'
|
package/src/tui/checks-panel.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// built-in panel: contracts + every discovered *.check.ts through runCheck
|
|
2
2
|
// (both TTL cache layers intact) — per-check results stream in via the
|
|
3
|
-
// onResult tap. status:false:
|
|
3
|
+
// onResult tap. status:false: no TUI panel writes STATUS.md (tasks-panel.ts).
|
|
4
4
|
import { discoverChecks } from '../core/discover-checks.ts'
|
|
5
5
|
import { runCheck } from '../core/run-check.ts'
|
|
6
6
|
import { formatLine } from '../core/wire.ts'
|
package/src/tui/run-tui.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// The tui command body — a separate module so cli.ts NEVER loads
|
|
2
|
-
// react/ink/mobx for non-tui commands
|
|
3
|
-
// must not touch the react dependency chain
|
|
4
|
-
// crashed every command on repos without react-dom).
|
|
2
|
+
// react/ink/mobx for non-tui commands: a fresh consumer running `shipkit ci`
|
|
3
|
+
// must not touch the react dependency chain.
|
|
5
4
|
import React from 'react'
|
|
6
5
|
import { render } from 'ink'
|
|
7
6
|
import { configure } from 'mobx'
|
package/src/tui/tasks-panel.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
// built-in panel: the ci gate (floor + contracts + repo tasks)
|
|
2
|
-
// runCi — results stream in via the onTaskFinish tap.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// built-in panel: the ci gate's TASKS (floor + contracts + repo tasks)
|
|
2
|
+
// through runCi — results stream in via the onTaskFinish tap. The checks are
|
|
3
|
+
// skipped here on purpose: the Checks panel runs the same suite and renders
|
|
4
|
+
// it, and running it twice per refresh costs twice and proves the same thing.
|
|
5
|
+
// no panel writes STATUS.md: a dashboard refresh runs the tasks alone, so
|
|
6
|
+
// stamping a verdict from it would overwrite what a real gate proved with a
|
|
7
|
+
// narrower claim, just by looking at the repo.
|
|
5
8
|
import type { RepoConfig } from '../repo-config.ts'
|
|
6
9
|
import { allTasks } from '../repo-config.ts'
|
|
7
10
|
import { runCi } from '../core/run-ci.ts'
|
|
@@ -38,6 +41,9 @@ export function tasksPanel(root: string, config: RepoConfig): TuiPanelDef {
|
|
|
38
41
|
const byKey = new Map(rows.map((r, i) => [r.key, i]))
|
|
39
42
|
update()
|
|
40
43
|
await runCi(root, {
|
|
44
|
+
config,
|
|
45
|
+
skipChecks: true,
|
|
46
|
+
status: false,
|
|
41
47
|
print: () => {}, // rows carry everything the wire lines would
|
|
42
48
|
onTaskFinish: (r) => {
|
|
43
49
|
const i = byKey.get(r.id)
|