rvlib-shipkit 0.10.0 → 0.13.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 +204 -0
- package/README.md +17 -3
- package/package.json +5 -2
- package/src/asks/asks-queue.ts +42 -0
- package/src/assets/kit-config.ts +2 -3
- package/src/assets/roll-workspace.ts +2 -1
- package/src/assets/run-assets.ts +0 -11
- package/src/assets/server/studio-server.ts +7 -24
- package/src/cli/cmd_Assets.ts +3 -14
- package/src/cli/cmd_Check.ts +22 -4
- package/src/cli/cmd_Needs.ts +47 -0
- package/src/cli/cmd_Reflect.ts +116 -0
- package/src/cli/cmd_Rules.ts +32 -0
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli.ts +7 -2
- package/src/collections/actions.ts +85 -0
- package/src/collections/collection-types.ts +65 -0
- package/src/collections/graphs.ts +34 -0
- package/src/collections/pings.ts +72 -0
- package/src/collections/posts.ts +125 -0
- package/src/collections/providers.ts +275 -0
- package/src/contracts/apply-contracts.ts +47 -0
- package/src/contracts/run-contracts.ts +8 -3
- package/src/core/canonical-scripts.ts +46 -0
- package/src/core/check-suite.ts +6 -3
- package/src/core/locked-write.ts +39 -0
- package/src/core/motherhome.ts +87 -0
- package/src/core/run-check.ts +28 -15
- package/src/core/run-ci.ts +4 -0
- package/src/core/write-status.ts +7 -26
- package/src/init-repo.ts +24 -11
- package/src/reflect/prompt-corpus.ts +305 -0
- package/src/repo-config.ts +26 -38
- package/src/rules/SK001-package-json-shape.ts +156 -0
- package/src/rules/SK002-contracts-fulfillness.ts +19 -0
- package/src/rules/SK003-claude-md-shape.ts +64 -0
- package/src/rules/SK004-gitignore-policy.ts +105 -0
- package/src/rules/SK005-rvlib-versions.ts +99 -0
- package/src/rules/SK006-npm-publish-safety.ts +42 -0
- package/src/rules/SK007-github-visibility.ts +63 -0
- package/src/rules/rule-registry.ts +32 -0
- package/src/rules/rule.ts +62 -0
- package/src/rules/run-fix.ts +57 -0
- package/src/serve/hub-app/col-card.tsx +192 -0
- package/src/serve/hub-app/cols-svc.ts +136 -0
- package/src/serve/hub-app/geometry-view.tsx +155 -0
- package/src/serve/hub-app/health-svc.ts +49 -0
- package/src/serve/hub-app/hub-app.tsx +240 -127
- package/src/serve/hub-app/hub-st.ts +169 -0
- package/src/serve/hub-app/hub-types.ts +72 -0
- package/src/serve/hub-app/hub.css +286 -35
- package/src/serve/hub-app/index.html +1 -1
- package/src/serve/hub-app/md.ts +24 -0
- package/src/serve/hub-app/media-svc.ts +429 -0
- package/src/serve/hub-app/media-view.tsx +299 -0
- package/src/serve/run-serve.ts +46 -91
- package/src/serve/serve-hub.ts +99 -49
- package/src/shipkit.ts +13 -6
- package/src/assets/server/studio-page.ts +0 -687
- package/src/core/package-json-check.ts +0 -88
- package/src/serve/hub-pages.ts +0 -45
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// shipkit reflect — the user's typed prompts across every Claude Code session
|
|
2
|
+
// of a repo, as a chronological corpus for retrospective extraction passes.
|
|
3
|
+
// REPORT-ONLY: exit 0 with prompts, 1 when a repo has no transcripts (loud,
|
|
4
|
+
// naming the config roots that were searched).
|
|
5
|
+
import { mkdirSync, writeFileSync } from 'fs'
|
|
6
|
+
import { basename, join, resolve } from 'path'
|
|
7
|
+
import { Command, Option } from 'clipanion'
|
|
8
|
+
import { type PromptEntry, collectRepoPrompts, corpusMarkdown, projectSlug } from '../reflect/prompt-corpus.ts'
|
|
9
|
+
|
|
10
|
+
const SHORT_MAX_LINES = 20
|
|
11
|
+
|
|
12
|
+
/** the --json unit: one per requested repo, failures carried in-band */
|
|
13
|
+
export type RepoDoc = { repo: string; prompts: PromptEntry[]; error?: string }
|
|
14
|
+
|
|
15
|
+
export class ReflectCommand extends Command {
|
|
16
|
+
static override paths = [['reflect']]
|
|
17
|
+
|
|
18
|
+
static override usage = Command.Usage({
|
|
19
|
+
category: 'Health',
|
|
20
|
+
description:
|
|
21
|
+
"the user's typed prompts across every Claude Code session of a repo — chronological md corpus for retrospectives",
|
|
22
|
+
examples: [
|
|
23
|
+
['corpus for the current repo', 'shipkit reflect'],
|
|
24
|
+
['corpus for other repos', 'shipkit reflect ~/dev/one-repo ~/dev/another-repo'],
|
|
25
|
+
['drop the long pastes', 'shipkit reflect --short'],
|
|
26
|
+
['batch to files instead of stdout', 'shipkit reflect ~/dev/a ~/dev/b --out stats'],
|
|
27
|
+
['machine-readable', 'shipkit reflect --json'],
|
|
28
|
+
],
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
repoPaths = Option.Rest()
|
|
32
|
+
json = Option.Boolean('--json', false, { description: 'emit [{repo, prompts, error?}] JSON — same shape for 1 or N repos' })
|
|
33
|
+
short = Option.Boolean('--short', false, { description: `only prompts of ${SHORT_MAX_LINES} lines or fewer` })
|
|
34
|
+
out = Option.String('--out', { description: 'write <repo>[.short].md (or .json) per repo into this dir instead of stdout' })
|
|
35
|
+
|
|
36
|
+
async execute(): Promise<number> {
|
|
37
|
+
const repos = this.repoPaths.length > 0 ? this.repoPaths : [process.cwd()]
|
|
38
|
+
if (this.out != null) mkdirSync(this.out, { recursive: true })
|
|
39
|
+
let failed = false
|
|
40
|
+
const jsonDocs: RepoDoc[] = []
|
|
41
|
+
for (const repo of repos) {
|
|
42
|
+
const { prompts, dirs, roots } = collectRepoPrompts(repo)
|
|
43
|
+
const kept = this.short ? prompts.filter((p) => p.text.split('\n').length <= SHORT_MAX_LINES) : prompts
|
|
44
|
+
if (kept.length === 0) {
|
|
45
|
+
const error = this.fail(repo, prompts.length, roots)
|
|
46
|
+
failed = true
|
|
47
|
+
if (this.json) jsonDocs.push({ repo: resolve(repo), prompts: [], error })
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
if (this.json) jsonDocs.push({ repo: resolve(repo), prompts: kept })
|
|
51
|
+
else this.emitMarkdown(repo, kept, dirs)
|
|
52
|
+
}
|
|
53
|
+
if (this.json) this.emitJson(jsonDocs)
|
|
54
|
+
return failed ? 1 : 0
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** one FAIL line per repo, and a fix that is not "do what you just did".
|
|
58
|
+
* Returns the same reason for the --json payload: a consumer must not have
|
|
59
|
+
* to diff stderr against stdout to learn a repo produced nothing. */
|
|
60
|
+
private fail(repo: string, total: number, roots: string[]): string {
|
|
61
|
+
const e = this.context.stderr
|
|
62
|
+
if (this.short && total > 0) {
|
|
63
|
+
const reason = `${total} prompts, none of ${SHORT_MAX_LINES} lines or fewer`
|
|
64
|
+
e.write(`FAIL: ${resolve(repo)}: ${reason}\n`)
|
|
65
|
+
e.write(` fix: drop --short\n`)
|
|
66
|
+
return reason
|
|
67
|
+
}
|
|
68
|
+
e.write(`FAIL: no typed prompts for ${resolve(repo)}\n`)
|
|
69
|
+
e.write(` looked for projects/${projectSlug(repo)}* under: ${roots.join(' · ')}\n`)
|
|
70
|
+
e.write(
|
|
71
|
+
this.repoPaths.length === 0
|
|
72
|
+
? ` fix: this repo has no Claude Code sessions — name another, shipkit reflect ~/dev/<repo>\n`
|
|
73
|
+
: ` fix: check the path (it is slugged verbatim) — a session dir appears only after a session ran there\n`,
|
|
74
|
+
)
|
|
75
|
+
return 'no typed prompts'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** ONE shape whatever the run looked like: RepoDoc[], failures included with
|
|
79
|
+
* their reason. A data-dependent schema breaks every consumer eventually. */
|
|
80
|
+
private emitJson(docs: RepoDoc[]): void {
|
|
81
|
+
if (this.out == null) {
|
|
82
|
+
this.context.stdout.write(`${JSON.stringify(docs, null, 2)}\n`)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
for (const d of docs) {
|
|
86
|
+
if (d.error != null) continue
|
|
87
|
+
const file = this.outFile(d.repo, '.json')
|
|
88
|
+
writeFileSync(file, `${JSON.stringify(d, null, 2)}\n`)
|
|
89
|
+
this.context.stdout.write(`ok: ${this.counts(d.prompts)} → ${file}\n`)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** queued prompts are the ones typed mid-run: say how many, so a future hole
|
|
94
|
+
* in that path is visible instead of silent */
|
|
95
|
+
private counts(prompts: PromptEntry[]): string {
|
|
96
|
+
const queued = prompts.filter((p) => p.origin === 'queued').length
|
|
97
|
+
const base = `${String(prompts.length).padStart(4)} prompts`
|
|
98
|
+
return queued === 0 ? base : `${base} (${queued} queued mid-run)`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private emitMarkdown(repo: string, prompts: PromptEntry[], dirs: string[]): void {
|
|
102
|
+
const md = corpusMarkdown(repo, prompts)
|
|
103
|
+
if (this.out == null) {
|
|
104
|
+
this.context.stdout.write(md)
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
const file = this.outFile(repo, '.md')
|
|
108
|
+
writeFileSync(file, md)
|
|
109
|
+
this.context.stdout.write(`ok: ${this.counts(prompts)} → ${file} (${dirs.length} session dir(s))\n`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** --short writes its OWN file: the filtered corpus must never overwrite the full one */
|
|
113
|
+
private outFile(repo: string, ext: string): string {
|
|
114
|
+
return join(this.out ?? '.', `${basename(resolve(repo))}${this.short ? '.short' : ''}${ext}`)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// shipkit rules — render the SK rule registry. Agents read ids HERE (or
|
|
2
|
+
// --json), never guess or mint one: the registry is the single source.
|
|
3
|
+
import { Command, Option } from 'clipanion'
|
|
4
|
+
import { RULES } from '../rules/rule-registry.ts'
|
|
5
|
+
|
|
6
|
+
export class RulesCommand extends Command {
|
|
7
|
+
static override paths = [['rules']]
|
|
8
|
+
|
|
9
|
+
static override usage = Command.Usage({
|
|
10
|
+
category: 'Health',
|
|
11
|
+
description: 'list every built-in SK rule: id, what it lints, owning doc, whether check --fix can repair it',
|
|
12
|
+
examples: [
|
|
13
|
+
['list rules', 'shipkit rules'],
|
|
14
|
+
['machine-readable', 'shipkit rules --json'],
|
|
15
|
+
],
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
json = Option.Boolean('--json', false, { description: 'machine-readable [{id, title, owner, fixable}]' })
|
|
19
|
+
|
|
20
|
+
async execute(): Promise<number> {
|
|
21
|
+
const rows = RULES.map((r) => ({ id: r.id, title: r.title, owner: r.owner, fixable: r.fix != null }))
|
|
22
|
+
if (this.json) {
|
|
23
|
+
this.context.stdout.write(`${JSON.stringify(rows)}\n`)
|
|
24
|
+
return 0
|
|
25
|
+
}
|
|
26
|
+
// a catalog, not a health run — no wire prefixes (`ok:` would assert
|
|
27
|
+
// a passing check into anything that parses wire output)
|
|
28
|
+
for (const r of rows)
|
|
29
|
+
this.context.stdout.write(`${r.id} ${r.title} — ${r.owner}${r.fixable ? ' · fixable' : ''}\n`)
|
|
30
|
+
return 0
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/cli/cmd_Serve.ts
CHANGED
|
@@ -8,7 +8,7 @@ export class ServeCommand extends Command {
|
|
|
8
8
|
|
|
9
9
|
static override usage = Command.Usage({
|
|
10
10
|
category: 'Serve',
|
|
11
|
-
description: 'ONE
|
|
11
|
+
description: 'ONE server, ONE page: media studio + asks + creds + posts + health + graphs, with drill-down graph pages',
|
|
12
12
|
examples: [
|
|
13
13
|
['serve the repo toolbox', 'shipkit serve'],
|
|
14
14
|
['pin the port for this launch', 'shipkit serve --port 4804'],
|
package/src/cli.ts
CHANGED
|
@@ -5,12 +5,15 @@
|
|
|
5
5
|
import { readFileSync } from 'fs'
|
|
6
6
|
import { Builtins, Cli } from 'clipanion'
|
|
7
7
|
import { AskNewCommand, AsksCheckCommand, AsksCommand } from './cli/cmd_Asks.ts'
|
|
8
|
-
import { AssetsAddCommand, AssetsCheckCommand, AssetsFillCommand, AssetsGenCommand, AssetsIdentityCommand
|
|
8
|
+
import { AssetsAddCommand, AssetsCheckCommand, AssetsFillCommand, AssetsGenCommand, AssetsIdentityCommand } from './cli/cmd_Assets.ts'
|
|
9
9
|
import { CheckCommand } from './cli/cmd_Check.ts'
|
|
10
10
|
import { CiCommand } from './cli/cmd_Ci.ts'
|
|
11
11
|
import { ContractsCommand } from './cli/cmd_Contracts.ts'
|
|
12
12
|
import { InitCommand } from './cli/cmd_Init.ts'
|
|
13
13
|
import { MvCommand } from './cli/cmd_Mv.ts'
|
|
14
|
+
import { NeedsCommand } from './cli/cmd_Needs.ts'
|
|
15
|
+
import { ReflectCommand } from './cli/cmd_Reflect.ts'
|
|
16
|
+
import { RulesCommand } from './cli/cmd_Rules.ts'
|
|
14
17
|
import { ServeCommand } from './cli/cmd_Serve.ts'
|
|
15
18
|
import { StartCommand } from './cli/cmd_Start.ts'
|
|
16
19
|
import { TuiCommand } from './cli/cmd_Tui.ts'
|
|
@@ -39,7 +42,6 @@ cli.register(AsksCheckCommand)
|
|
|
39
42
|
cli.register(AskNewCommand)
|
|
40
43
|
cli.register(WikiServeCommand)
|
|
41
44
|
cli.register(WikiLintCommand)
|
|
42
|
-
cli.register(AssetsServeCommand)
|
|
43
45
|
cli.register(AssetsFillCommand)
|
|
44
46
|
cli.register(AssetsAddCommand)
|
|
45
47
|
cli.register(AssetsGenCommand)
|
|
@@ -47,5 +49,8 @@ cli.register(AssetsIdentityCommand)
|
|
|
47
49
|
cli.register(AssetsCheckCommand)
|
|
48
50
|
cli.register(InitCommand)
|
|
49
51
|
cli.register(StartCommand)
|
|
52
|
+
cli.register(NeedsCommand)
|
|
53
|
+
cli.register(ReflectCommand)
|
|
54
|
+
cli.register(RulesCommand)
|
|
50
55
|
|
|
51
56
|
cli.runExit(process.argv.slice(2))
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Act-in-place verbs behind the hub feed — every mutation targets a
|
|
2
|
+
// git-tracked truth file (studio pick-flow precedent: the hub writes, the
|
|
3
|
+
// repo's diff shows it; Feedback appends are append-only, never destructive).
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'
|
|
5
|
+
import { join } from 'path'
|
|
6
|
+
import { archiveAsk, proofAsk } from '../asks/asks-queue.ts'
|
|
7
|
+
import { parsePostFile } from './posts.ts'
|
|
8
|
+
|
|
9
|
+
const today = (): string => new Date().toISOString().slice(0, 10)
|
|
10
|
+
|
|
11
|
+
const postPath = (root: string, channel: string, file: string): string => {
|
|
12
|
+
if (channel.includes('/') || channel.includes('..') || file.includes('/') || file.includes('..')) throw new Error('bad path')
|
|
13
|
+
const p = join(root, '.rv-social', channel, file)
|
|
14
|
+
if (!existsSync(p)) throw new Error(`no such post: ${channel}/${file}`)
|
|
15
|
+
return p
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const setStatus = (p: string, status: string): void => {
|
|
19
|
+
const md = readFileSync(p, 'utf8')
|
|
20
|
+
if (!/^status:/m.test(md)) throw new Error('post has no status: frontmatter')
|
|
21
|
+
writeFileSync(p, md.replace(/^status:.*$/m, `status: ${status}`))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** pick one draft variant: it flips to picked, sibling drafts to rejected
|
|
25
|
+
* (files KEPT — rejections are training data for the next generation) */
|
|
26
|
+
export function pickPostVariant(root: string, channel: string, file: string): void {
|
|
27
|
+
const p = postPath(root, channel, file)
|
|
28
|
+
const dir = join(root, '.rv-social', channel)
|
|
29
|
+
const slug = parsePostFile(dir, channel, file).slug
|
|
30
|
+
setStatus(p, 'picked')
|
|
31
|
+
for (const f of readdirSync(dir).filter((x) => x.endsWith('.md') && x !== file)) {
|
|
32
|
+
const sib = parsePostFile(dir, channel, f)
|
|
33
|
+
if (sib.slug === slug && sib.status === 'draft') setStatus(join(dir, f), 'rejected')
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** the posted-URL proof: status → posted, `posted:` frontmatter carries the URL */
|
|
38
|
+
export function markPosted(root: string, channel: string, file: string, url: string, rating?: number): void {
|
|
39
|
+
const p = postPath(root, channel, file)
|
|
40
|
+
setStatus(p, 'posted')
|
|
41
|
+
const md = readFileSync(p, 'utf8')
|
|
42
|
+
const withUrl = /^posted:/m.test(md) ? md.replace(/^posted:.*$/m, `posted: ${url}`) : md.replace(/^status:.*$/m, (s) => `${s}\nposted: ${url}`)
|
|
43
|
+
const withRating = rating != null
|
|
44
|
+
? (/^rating:/m.test(withUrl) ? withUrl.replace(/^rating:.*$/m, `rating: ${rating}`) : withUrl.replace(/^posted:.*$/m, (s) => `${s}\nrating: ${rating}`))
|
|
45
|
+
: withUrl
|
|
46
|
+
writeFileSync(p, withRating)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** append a dated remark under ## Feedback (created if absent) — NEVER
|
|
50
|
+
* destructive; future generations re-read these before drafting */
|
|
51
|
+
export function appendPostFeedback(root: string, channel: string, file: string, text: string): void {
|
|
52
|
+
const p = postPath(root, channel, file)
|
|
53
|
+
const md = readFileSync(p, 'utf8')
|
|
54
|
+
const line = `- ${today()}: ${text.replace(/\n+/g, ' ').trim()}`
|
|
55
|
+
writeFileSync(p, /^## Feedback\s*$/m.test(md) ? `${md.trimEnd()}\n${line}\n` : `${md.trimEnd()}\n\n## Feedback\n${line}\n`)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** his asset remark → `.rv-assets/notes.md` (git-tracked; the studio
|
|
59
|
+
* workspace is gitignored, so notes live beside kit.ts) */
|
|
60
|
+
export function appendAssetNote(root: string, assetId: string, text: string): void {
|
|
61
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(assetId)) throw new Error('bad asset id')
|
|
62
|
+
const p = join(root, '.rv-assets', 'notes.md')
|
|
63
|
+
const line = `- ${today()} [${assetId}]: ${text.replace(/\n+/g, ' ').trim()}`
|
|
64
|
+
writeFileSync(p, existsSync(p) ? `${readFileSync(p, 'utf8').trimEnd()}\n${line}\n` : `# asset notes — his remarks, read by agents at boot\n\n${line}\n`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** his remark on an ask → appended to the ask file, dated, non-destructive.
|
|
68
|
+
* Accepts exactly one optional `done/` prefix (archived asks stay
|
|
69
|
+
* annotatable); any other slash or traversal is a bad path. */
|
|
70
|
+
export function appendAskRemark(root: string, asksDir: string, file: string, text: string): void {
|
|
71
|
+
if (!/^(done\/)?[A-Za-z0-9][A-Za-z0-9._-]*$/.test(file) || file.includes('..')) throw new Error('bad path')
|
|
72
|
+
const p = join(root, asksDir, file)
|
|
73
|
+
if (!existsSync(p)) throw new Error(`no such ask: ${file}`)
|
|
74
|
+
writeFileSync(p, `${readFileSync(p, 'utf8').trimEnd()}\n- ${today()}: ${text.replace(/\n+/g, ' ').trim()}\n`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function askDone(root: string, asksDir: string, file: string): void {
|
|
78
|
+
if (file.includes('/') || file.includes('..')) throw new Error('bad path')
|
|
79
|
+
archiveAsk(join(root, asksDir), file)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function askProof(root: string, asksDir: string, file: string, url: string): void {
|
|
83
|
+
if (file.includes('/') || file.includes('..')) throw new Error('bad path')
|
|
84
|
+
proofAsk(join(root, asksDir), file, url)
|
|
85
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The collection model: every curatable surface (media kinds, asks, creds,
|
|
2
|
+
// repo health, posts) projects through ONE interface into the hub's needs
|
|
3
|
+
// feed, the studio browse, and `shipkit needs`. Truth files never move to
|
|
4
|
+
// fit the UI — a Collection only READS its source. Severity: yellow = a
|
|
5
|
+
// default is in use, taste pending; red = missing with no inventable
|
|
6
|
+
// default, or a paste-from-elsewhere. Doctrine page: build/collections in
|
|
7
|
+
// the docs wiki this lib ships beside.
|
|
8
|
+
|
|
9
|
+
export type NeedSeverity = 'red' | 'yellow'
|
|
10
|
+
|
|
11
|
+
export type CurationVerb = 'pick' | 'approve' | 'rate' | 'provide' | 'paste-proof' | 'set' | 'done' | 'archive'
|
|
12
|
+
|
|
13
|
+
export type Instance = {
|
|
14
|
+
id: string
|
|
15
|
+
/** one-line human text — the feed line */
|
|
16
|
+
text: string
|
|
17
|
+
/** absent = healthy, nothing needed */
|
|
18
|
+
severity?: NeedSeverity
|
|
19
|
+
/** deep link (studio anchor, /status, external editor) */
|
|
20
|
+
href?: string
|
|
21
|
+
/** paste-ready artifact: exact command/text to copy + optional steps */
|
|
22
|
+
artifact?: { copy?: string; url?: string; steps?: string[] }
|
|
23
|
+
verbs?: CurationVerb[]
|
|
24
|
+
/** roll-strip analog: pickable variants (post drafts, rolls) */
|
|
25
|
+
candidates?: { id: string; label: string; hint?: string }[]
|
|
26
|
+
/** TRUST: the git file this instance IS — shown on the tile; the file
|
|
27
|
+
* always wins over the page */
|
|
28
|
+
file?: string
|
|
29
|
+
/** thumbnail URL for media tiles */
|
|
30
|
+
img?: string
|
|
31
|
+
/** his latest remarks from the item's own file — agents boot on these */
|
|
32
|
+
remarks?: string[]
|
|
33
|
+
/** the truth file's markdown BODY (frontmatter stripped) — clamped card
|
|
34
|
+
* in the grid, full in the item view; capped server-side, never a dump */
|
|
35
|
+
md?: string
|
|
36
|
+
/** an open ask owns the action (dedup = acted-on once, never hidden):
|
|
37
|
+
* the instance stays red HERE but verbs and pings live with the ask */
|
|
38
|
+
askOwned?: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type Collection = {
|
|
42
|
+
id: string
|
|
43
|
+
title: string
|
|
44
|
+
media: 'image' | 'audio' | 'text' | 'data' | 'pointer' | 'health'
|
|
45
|
+
/** wire tone when any needing instance is only yellow */
|
|
46
|
+
list: () => Promise<Instance[]>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type NeedItem = Instance & { collection: string }
|
|
50
|
+
|
|
51
|
+
/** the flat needs projection — failure-isolated per collection: a broken
|
|
52
|
+
* source reports itself as ONE red item, never blanks the feed */
|
|
53
|
+
export async function projectNeeds(collections: Collection[]): Promise<NeedItem[]> {
|
|
54
|
+
const out: NeedItem[] = []
|
|
55
|
+
for (const c of collections) {
|
|
56
|
+
try {
|
|
57
|
+
for (const i of await c.list()) if (i.severity != null) out.push({ ...i, collection: c.id })
|
|
58
|
+
} catch (e) {
|
|
59
|
+
out.push({ collection: c.id, id: `${c.id}:broken`, text: `collector broke: ${(e as Error).message}`, severity: 'red' })
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const redCount = (needs: NeedItem[]): number => needs.filter((n) => n.severity === 'red').length
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// graphs collection — the mounted md graphs as CHECKED section entries:
|
|
2
|
+
// each graph instance carries its lint state (orphans + dangling refs =
|
|
3
|
+
// yellow with counts; clean = quiet). Custom wiki-graph providers (repos
|
|
4
|
+
// with their own loader + their own lint) get a link-only instance — their
|
|
5
|
+
// repo lint owns correctness, never double-judged here.
|
|
6
|
+
import { existsSync } from 'fs'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
import { lintMd } from '../wiki/lint-md.ts'
|
|
9
|
+
import type { Collection, Instance } from './collection-types.ts'
|
|
10
|
+
|
|
11
|
+
export function graphsCollection(root: string, o: { customWiki: boolean; agentIndex?: string }): Collection {
|
|
12
|
+
return {
|
|
13
|
+
id: 'graphs',
|
|
14
|
+
title: 'graphs',
|
|
15
|
+
media: 'data',
|
|
16
|
+
list: async () => {
|
|
17
|
+
const out: Instance[] = []
|
|
18
|
+
const checked = (id: string, title: string, href: string, dir: string, index?: string): Instance => {
|
|
19
|
+
const r = lintMd({ root: dir, index })
|
|
20
|
+
const issues = r.orphans.length + r.dangling.length
|
|
21
|
+
return {
|
|
22
|
+
id,
|
|
23
|
+
text: issues === 0 ? title : `${title} — ${r.orphans.length} orphan(s), ${r.dangling.length} dangling ref(s)`,
|
|
24
|
+
severity: issues === 0 ? undefined : 'yellow',
|
|
25
|
+
href,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (o.customWiki) out.push({ id: 'graph:wiki', text: 'wiki graph (repo-owned lint)', href: '/graph/wiki' })
|
|
29
|
+
else out.push(checked('graph:wiki', 'wiki graph', '/graph/wiki', root))
|
|
30
|
+
if (existsSync(join(root, 'agent'))) out.push(checked('graph:agent', 'agent graph', '/graph/agent', join(root, 'agent'), o.agentIndex))
|
|
31
|
+
return out
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// RED-item pings — one batched notification per burst of NEW red needs.
|
|
2
|
+
// Yellow (taste debt) NEVER pings: that would be progress spam. Stateful by
|
|
3
|
+
// design: last-pinged ids persist in node_modules/.cache/shipkit (always
|
|
4
|
+
// gitignored territory); a repo without node_modules gets NO pings rather
|
|
5
|
+
// than re-fired backlogs (no persistence = no dedup = stay silent). For
|
|
6
|
+
// ask-backed items the marker is the ask file's own `pinged:` field — the
|
|
7
|
+
// SAME once-ever guarantee every other ping path uses.
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
import { markPinged } from '../asks/asks-queue.ts'
|
|
11
|
+
import type { NeedItem } from './collection-types.ts'
|
|
12
|
+
|
|
13
|
+
export type PingIo = {
|
|
14
|
+
/** run a notify command — injectable for tests */
|
|
15
|
+
run: (cmd: string[]) => void
|
|
16
|
+
/** repo.config notifyCommand template, `{message}` substituted */
|
|
17
|
+
notifyCommand?: string
|
|
18
|
+
/** additive desktop notification on macOS */
|
|
19
|
+
osascript?: boolean
|
|
20
|
+
/** hub URL appended to the message — the OFF-MACHINE form
|
|
21
|
+
* (serve.publicHost); a phone can't resolve localhost */
|
|
22
|
+
link?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const stateDir = (root: string): string | null => (existsSync(join(root, 'node_modules')) ? join(root, 'node_modules', '.cache', 'shipkit') : null)
|
|
26
|
+
|
|
27
|
+
const loadPinged = (root: string): Set<string> | null => {
|
|
28
|
+
const dir = stateDir(root)
|
|
29
|
+
if (dir == null) return null
|
|
30
|
+
try {
|
|
31
|
+
return new Set(JSON.parse(readFileSync(join(dir, 'pinged.json'), 'utf8')) as string[])
|
|
32
|
+
} catch {
|
|
33
|
+
return new Set()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const savePinged = (root: string, ids: Set<string>): void => {
|
|
38
|
+
const dir = stateDir(root)
|
|
39
|
+
if (dir == null) return
|
|
40
|
+
mkdirSync(dir, { recursive: true })
|
|
41
|
+
writeFileSync(join(dir, 'pinged.json'), JSON.stringify([...ids].sort()))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** one tick: ping ONCE for the batch of never-pinged red items, mark them.
|
|
45
|
+
* Returns the pinged ids (empty = silence). */
|
|
46
|
+
export function pingTick(root: string, asksDir: string, needs: NeedItem[], io: PingIo): string[] {
|
|
47
|
+
const pinged = loadPinged(root)
|
|
48
|
+
if (pinged == null) return [] // no persistence, no pings — never re-fire a backlog
|
|
49
|
+
const fresh: NeedItem[] = []
|
|
50
|
+
for (const n of needs) {
|
|
51
|
+
if (n.severity !== 'red') continue
|
|
52
|
+
if (n.askOwned === true) continue // the covering ask's pinged: field already fired the once-ever ping
|
|
53
|
+
if (n.collection === 'asks') {
|
|
54
|
+
// ask file owns the once-ever marker (shared with agent pings)
|
|
55
|
+
const file = n.id.replace(/^ask:/, '')
|
|
56
|
+
if (markPinged(join(root, asksDir), file)) fresh.push(n)
|
|
57
|
+
} else if (!pinged.has(n.id)) {
|
|
58
|
+
pinged.add(n.id)
|
|
59
|
+
fresh.push(n)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
savePinged(root, pinged)
|
|
63
|
+
if (fresh.length === 0) return []
|
|
64
|
+
const head = fresh.slice(0, 3).map((n) => n.text.slice(0, 80)).join(' · ')
|
|
65
|
+
const message = `${fresh.length} new item(s) need you: ${head}${fresh.length > 3 ? ' · …' : ''}${io.link != null ? ` → ${io.link}` : ''}`
|
|
66
|
+
// item texts come from repo data — strip shell-active chars before the template substitution
|
|
67
|
+
const safe = message.replace(/[`$\\'"]/g, '')
|
|
68
|
+
if (io.notifyCommand != null) io.run(['sh', '-c', io.notifyCommand.replaceAll('{message}', safe)])
|
|
69
|
+
if (io.osascript !== false && process.platform === 'darwin')
|
|
70
|
+
io.run(['osascript', '-e', `display notification ${JSON.stringify(message)} with title "shipkit needs"`])
|
|
71
|
+
return fresh.map((n) => n.id)
|
|
72
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// social-posts collection — projects `.rv-social/<channel>/` post files
|
|
2
|
+
// (contract v2) into the needs feed. Status-aware: rejected/posted history
|
|
3
|
+
// stays on disk as training data and never floods the feed. Drafts-as-rolls:
|
|
4
|
+
// variants are sibling files `<slug>.a.md`, `<slug>.b.md`; picking one flips
|
|
5
|
+
// it to picked and the others to rejected (files KEPT). Severities:
|
|
6
|
+
// any file missing `status:` → red (metadata with no inventable default)
|
|
7
|
+
// drafts awaiting a pick → yellow (his taste)
|
|
8
|
+
// picked but not posted → red (paste-from-elsewhere: post + URL proof)
|
|
9
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
10
|
+
import { join } from 'path'
|
|
11
|
+
import type { Collection, Instance } from './collection-types.ts'
|
|
12
|
+
|
|
13
|
+
export type PostFile = {
|
|
14
|
+
channel: string
|
|
15
|
+
file: string
|
|
16
|
+
slug: string
|
|
17
|
+
variant: string | null
|
|
18
|
+
status: 'draft' | 'picked' | 'posted' | 'rejected' | null
|
|
19
|
+
kind: string | null
|
|
20
|
+
posted: string | null
|
|
21
|
+
body: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const fm = (md: string, key: string): string | null => md.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1]?.trim() ?? null
|
|
25
|
+
|
|
26
|
+
/** the paste-ready text: the `## Post` section body (Angle/Feedback never leak into the paste) */
|
|
27
|
+
export const postSection = (md: string): string => md.match(/^## Post\s*\n([\s\S]*?)(?=^## |\s*$(?![\s\S]))/m)?.[1]?.trim() ?? ''
|
|
28
|
+
|
|
29
|
+
export function parsePostFile(dir: string, channel: string, file: string): PostFile {
|
|
30
|
+
const md = readFileSync(join(dir, file), 'utf8')
|
|
31
|
+
const m = file.match(/^(.*?)(?:\.([a-z]))?\.md$/)
|
|
32
|
+
const status = fm(md, 'status')
|
|
33
|
+
return {
|
|
34
|
+
channel,
|
|
35
|
+
file,
|
|
36
|
+
slug: m?.[1] ?? file,
|
|
37
|
+
variant: m?.[2] ?? null,
|
|
38
|
+
status: status === 'draft' || status === 'picked' || status === 'posted' || status === 'rejected' ? status : null,
|
|
39
|
+
kind: fm(md, 'kind'),
|
|
40
|
+
posted: fm(md, 'posted'),
|
|
41
|
+
body: md,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function listPosts(root: string): PostFile[] {
|
|
46
|
+
const social = join(root, '.rv-social')
|
|
47
|
+
if (!existsSync(social)) return []
|
|
48
|
+
const out: PostFile[] = []
|
|
49
|
+
for (const ch of readdirSync(social)) {
|
|
50
|
+
const d = join(social, ch)
|
|
51
|
+
if (!statSync(d).isDirectory()) continue
|
|
52
|
+
for (const f of readdirSync(d).filter((x) => x.endsWith('.md')).sort()) out.push(parsePostFile(d, ch, f))
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function socialPostsCollection(root: string): Collection {
|
|
58
|
+
return {
|
|
59
|
+
id: 'posts',
|
|
60
|
+
title: 'posts',
|
|
61
|
+
media: 'text',
|
|
62
|
+
list: async () => {
|
|
63
|
+
const bySlug = new Map<string, PostFile[]>()
|
|
64
|
+
for (const p of listPosts(root)) {
|
|
65
|
+
const key = `${p.channel}/${p.slug}`
|
|
66
|
+
bySlug.set(key, [...(bySlug.get(key) ?? []), p])
|
|
67
|
+
}
|
|
68
|
+
const out: Instance[] = []
|
|
69
|
+
const feedback = (v: PostFile): string[] | undefined => {
|
|
70
|
+
const lines = v.body.split('\n').filter((l) => /^- \d{4}-\d{2}-\d{2}: /.test(l))
|
|
71
|
+
return lines.length > 0 ? lines.slice(-3) : undefined
|
|
72
|
+
}
|
|
73
|
+
for (const [key, variants] of bySlug) {
|
|
74
|
+
const fileOf = (v: PostFile): string => `.rv-social/${v.channel}/${v.file}`
|
|
75
|
+
const missing = variants.filter((v) => v.status == null)
|
|
76
|
+
if (missing.length > 0) {
|
|
77
|
+
out.push({
|
|
78
|
+
id: `post:${key}:invalid`,
|
|
79
|
+
text: `${key} — missing/invalid status: frontmatter (${missing.map((v) => v.file).join(', ')})`,
|
|
80
|
+
severity: 'red',
|
|
81
|
+
file: fileOf(missing[0]!),
|
|
82
|
+
})
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
const picked = variants.find((v) => v.status === 'picked')
|
|
86
|
+
if (picked != null) {
|
|
87
|
+
out.push({
|
|
88
|
+
id: `post:${key}:topost`,
|
|
89
|
+
text: `${key} — picked, waiting to be POSTED (copy → post → paste the URL)`,
|
|
90
|
+
severity: 'red',
|
|
91
|
+
artifact: { copy: postSection(picked.body) },
|
|
92
|
+
verbs: ['paste-proof', 'rate'],
|
|
93
|
+
candidates: [{ id: picked.file, label: picked.variant ?? 'post' }],
|
|
94
|
+
file: fileOf(picked),
|
|
95
|
+
remarks: feedback(picked),
|
|
96
|
+
})
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
const drafts = variants.filter((v) => v.status === 'draft')
|
|
100
|
+
if (drafts.length > 0) {
|
|
101
|
+
out.push({
|
|
102
|
+
id: `post:${key}:pick`,
|
|
103
|
+
text: `${key} — ${drafts.length} draft(s), pick one`,
|
|
104
|
+
severity: 'yellow',
|
|
105
|
+
verbs: ['pick', 'rate'],
|
|
106
|
+
candidates: drafts.map((v) => ({ id: v.file, label: v.variant ?? 'draft', hint: postSection(v.body).slice(0, 200) })),
|
|
107
|
+
file: fileOf(drafts[0]!),
|
|
108
|
+
remarks: feedback(drafts[0]!),
|
|
109
|
+
})
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
// posted / rejected history: VISIBLE (browse-all), never in needs
|
|
113
|
+
const posted = variants.find((v) => v.status === 'posted')
|
|
114
|
+
const shown = posted ?? variants[0]!
|
|
115
|
+
out.push({
|
|
116
|
+
id: `post:${key}:${posted != null ? 'posted' : 'rejected'}`,
|
|
117
|
+
text: `${key} — ${posted != null ? `posted${posted.posted != null ? ` (${posted.posted})` : ''}` : 'rejected'}`,
|
|
118
|
+
file: fileOf(shown),
|
|
119
|
+
remarks: feedback(shown),
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
return out
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
}
|