rvlib-shipkit 1.6.0 → 1.8.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 +26 -2
- package/package.json +1 -1
- package/src/checks/SK027-agent-wiki-links.check.ts +6 -2
- package/src/checks/SK033-global-contribution.check.ts +47 -0
- package/src/cli/cmd_GlobalContext.ts +47 -0
- package/src/cli/cmd_GlobalProcesses.ts +38 -1
- package/src/cli/cmd_Insights.ts +78 -0
- package/src/cli.ts +6 -1
- package/src/core/global-contribution.ts +42 -0
- package/src/core/trace-audit.ts +16 -7
- package/src/home/global-context.ts +145 -0
- package/src/home/gui-app/ShipkitGui.swift +125 -41
- package/src/home/home-routes.ts +25 -0
- package/src/home/insights-shape.ts +124 -0
- package/src/home/insights.ts +90 -0
- package/src/home/serve-web.ts +3 -3
- package/src/home/widgets.ts +12 -2
- package/src/serve/hub-app/home-st.ts +12 -5
- package/src/serve/hub-app/home-strip.css +2 -0
- package/src/serve/hub-app/home-strip.tsx +4 -0
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +2 -0
- package/src/serve/hub-app/icons.tsx +1 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-st.ts +111 -0
- package/src/serve/hub-app/insights-view.tsx +112 -0
- package/src/serve/hub-app/insights.css +24 -0
- package/src/serve/hub-app/keys.ts +1 -1
- package/src/serve/hub-app/machine-st.ts +23 -0
- package/src/serve/hub-app/palette-st.ts +2 -1
- package/src/serve/hub-app/project-list.tsx +3 -2
- package/src/serve/hub-app/projects-svc.ts +1 -1
- package/src/serve/hub-app/repo-st.ts +1 -1
- package/src/serve/hub-runtime.ts +31 -21
- package/src/serve/run-lanes.ts +134 -0
- package/src/shipkit-files.ts +3 -2
- package/src/suite/judge-suites.ts +27 -7
- package/src/suite/reads-env.ts +8 -0
- package/src/suite/select.ts +510 -0
- package/src/suite/suite-cli.ts +196 -14
- package/src/suite/suite-reads-preload.ts +335 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
## 1.8.0
|
|
2
|
+
|
|
3
|
+
- **The quick tier can run only the suites a change reaches** (opt in: `tests: { select: true }`, or `SHIPKIT_TEST_SELECT=1` for one run). It keeps the snapshot of the last run and its red suites, and runs those plus every suite whose import closure, or whose recorded runtime reads, a change since then reaches. What it cannot see it runs: an unclaimed change under a code root, a spawn it does not know, a module that threw while loading, a run config file. The full tier still runs every suite.
|
|
4
|
+
|
|
5
|
+
- **Runs of one repo in the home share, then overlap.** A terminal `sk ci` or `sk check` identical to one in flight on the same tree attaches to it, prints its lines and exits with its code. Runs without the repair pass start at once beside the runs in flight, bounded by the machine slot pool. A run with the repair pass still goes alone. Before, every run of a repo queued behind the one in flight, so a one second gate could wait minutes.
|
|
6
|
+
|
|
7
|
+
- **The house context: one generated file every house agent loads.** `sk global-context` builds `generated/global-context.md` beside the house index. It lists every global process with the commands that drive it, every house repo, and each repo's `agent/global-contribution.md`. The daemon rewrites it within a minute of a source moving. A process or repo description joins by its first line only, and every item is scanned with the banned keywords of the commit leak guard: a hit leaves it out and says so. It exists only for a house whose index includes `@generated/global-context.md`. `--write` writes it and `--check` fails when it is stale.
|
|
8
|
+
|
|
9
|
+
- **SK033: every house repo carries `agent/global-contribution.md`.** It is what the repo offers every other agent, capped at 40 lines and 3 KB with no heading above `####`, and `sk ci` writes a comment-only stub when it is missing. It applies only where the house index loads the generated context. It is cached on the file, so the check body runs again only when the file moves. SK027 no longer calls the file an orphan, because the house index links it.
|
|
10
|
+
|
|
11
|
+
- **`sk gp open <id>` opens a global process's url** in the default browser, in Chrome (`--chrome`) or as its own app (`--app`), the overview's open buttons in the console. A process that is down starts first.
|
|
12
|
+
|
|
13
|
+
- **The quick tier honours the worker count a repo names.** A `--parallel=<n>` in the repo's own `bun test` body is now a ceiling over the machine pool's share: the parallel phase takes the smaller of the two, as the one `--parallel` on its line. Before, the runner appended its own count after the body, and bun reads the last one, so a repo that measured fewer workers as faster still ran on every core of an idle machine. A bare `--parallel` keeps the pool's count.
|
|
14
|
+
|
|
15
|
+
- **Declared serial suites and the full tier stay one at a time when the repo's body names `--parallel`.** The runner passed the body as written to the serial phase, to the full tier and to a suspect's alone rerun. So a body naming `--parallel=7` ran its declared serial suites, the ones that cannot share a run, in seven workers, and its full tier too, which runs one suite at a time because its suites spawn git and whole gates. Only the quick tier's parallel phase names a worker count now, and the runner keeps the body's `--parallel` off every other line.
|
|
16
|
+
|
|
17
|
+
## 1.7.0
|
|
18
|
+
|
|
19
|
+
- **Cmd-F in the shipkit app shows a find bar now.** Since the app shipped, the Find menu did nothing visible: NSTextFinder never shows its bar for a web view. The app now draws its own bar on WebKit's find API. Return and cmd-G go to the next match, shift-return and shift-cmd-G to the previous one, Esc closes the bar, and it says "no match" when the page does not hold the text. Rebuilt apps pick it up at their next launch.
|
|
20
|
+
|
|
21
|
+
- **A new cross-repo page, `/insights`, and its console twin `sk insights`.** It shows a grid of boxes, each with tags, and a tag row filters which boxes show. Dependency usage lists every package by how many repos use it. Click a row to list each repo and the range it asks. Version drift lists the packages asked at more than one range. Script names lists every package.json script with its commands. The scope is the house repos or every folder of `~/dev`, and one filter searches every box.
|
|
22
|
+
|
|
23
|
+
- **Every widget card has a refresh button.** It renders the widget now, off its clock, and passes `render({ force: true })`, so a widget that samples another machine can skip its cache or backoff. A widget that ignores the argument simply renders again.
|
|
24
|
+
|
|
25
|
+
- **The traced audit groups files under a dir the task listed.** A build that lists an asset folder and stats each image in it proposed one `ignoredInputs` line per image. The files now group under the outermost dir the task also listed, keyed by content once more than three fall under it.
|
|
26
|
+
|
|
1
27
|
## 1.6.0
|
|
2
28
|
|
|
3
29
|
- **lint, typecheck, test and every task that declares nothing are cached now.** Such a task is keyed on the whole work tree, plus the gitignored paths the new `ignoredInputs` lists for it (`{ test: ['dist/**'] }`, `'*'` for every task; a path by content, `dir/**` by the names under it), and the lockfile, the installed packages, the bun and node versions and the toolkit code behind it. `sk trace-audit` runs each gate task once under a file tracer and names the gitignored paths it reads that its key misses. Tracing is on wherever it can run (Apple silicon macOS): SK031 audits about daily, `sk trace-audit --disable` opts a machine out and `SHIPKIT_TRACE=0` one run, and each finding joins the key of the task that read it at once, in every worktree of the repo. SK032 and the fix pass of `sk ci` then write the findings into their own block of `repo.config.ts`, once the repo pins 1.6.0 or later. The tracer never decides a verdict. It needs Apple silicon macOS: on Linux and in CI nothing audits, and SK030's daily sample is the only net there.
|
|
@@ -10,8 +36,6 @@
|
|
|
10
36
|
|
|
11
37
|
- **SK030 audits the cache.** About once a day, while somebody works in the repo, it re-runs a sample of the checks the cache would answer right now, fresh, and compares. A cached green that a fresh run disagrees with fails, naming the check whose key misses a path its body reads, and the stale answer is dropped so it is never served again. `sk cache-audit` is the same audit on demand (`--sample <n>`, `--only <id>`, `--json`).
|
|
12
38
|
|
|
13
|
-
## Unreleased (patch)
|
|
14
|
-
|
|
15
39
|
- **The hub tree groups assets by kit category.** Under media, each category is a row that folds, and its label opens the category page. An asset row reads by its kit label (`mon/brindon/front`), never its file id.
|
|
16
40
|
|
|
17
41
|
- **SK001 no longer asks a comfy repo for a `comfy:up` script.** The box is a global process that `sk gp start comfyui` starts from any repo, so a per-repo script only copied that command. A `.shipkit/comfy/` folder now asks for nothing, and SK001 no longer probes it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"funding": "https://github.com/sponsors/rvion",
|
|
5
5
|
"description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
|
|
6
6
|
"license": "MIT",
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// a dangling ref there fails. A link that leaves agent/ but stays in the repo
|
|
4
4
|
// is checked on disk; a pointer out of the repo (a house wiki) is backticked,
|
|
5
5
|
// never a bare → or @. A repo without agent/index.md skips.
|
|
6
|
+
// agent/global-contribution.md is no orphan: the house index links it (SK033).
|
|
6
7
|
import { existsSync, readFileSync } from 'fs'
|
|
7
8
|
import { basename, join } from 'path'
|
|
8
9
|
import { check } from '../check.ts'
|
|
10
|
+
import { CONTRIBUTION_FILE } from '../core/global-contribution.ts'
|
|
9
11
|
import { lintMd } from '../wiki/lint-md.ts'
|
|
10
12
|
|
|
11
13
|
export const agentWikiLinks = check({
|
|
@@ -22,8 +24,10 @@ export const agentWikiLinks = check({
|
|
|
22
24
|
run: (c) => {
|
|
23
25
|
if (!existsSync(join(c.root, 'agent/index.md'))) return void c.lines.push({ kind: 'skip', text: 'no agent/ wiki in this repo' })
|
|
24
26
|
const lint = lintMd({ root: join(c.root, 'agent'), index: 'index.md', boundary: c.root })
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
// the global contribution is the house index's to link (SK033), never agent/'s
|
|
28
|
+
const orphans = lint.orphans.filter((f) => `agent/${f}` !== CONTRIBUTION_FILE)
|
|
29
|
+
const linked = lint.pages.filter((p) => !orphans.includes(p))
|
|
30
|
+
for (const f of orphans) {
|
|
27
31
|
const by = namingPage(c.root, f, linked)
|
|
28
32
|
const where = by != null ? `${by}, which already names it (turn that mention into the link)` : 'agent/index.md or the page of its topic'
|
|
29
33
|
c.fail(`agent/${f}: orphan, unreachable from agent/index.md: add a markdown link to it from ${where}; delete it only if the page is dead`)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// SK033 global contribution (meta/agent-folder.md): every house repo carries
|
|
2
|
+
// agent/global-contribution.md, what it offers every agent of every house
|
|
3
|
+
// repo (a house whose index loads @generated/global-context.md), which the house concatenates into the context each session loads
|
|
4
|
+
// (home/global-context.ts). A new one is a comment only, so it contributes
|
|
5
|
+
// nothing until an agent writes to it. Keyed on the file itself: once it is
|
|
6
|
+
// there, the body runs again only when the file moves. Its page is linked from
|
|
7
|
+
// the house index, never from agent/index.md, so SK027 leaves it out.
|
|
8
|
+
import { homedir } from 'os'
|
|
9
|
+
import { check, type FixResult } from '../check.ts'
|
|
10
|
+
import { CONTRIBUTION_FILE, CONTRIBUTION_STUB, CONTEXT_INCLUDE, contributionBody, contributionOver, loadsContext } from '../core/global-contribution.ts'
|
|
11
|
+
import { homeRepo, houseIndex } from '../serve/project-gutter.ts'
|
|
12
|
+
|
|
13
|
+
/** why a repo gives SK033 nothing to do, or null in a repo whose house
|
|
14
|
+
* index loads the generated context */
|
|
15
|
+
export function notHouse(root: string, home: string = homedir()): string | null {
|
|
16
|
+
const index = houseIndex(homeRepo(root), home)
|
|
17
|
+
if (index == null) return 'this repo includes no house index, so no house context reads it'
|
|
18
|
+
if (!loadsContext(index)) return `its house index does not load the generated context (no ${CONTEXT_INCLUDE} line), so nothing reads a contribution`
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const globalContribution = check({
|
|
23
|
+
id: 'SK033',
|
|
24
|
+
blocking: true,
|
|
25
|
+
title: 'global contribution',
|
|
26
|
+
description: 'agent/global-contribution.md exists and fits in the house context',
|
|
27
|
+
owner: 'meta/agent-folder.md',
|
|
28
|
+
runs: { onPreCommit: false, onCi: true, onFull: true, onRelease: true, onHealth: true, onSave: 'moved', onClock: false },
|
|
29
|
+
cache: { by: 'files', inputs: [CONTRIBUTION_FILE], key: null },
|
|
30
|
+
fix: 'ci',
|
|
31
|
+
applies: (env) => notHouse(env.root),
|
|
32
|
+
run: (c) => {
|
|
33
|
+
const text = c.read(CONTRIBUTION_FILE)
|
|
34
|
+
if (text == null) return void c.fail(`${CONTRIBUTION_FILE} is missing: every house repo carries one, empty until it has something to offer`, 'shipkit ci writes the empty one')
|
|
35
|
+
const body = contributionBody(text)
|
|
36
|
+
const over = contributionOver(body)
|
|
37
|
+
if (over != null) return void c.fail(`${CONTRIBUTION_FILE} is ${over}: every session of every house repo loads it, so the house context cuts it`, 'keep what another repo\'s agent can act on, move the rest to agent/')
|
|
38
|
+
c.ok(body === '' ? `${CONTRIBUTION_FILE} is there, contributing nothing yet` : `${CONTRIBUTION_FILE} contributes ${body.split('\n').length} line(s)`)
|
|
39
|
+
},
|
|
40
|
+
repair: (c): FixResult => {
|
|
41
|
+
if (c.read(CONTRIBUTION_FILE) != null) return { changed: false, summary: `${CONTRIBUTION_FILE} is there: its size is the author's to fix` }
|
|
42
|
+
c.write(CONTRIBUTION_FILE, CONTRIBUTION_STUB)
|
|
43
|
+
return { changed: true, summary: `wrote the empty ${CONTRIBUTION_FILE}` }
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
export default globalContribution
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// shipkit global-context: the house's generated context, printed, written, or
|
|
2
|
+
// checked against the file on disk. houseContext is the one computation, the
|
|
3
|
+
// daemon's writer calls it too.
|
|
4
|
+
import { existsSync, readFileSync } from 'fs'
|
|
5
|
+
import { Command, Option } from 'clipanion'
|
|
6
|
+
import { houseContext, writeIfChanged } from '../home/global-context.ts'
|
|
7
|
+
|
|
8
|
+
export class GlobalContextCommand extends Command {
|
|
9
|
+
static override paths = [['global-context']]
|
|
10
|
+
|
|
11
|
+
static override usage = Command.Usage({
|
|
12
|
+
category: 'Home',
|
|
13
|
+
description: 'the context every house agent loads: global processes, house repos, each repo\'s global contribution',
|
|
14
|
+
details: 'one md file beside the house index (generated/global-context.md), which the index includes, gitignored. The daemon rewrites it when a source moves; this command prints it, writes it, or fails when the file on disk is stale',
|
|
15
|
+
examples: [
|
|
16
|
+
['print what the file would hold', 'sk global-context'],
|
|
17
|
+
['write it beside the house index', 'sk global-context --write'],
|
|
18
|
+
['fail when the file on disk is stale', 'sk global-context --check'],
|
|
19
|
+
],
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
write = Option.Boolean('--write', false, { description: 'write it beside the house index, only when it changed' })
|
|
23
|
+
check = Option.Boolean('--check', false, { description: 'exit 1 when the file on disk differs from what the sources give' })
|
|
24
|
+
|
|
25
|
+
async execute(): Promise<number> {
|
|
26
|
+
const c = await houseContext(process.cwd())
|
|
27
|
+
if (c == null) {
|
|
28
|
+
this.context.stderr.write('FAIL: this repo includes no house index in its CLAUDE.md, so there is no house to read\n')
|
|
29
|
+
return 1
|
|
30
|
+
}
|
|
31
|
+
if (this.check) {
|
|
32
|
+
const now = existsSync(c.path) ? readFileSync(c.path, 'utf8') : null
|
|
33
|
+
if (now === c.text) {
|
|
34
|
+
this.context.stdout.write(`ok: ${c.path} is current\n`)
|
|
35
|
+
return 0
|
|
36
|
+
}
|
|
37
|
+
this.context.stdout.write(`FAIL: ${c.path} is ${now == null ? 'missing' : 'stale'}\n fix: shipkit global-context --write\n`)
|
|
38
|
+
return 1
|
|
39
|
+
}
|
|
40
|
+
if (this.write) {
|
|
41
|
+
this.context.stdout.write(writeIfChanged(c.path, c.text) ? `ok: wrote ${c.path}\n` : `ok: ${c.path} unchanged\n`)
|
|
42
|
+
return 0
|
|
43
|
+
}
|
|
44
|
+
this.context.stdout.write(c.text)
|
|
45
|
+
return 0
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -161,7 +161,7 @@ export type SwitchAct = 'start' | 'stop' | 'restart'
|
|
|
161
161
|
|
|
162
162
|
export type SwitchDeps = {
|
|
163
163
|
/** pm's answer to the switch: null when it took it, else why not */
|
|
164
|
-
post: (id: string, act: SwitchAct) => Promise<string | null>
|
|
164
|
+
post: (id: string, act: SwitchAct | 'open' | 'chrome' | 'app') => Promise<string | null>
|
|
165
165
|
/** the process as pm sees it now, null when pm does not answer */
|
|
166
166
|
view: (id: string) => Promise<ResourceView | null>
|
|
167
167
|
sleep: (ms: number) => Promise<void>
|
|
@@ -266,3 +266,40 @@ export class GlobalProcessRestartCommand extends SwitchCommand {
|
|
|
266
266
|
examples: [['restart ComfyUI, so it rescans its models', 'sk gp restart comfyui']],
|
|
267
267
|
})
|
|
268
268
|
}
|
|
269
|
+
|
|
270
|
+
/** where `sk gp open` sends the url, by flag, as pm's resource api names it */
|
|
271
|
+
export const openAct = (o: { chrome: boolean; app: boolean }): 'open' | 'chrome' | 'app' => (o.app ? 'app' : o.chrome ? 'chrome' : 'open')
|
|
272
|
+
|
|
273
|
+
export class GlobalProcessOpenCommand extends Command {
|
|
274
|
+
static override paths = [['global-processes', 'open'], ['gp', 'open']]
|
|
275
|
+
static override usage = Command.Usage({
|
|
276
|
+
category: 'Home',
|
|
277
|
+
description: 'open a global process\'s url in the browser, Chrome or its app, starting it first when down',
|
|
278
|
+
details: 'the overview\'s open buttons in the console. pm opens the url at once while the process answers, else starts it and opens once its probe answers. Exit 1 when pm refuses (no such process, no url declared)',
|
|
279
|
+
examples: [
|
|
280
|
+
['the comfy panel in the default browser', 'sk gp open comfy-panel'],
|
|
281
|
+
['in Chrome', 'sk gp open comfy-panel --chrome'],
|
|
282
|
+
['as its own app in the dock', 'sk gp open comfy-panel --app'],
|
|
283
|
+
],
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
id = Option.String({ required: true, name: 'id' })
|
|
287
|
+
chrome = Option.Boolean('--chrome', false, { description: 'open it in Chrome' })
|
|
288
|
+
app = Option.Boolean('--app', false, { description: 'open it as its own app, built on first use' })
|
|
289
|
+
|
|
290
|
+
async execute(): Promise<number> {
|
|
291
|
+
if (this.chrome && this.app) {
|
|
292
|
+
this.context.stderr.write('--chrome and --app name two places: pick one\n')
|
|
293
|
+
return 2
|
|
294
|
+
}
|
|
295
|
+
const act = openAct(this)
|
|
296
|
+
const refused = await pmSwitchDeps(() => {}).post(this.id, act)
|
|
297
|
+
if (refused != null) {
|
|
298
|
+
this.context.stdout.write(`FAIL open ${this.id}: ${refused}\n`)
|
|
299
|
+
return 1
|
|
300
|
+
}
|
|
301
|
+
const where = act === 'app' ? 'as its own app' : act === 'chrome' ? 'in Chrome' : 'in the default browser'
|
|
302
|
+
this.context.stdout.write(`ok: ${this.id} opens ${where}; a process that was down starts first (shipkit gp shows it)\n`)
|
|
303
|
+
return 0
|
|
304
|
+
}
|
|
305
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// shipkit insights: the insights page in the console. The boxes come from
|
|
2
|
+
// insightsOf, the one computation the daemon's route serves the page; this
|
|
3
|
+
// file only renders them as text. It reads the manifests directly and needs no
|
|
4
|
+
// daemon.
|
|
5
|
+
import { homedir } from 'os'
|
|
6
|
+
import { Command, Option } from 'clipanion'
|
|
7
|
+
import { devRepos, insightsOf, placeWords, rowMatches, SCOPES, tagsOf, type InsightsAnswer, type Scope } from '../home/insights.ts'
|
|
8
|
+
|
|
9
|
+
/** the boxes as text: a heading per box, then its rows, top `top` of each */
|
|
10
|
+
export function renderInsights(a: InsightsAnswer, o: { tag?: string | null; query?: string; top: number; uses: boolean }): string[] {
|
|
11
|
+
const out: string[] = []
|
|
12
|
+
for (const b of a.boxes) {
|
|
13
|
+
if (o.tag != null && !b.tags.includes(o.tag)) continue
|
|
14
|
+
const rows = b.rows.filter((r) => rowMatches(b, r, o.query ?? ''))
|
|
15
|
+
out.push(`${b.title} [${b.tags.join(', ')}] ${rows.length} ${b.noun}${rows.length === 1 ? '' : 's'}`)
|
|
16
|
+
const width = Math.max(0, ...rows.slice(0, o.top).map((r) => r.key.length))
|
|
17
|
+
for (const r of rows.slice(0, o.top)) {
|
|
18
|
+
out.push(` ${r.key.padEnd(width)} ${String(r.repos).padStart(3)} repos ${r.values.slice(0, 3).join(' ')}${r.values.length > 3 ? ` +${r.values.length - 3}` : ''}`)
|
|
19
|
+
if (o.uses) for (const u of r.uses) out.push(` ${placeWords(u)} ${u.value}${u.dev ? ' (dev)' : ''}`)
|
|
20
|
+
}
|
|
21
|
+
if (rows.length > o.top) out.push(` … ${rows.length - o.top} more`)
|
|
22
|
+
out.push('')
|
|
23
|
+
}
|
|
24
|
+
out.push(`${a.repos} repos, ${a.files} package.json, read in ${a.ms}ms`)
|
|
25
|
+
for (const e of a.errors) out.push(`WARN ${e}`)
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class InsightsCommand extends Command {
|
|
30
|
+
static override paths = [['insights']]
|
|
31
|
+
|
|
32
|
+
static override usage = Command.Usage({
|
|
33
|
+
category: 'Home',
|
|
34
|
+
description: 'the insights page as text: boxes about every repo, like which packages they use and by how many',
|
|
35
|
+
details: 'every package.json the house repos track (or every folder of ~/dev with --scope dev), turned into boxes: dependency usage sorted by how many repos use each package, version drift, script names. The same computation the page reads, no daemon needed',
|
|
36
|
+
examples: [
|
|
37
|
+
['every box, the house repos', 'sk insights'],
|
|
38
|
+
['every repo of ~/dev', 'sk insights --scope dev'],
|
|
39
|
+
['where a package is used, and at which range', 'sk insights --scope dev --tag deps --query zod --uses'],
|
|
40
|
+
['the whole list of one tag', 'sk insights --tag deps --top 1000'],
|
|
41
|
+
['the route\'s exact payload', 'sk insights --json'],
|
|
42
|
+
],
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
scope = Option.String('--scope', 'house', { description: 'house (the project list) or dev (every folder of ~/dev)' })
|
|
46
|
+
tag = Option.String('--tag', { required: false, description: 'only the boxes carrying this tag' })
|
|
47
|
+
query = Option.String('--query', { required: false, description: 'only the rows whose key or a value holds this text' })
|
|
48
|
+
top = Option.String('--top', '20', { description: 'rows shown per box' })
|
|
49
|
+
uses = Option.Boolean('--uses', false, { description: 'list every use under each row' })
|
|
50
|
+
json = Option.Boolean('--json', false, { description: 'the daemon route\'s exact payload: { now, scope, repos, files, boxes, errors, ms }' })
|
|
51
|
+
|
|
52
|
+
async execute(): Promise<number> {
|
|
53
|
+
if (!(SCOPES as readonly string[]).includes(this.scope)) {
|
|
54
|
+
this.context.stderr.write(`--scope must be one of ${SCOPES.join(', ')}, got ${JSON.stringify(this.scope)}\n`)
|
|
55
|
+
return 2
|
|
56
|
+
}
|
|
57
|
+
const scope = this.scope as Scope
|
|
58
|
+
const top = Number(this.top)
|
|
59
|
+
if (!(top >= 1)) {
|
|
60
|
+
this.context.stderr.write(`--top must be a number from 1, got ${JSON.stringify(this.top)}\n`)
|
|
61
|
+
return 2
|
|
62
|
+
}
|
|
63
|
+
const home = process.env.HOME || homedir()
|
|
64
|
+
// the hub route module draws app icons, so it loads only when this runs
|
|
65
|
+
const repos = scope === 'dev' ? devRepos(home) : (await import('../serve/project-gutter-route.ts')).railRepos(process.cwd(), home)
|
|
66
|
+
const a = await insightsOf(repos, scope)
|
|
67
|
+
if (this.json) {
|
|
68
|
+
this.context.stdout.write(`${JSON.stringify(a, null, 2)}\n`)
|
|
69
|
+
return 0
|
|
70
|
+
}
|
|
71
|
+
if (this.tag != null && !tagsOf(a.boxes).includes(this.tag)) {
|
|
72
|
+
this.context.stderr.write(`--tag must be one of ${tagsOf(a.boxes).join(', ')}, got ${JSON.stringify(this.tag)}\n`)
|
|
73
|
+
return 2
|
|
74
|
+
}
|
|
75
|
+
for (const l of renderInsights(a, { tag: this.tag ?? null, query: this.query, top, uses: this.uses })) this.context.stdout.write(`${l}\n`)
|
|
76
|
+
return 0
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -11,8 +11,10 @@ import { CheckCommand } from './cli/cmd_Check.ts'
|
|
|
11
11
|
import { CommandsCommand } from './cli/cmd_Commands.ts'
|
|
12
12
|
import { aliasArgs, CrossRepoActivityCommand } from './cli/cmd_CrossRepoActivity.ts'
|
|
13
13
|
import { ConfigKeysCommand } from './cli/cmd_ConfigKeys.ts'
|
|
14
|
-
import { GlobalProcessesCommand, GlobalProcessRestartCommand, GlobalProcessStartCommand, GlobalProcessStopCommand } from './cli/cmd_GlobalProcesses.ts'
|
|
14
|
+
import { GlobalProcessesCommand, GlobalProcessOpenCommand, GlobalProcessRestartCommand, GlobalProcessStartCommand, GlobalProcessStopCommand } from './cli/cmd_GlobalProcesses.ts'
|
|
15
15
|
import { PlannedCommand } from './cli/cmd_Planned.ts'
|
|
16
|
+
import { InsightsCommand } from './cli/cmd_Insights.ts'
|
|
17
|
+
import { GlobalContextCommand } from './cli/cmd_GlobalContext.ts'
|
|
16
18
|
import { OverviewCommand } from './cli/cmd_Overview.ts'
|
|
17
19
|
import { CiCommand } from './cli/cmd_Ci.ts'
|
|
18
20
|
import { ContractsCommand } from './cli/cmd_Contracts.ts'
|
|
@@ -97,8 +99,11 @@ export function buildCli(): Cli {
|
|
|
97
99
|
cli.register(GlobalProcessStartCommand)
|
|
98
100
|
cli.register(GlobalProcessStopCommand)
|
|
99
101
|
cli.register(GlobalProcessRestartCommand)
|
|
102
|
+
cli.register(GlobalProcessOpenCommand)
|
|
100
103
|
cli.register(CrossRepoActivityCommand)
|
|
101
104
|
cli.register(PlannedCommand)
|
|
105
|
+
cli.register(InsightsCommand)
|
|
106
|
+
cli.register(GlobalContextCommand)
|
|
102
107
|
cli.register(OverviewCommand)
|
|
103
108
|
cli.register(ChecksCommand)
|
|
104
109
|
cli.register(WhenCommand)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// a house repo's global contribution: `agent/global-contribution.md`, what the
|
|
2
|
+
// repo offers every agent of every house repo (commands, how to run them).
|
|
3
|
+
// The house's generated context (home/global-context.ts) concatenates them,
|
|
4
|
+
// and every session of every house repo loads it, so each line costs tokens
|
|
5
|
+
// everywhere: CONTRIBUTION_MAX_LINES and CONTRIBUTION_MAX_BYTES bound it.
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from 'fs'
|
|
8
|
+
|
|
9
|
+
export const CONTRIBUTION_FILE = 'agent/global-contribution.md'
|
|
10
|
+
/** the line a house index carries to load the generated context */
|
|
11
|
+
export const CONTEXT_INCLUDE = '@generated/global-context.md'
|
|
12
|
+
export const CONTRIBUTION_MAX_LINES = 40
|
|
13
|
+
export const CONTRIBUTION_MAX_BYTES = 3_000
|
|
14
|
+
|
|
15
|
+
/** what a new repo gets: a comment only, so it contributes nothing yet */
|
|
16
|
+
export const CONTRIBUTION_STUB = `<!-- global contribution: what this repo offers every agent in every house repo, such as the commands another repo can run and what they produce. The house concatenates every repo's file into one context loaded in every session, so keep it to what another repo's agent can act on: at most ${CONTRIBUTION_MAX_LINES} lines, no heading above ####. Empty contributes nothing. This comment is dropped. -->
|
|
17
|
+
`
|
|
18
|
+
|
|
19
|
+
/** the text that joins the context: html comments dropped, trimmed */
|
|
20
|
+
export const contributionBody = (text: string): string => text.replace(/<!--[\s\S]*?-->/g, '').trim()
|
|
21
|
+
|
|
22
|
+
/** whether a house index loads the generated context: only then does a
|
|
23
|
+
* contribution reach anyone */
|
|
24
|
+
export function loadsContext(index: string): boolean {
|
|
25
|
+
try {
|
|
26
|
+
return readFileSync(index, 'utf8').split('\n').some((l) => l.trim() === CONTEXT_INCLUDE)
|
|
27
|
+
} catch {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** why a body breaks the house context, or null: over its bounds, or a
|
|
33
|
+
* heading that would outrank the `### <repo>` it sits under */
|
|
34
|
+
export function contributionOver(body: string): string | null {
|
|
35
|
+
const lines = body === '' ? 0 : body.split('\n').length
|
|
36
|
+
const heading = body.replace(/^```[\s\S]*?^```/gm, '').split('\n').find((l) => /^#{1,3}\s/.test(l))
|
|
37
|
+
if (heading != null) return `holding the heading "${heading.slice(0, 40)}", above ####`
|
|
38
|
+
const bytes = Buffer.byteLength(body)
|
|
39
|
+
if (lines > CONTRIBUTION_MAX_LINES) return `${lines} lines, over the ${CONTRIBUTION_MAX_LINES} line cap`
|
|
40
|
+
if (bytes > CONTRIBUTION_MAX_BYTES) return `${bytes} bytes, over the ${CONTRIBUTION_MAX_BYTES} byte cap`
|
|
41
|
+
return null
|
|
42
|
+
}
|
package/src/core/trace-audit.ts
CHANGED
|
@@ -47,18 +47,27 @@ function covered(p: string, listing: boolean, extras: string[]): boolean {
|
|
|
47
47
|
return extras.some((e) => (isNamesInput(e) ? listing && under(p, e.slice(0, -3)) : under(p, e)))
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
/** the fewest entries that cover what was read: each file by itself, or
|
|
51
|
-
* dir once more than three files
|
|
52
|
-
*
|
|
50
|
+
/** the fewest entries that cover what was read: each file by itself, or a
|
|
51
|
+
* dir by content once more than three files under it were read (its own
|
|
52
|
+
* dir, or the outermost dir the task also listed); each listed dir as
|
|
53
|
+
* `dir/**`, the outermost one only, unless a content entry covers it */
|
|
53
54
|
export function proposeInputs(content: string[], listed: string[]): string[] {
|
|
54
|
-
const byDir = new Map<string, string[]>()
|
|
55
|
-
for (const f of content) byDir.set(posix.dirname(f), [...(byDir.get(posix.dirname(f)) ?? []), f])
|
|
56
|
-
const bytes = [...byDir].flatMap(([dir, files]) => (files.length > 3 && dir !== '.' ? [dir] : files))
|
|
57
55
|
const minimal = (xs: string[]): string[] => {
|
|
58
56
|
const sorted = [...new Set(xs)].sort()
|
|
59
57
|
return sorted.filter((x) => !sorted.some((y) => y !== x && under(x, y)))
|
|
60
58
|
}
|
|
61
|
-
|
|
59
|
+
const tops = minimal(listed)
|
|
60
|
+
const byDir = new Map<string, string[]>()
|
|
61
|
+
for (const f of content) {
|
|
62
|
+
const d = tops.find((t) => under(f, t)) ?? posix.dirname(f)
|
|
63
|
+
byDir.set(d, [...(byDir.get(d) ?? []), f])
|
|
64
|
+
}
|
|
65
|
+
// a listed dir holding three or fewer: each file under its own dir
|
|
66
|
+
const regroup = [...byDir].flatMap(([dir, files]) => (tops.includes(dir) && files.length <= 3 ? files.map((f) => [posix.dirname(f), f] as const) : files.map((f) => [dir, f] as const)))
|
|
67
|
+
const again = new Map<string, string[]>()
|
|
68
|
+
for (const [d, f] of regroup) again.set(d, [...(again.get(d) ?? []), f])
|
|
69
|
+
const bytes = minimal([...again].flatMap(([dir, files]) => (files.length > 3 && dir !== '.' ? [dir] : files)))
|
|
70
|
+
return [...bytes, ...tops.filter((d) => !bytes.some((b) => under(d, b))).map((d) => `${d}/**`)].sort()
|
|
62
71
|
}
|
|
63
72
|
|
|
64
73
|
/** trace every gate task of the repo once and report what its key misses */
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// the house's generated context: every global process with the commands that
|
|
2
|
+
// drive it, every house repo, and each repo's global contribution, as ONE md
|
|
3
|
+
// file the house index includes, so every agent of every house repo loads it.
|
|
4
|
+
// It lives beside the index (`generated/global-context.md`), gitignored: its
|
|
5
|
+
// sources are the repos on this disk, so it is this machine's, never history.
|
|
6
|
+
//
|
|
7
|
+
// the one constraint a reader breaks first: it is in every session's context,
|
|
8
|
+
// so a contribution over its cap is cut here, and SK033 fails in its repo.
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
10
|
+
import { homedir } from 'os'
|
|
11
|
+
import { dirname, join } from 'path'
|
|
12
|
+
import { CONTRIBUTION_FILE, CONTRIBUTION_MAX_BYTES, CONTRIBUTION_MAX_LINES, contributionBody, contributionOver, loadsContext } from '../core/global-contribution.ts'
|
|
13
|
+
import { KEYWORD_FILE } from '../core/layout.ts'
|
|
14
|
+
import { evaluateLeak, globalKeywordFile, maskPattern, parsePatterns } from '../hooks/leak-guard.ts'
|
|
15
|
+
import { homeRepo, houseRepos } from '../serve/project-gutter.ts'
|
|
16
|
+
import { scanGlobalUnits, type GlobalProcessDecl } from './global-units.ts'
|
|
17
|
+
|
|
18
|
+
/** how often the daemon looks whether a source moved */
|
|
19
|
+
export const GLOBAL_CONTEXT_EVERY_MS = 60_000
|
|
20
|
+
|
|
21
|
+
export type ContextRepo = { name: string; path: string; description: string | null; contribution: string }
|
|
22
|
+
export type ContextProcess = Pick<GlobalProcessDecl, 'id' | 'label' | 'repo' | 'url' | 'description'>
|
|
23
|
+
|
|
24
|
+
/** the banned keyword a text matches, masked, or null: every item is
|
|
25
|
+
* scanned before it joins, since every session everywhere loads the file */
|
|
26
|
+
export type Leak = (text: string) => string | null
|
|
27
|
+
|
|
28
|
+
/** a repo description's longest form here: the rest is the repo's own */
|
|
29
|
+
export const DESCRIPTION_MAX = 160
|
|
30
|
+
|
|
31
|
+
/** where a house keeps it: beside its index */
|
|
32
|
+
export const globalContextPath = (index: string): string => join(dirname(index), 'generated', 'global-context.md')
|
|
33
|
+
|
|
34
|
+
/** a body cut to the caps, with the line that says so */
|
|
35
|
+
function capped(r: ContextRepo, body: string): string {
|
|
36
|
+
const over = contributionOver(body)
|
|
37
|
+
if (over == null) return body
|
|
38
|
+
// a heading above #### would outrank the `### <repo>` it sits under
|
|
39
|
+
let cut = body.replace(/^#{1,3}(?=\s)/gm, '####').split('\n').slice(0, CONTRIBUTION_MAX_LINES).join('\n')
|
|
40
|
+
while (Buffer.byteLength(cut) > CONTRIBUTION_MAX_BYTES) cut = cut.slice(0, cut.lastIndexOf('\n'))
|
|
41
|
+
return `${cut}\n\n(cut: ${r.name}/${CONTRIBUTION_FILE} is ${over}, and SK033 fails there until it fits)`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** a description's first line, at most DESCRIPTION_MAX characters */
|
|
45
|
+
const short = (d: string): string => {
|
|
46
|
+
const l = d.split('\n')[0]!.trim()
|
|
47
|
+
return l.length <= DESCRIPTION_MAX ? l : `${l.slice(0, DESCRIPTION_MAX - 1)}…`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const cell = (s: string): string => s.replace(/\|/g, '\\|').replace(/\n/g, ' ')
|
|
51
|
+
|
|
52
|
+
/** a description's first line only: the rest (a login, a how to) is the
|
|
53
|
+
* hub's to show, never every session's */
|
|
54
|
+
const firstLine = (d: string | null): string => {
|
|
55
|
+
const l = d?.split('\n')[0]?.trim() ?? ''
|
|
56
|
+
return l === '' ? '' : `: ${cell(l)}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** the file's text. Pure: the same sources always give the same bytes, so a
|
|
60
|
+
* write happens only when a source moved */
|
|
61
|
+
export function globalContextText(o: { repos: ContextRepo[]; processes: ContextProcess[]; errors: string[]; leak?: Leak }): string {
|
|
62
|
+
const left: string[] = []
|
|
63
|
+
/** the item when it matches no banned keyword, else null and a line saying so */
|
|
64
|
+
const clean = (where: string, text: string): string | null => {
|
|
65
|
+
const hit = o.leak?.(text) ?? null
|
|
66
|
+
if (hit == null) return text
|
|
67
|
+
left.push(`- ${where}: matches the banned keyword ${hit}`)
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
const out: string[] = [
|
|
71
|
+
'# global context: this machine, for every agent',
|
|
72
|
+
'',
|
|
73
|
+
`Generated by \`sk global-context --write\` from every house repo (the daemon rewrites it when a source moves). Never edit it: a repo adds to it through its own \`${CONTRIBUTION_FILE}\`.`,
|
|
74
|
+
'',
|
|
75
|
+
'## Global processes',
|
|
76
|
+
'',
|
|
77
|
+
]
|
|
78
|
+
if (o.processes.length === 0) out.push('No house repo declares one.')
|
|
79
|
+
else {
|
|
80
|
+
out.push('Drive each by id: `sk gp start|stop|restart <id>` waits for its probe, `sk gp open <id> [--chrome|--app]` opens its url and starts it first when it is down, `sk gp` shows where each stands.', '')
|
|
81
|
+
out.push('| id | process | repo | url |', '|---|---|---|---|')
|
|
82
|
+
for (const p of [...o.processes].sort((a, b) => a.repo.localeCompare(b.repo) || a.id.localeCompare(b.id))) {
|
|
83
|
+
const row = clean(`${p.repo} process ${p.id}`, `| \`${p.id}\` | ${cell(p.label)}${firstLine(p.description)} | ${p.repo} | ${p.url ?? 'no url: start, stop, restart only'} |`)
|
|
84
|
+
if (row != null) out.push(row)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
out.push('', '## House repos', '')
|
|
88
|
+
for (const r of o.repos) {
|
|
89
|
+
const line = clean(`${r.name} repo line`, `- **${r.name}** \`${r.path}\`${r.description == null ? '' : `: ${short(r.description)}`}`)
|
|
90
|
+
if (line != null) out.push(line)
|
|
91
|
+
}
|
|
92
|
+
const given = o.repos
|
|
93
|
+
.map((r) => ({ r, body: contributionBody(r.contribution) }))
|
|
94
|
+
.filter((x) => x.body !== '' && clean(`${x.r.name}/${CONTRIBUTION_FILE}`, x.body) != null)
|
|
95
|
+
out.push('', '## What each repo offers', '')
|
|
96
|
+
if (given.length === 0) out.push(`No repo contributes yet: each writes its own \`${CONTRIBUTION_FILE}\`.`)
|
|
97
|
+
for (const { r, body } of given) out.push(`### ${r.name}`, '', capped(r, body), '')
|
|
98
|
+
if (left.length > 0) out.push('', '## Left out', '', ...left)
|
|
99
|
+
if (o.errors.length > 0) out.push('', '## Could not read', '', ...o.errors.map((e) => `- ${e}`))
|
|
100
|
+
return `${out.join('\n').trimEnd()}\n`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** one repo's line and contribution, read from disk */
|
|
104
|
+
export function contextRepo(r: { name: string; path: string }, home: string = homedir()): ContextRepo {
|
|
105
|
+
let description: string | null = null
|
|
106
|
+
try {
|
|
107
|
+
const d = (JSON.parse(readFileSync(join(r.path, 'package.json'), 'utf8')) as { description?: unknown }).description
|
|
108
|
+
if (typeof d === 'string' && d.trim() !== '') description = d.trim()
|
|
109
|
+
} catch { /* no package.json, or one that does not parse: no line */ }
|
|
110
|
+
const f = join(r.path, CONTRIBUTION_FILE)
|
|
111
|
+
return { name: r.name, path: r.path.startsWith(`${home}/`) ? `~${r.path.slice(home.length)}` : r.path, description, contribution: existsSync(f) ? readFileSync(f, 'utf8') : '' }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** writes the text when it differs from what the file holds; true when it wrote */
|
|
115
|
+
export function writeIfChanged(path: string, text: string): boolean {
|
|
116
|
+
if (existsSync(path) && readFileSync(path, 'utf8') === text) return false
|
|
117
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
118
|
+
writeFileSync(path, text)
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** the leak scan over the machine's banned keywords and the house repo's own,
|
|
123
|
+
* the lists the commit leak guard reads; null when neither exists */
|
|
124
|
+
export function houseLeak(houseRoot: string): Leak | null {
|
|
125
|
+
const files = [globalKeywordFile(), join(houseRoot, KEYWORD_FILE)].filter((f): f is string => f != null && existsSync(f))
|
|
126
|
+
if (files.length === 0) return null
|
|
127
|
+
const patterns = parsePatterns(files.map((f) => readFileSync(f, 'utf8')).join('\n'))
|
|
128
|
+
return (text) => {
|
|
129
|
+
const hit = evaluateLeak([{ where: 'global context', content: text }], patterns, []).hits[0]
|
|
130
|
+
return hit == null ? null : maskPattern(hit.pattern)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** THE computation, for the house the repo at `root` belongs to: where the
|
|
135
|
+
* file goes and what it holds. The cli and the daemon both call it. Null
|
|
136
|
+
* when `root` includes no house index, or one that does not load the file */
|
|
137
|
+
export async function houseContext(root: string, home: string = homedir()): Promise<{ path: string; text: string } | null> {
|
|
138
|
+
const h = houseRepos(homeRepo(root), home)
|
|
139
|
+
if (h.index == null || !loadsContext(h.index)) return null
|
|
140
|
+
const units = await scanGlobalUnits(h.repos.map((r) => ({ name: r.name, path: r.path })))
|
|
141
|
+
return {
|
|
142
|
+
path: globalContextPath(h.index),
|
|
143
|
+
text: globalContextText({ repos: h.repos.map((r) => contextRepo(r, home)), processes: units.processes, errors: units.errors, leak: houseLeak(h.home) ?? undefined }),
|
|
144
|
+
}
|
|
145
|
+
}
|