@elmundi/ship-cli 0.14.2 → 0.15.4

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.
Files changed (39) hide show
  1. package/README.md +17 -16
  2. package/bin/shipctl.mjs +4 -80
  3. package/lib/commands/feedback.mjs +1 -1
  4. package/lib/commands/help.mjs +47 -131
  5. package/lib/commands/init.mjs +17 -250
  6. package/lib/commands/knowledge.mjs +25 -328
  7. package/lib/commands/preflight.mjs +213 -0
  8. package/lib/commands/run.mjs +298 -119
  9. package/lib/commands/trigger.mjs +95 -10
  10. package/lib/config/schema.mjs +73 -11
  11. package/lib/http.mjs +0 -2
  12. package/lib/runtime/routines.mjs +39 -0
  13. package/lib/templates.mjs +2 -2
  14. package/lib/verify/checks/agents-on-disk.mjs +5 -28
  15. package/lib/verify/registry.mjs +7 -8
  16. package/package.json +1 -1
  17. package/lib/artifacts/fs-index.mjs +0 -230
  18. package/lib/cache/store.mjs +0 -422
  19. package/lib/commands/bootstrap.mjs +0 -4
  20. package/lib/commands/callback.mjs +0 -742
  21. package/lib/commands/docs.mjs +0 -90
  22. package/lib/commands/kickoff.mjs +0 -192
  23. package/lib/commands/lanes.mjs +0 -566
  24. package/lib/commands/manifest-catalog.mjs +0 -251
  25. package/lib/commands/migrate.mjs +0 -204
  26. package/lib/commands/new.mjs +0 -452
  27. package/lib/commands/patterns.mjs +0 -160
  28. package/lib/commands/process.mjs +0 -388
  29. package/lib/commands/search.mjs +0 -43
  30. package/lib/commands/sync.mjs +0 -824
  31. package/lib/config/migrate.mjs +0 -223
  32. package/lib/find-ship-root.mjs +0 -75
  33. package/lib/process/specialist-prompt-contract.mjs +0 -171
  34. package/lib/state/lockfile.mjs +0 -180
  35. package/lib/vendor/run-agent.workflow.yml +0 -254
  36. package/lib/verify/checks/artifacts-up-to-date.mjs +0 -78
  37. package/lib/verify/checks/cache-integrity.mjs +0 -51
  38. package/lib/verify/checks/gitignore-cache.mjs +0 -51
  39. package/lib/verify/checks/rules-markers.mjs +0 -135
@@ -1,254 +0,0 @@
1
- # ship-cli: run-agent v1 — generated by `shipctl lanes install`.
2
- # Regenerate via: shipctl lanes install
3
- # Template ships with @elmundi/ship-cli; hand edits outside this banner may be overwritten.
4
- name: Ship · run-agent (reusable)
5
- # RFC-0007 Phase 3 + RFC-0008 C3.2 — reusable workflow for lane wrappers.
6
- # Caller repos use: uses: ./.github/workflows/run-agent.yml (same repository).
7
-
8
- on:
9
- workflow_call:
10
- inputs:
11
- lane:
12
- description: "Lane id as declared in .ship/config.yml"
13
- type: string
14
- required: true
15
- trigger:
16
- description: "Override trigger (once|event|schedule|manual). Defaults to github.event_name."
17
- type: string
18
- required: false
19
- default: ""
20
- shipctl_version:
21
- description: "npm dist-tag or semver for @elmundi/ship-cli. Default: latest."
22
- type: string
23
- required: false
24
- default: "latest"
25
- node_version:
26
- description: "Node.js major version for the runner"
27
- type: string
28
- required: false
29
- default: "20"
30
- dry_run:
31
- description: "Resolve the lane but neither call the callback nor write the idempotency marker."
32
- type: boolean
33
- required: false
34
- default: false
35
- offline:
36
- description: "Do not talk to the methodology API; read patterns from the local .ship/cache."
37
- type: boolean
38
- required: false
39
- default: false
40
- ship_run_id:
41
- description: "Pipeline-run id the callback should report against (set by Ship dispatch)."
42
- type: string
43
- required: false
44
- default: ""
45
- ship_callback_url:
46
- description: "Ship callback URL (set by Ship dispatch)."
47
- type: string
48
- required: false
49
- default: ""
50
- upload_prompt:
51
- description: "Upload the rendered prompt as a workflow artifact named `ship-prompt`."
52
- type: boolean
53
- required: false
54
- default: true
55
- secrets:
56
- SHIP_API_TOKEN:
57
- description: "Methodology API token (optional; required for private patterns / callbacks)."
58
- required: false
59
- SHIP_API_BASE:
60
- description: "Ship API origin for shipctl (e.g. https://api.example.com). Wizard seeds this from SHIP_PUBLIC_URL."
61
- required: false
62
- SHIP_RUN_TOKEN:
63
- description: "Short-lived bearer token Ship issued when dispatching this run."
64
- required: false
65
-
66
- permissions:
67
- contents: read
68
-
69
- jobs:
70
- plan:
71
- name: "ship plan --lane ${{ inputs.lane }}"
72
- runs-on: ubuntu-latest
73
- timeout-minutes: 5
74
- outputs:
75
- matrix: ${{ steps.plan.outputs.matrix }}
76
- patterns: ${{ steps.plan.outputs.patterns }}
77
- fanout: ${{ steps.plan.outputs.fanout }}
78
- steps:
79
- - name: Checkout caller repo
80
- uses: actions/checkout@v4
81
-
82
- - name: Setup Node.js
83
- uses: actions/setup-node@v4
84
- with:
85
- node-version: ${{ inputs.node_version }}
86
-
87
- - name: Install shipctl
88
- run: npm install -g @elmundi/ship-cli@${{ inputs.shipctl_version }}
89
-
90
- - name: Resolve patterns + fanout
91
- id: plan
92
- env:
93
- LANE: ${{ inputs.lane }}
94
- run: |
95
- set -euo pipefail
96
- JSON=$(shipctl lanes list --json)
97
- ROW=$(echo "$JSON" | jq -c --arg lane "$LANE" '.lanes[] | select(.lane == $lane)')
98
- if [ -z "$ROW" ] || [ "$ROW" = "null" ]; then
99
- echo "::error::lane '$LANE' not declared in .ship/config.yml"
100
- exit 1
101
- fi
102
- PATTERNS=$(echo "$ROW" | jq -c '.patterns // []')
103
- FANOUT=$(echo "$ROW" | jq -r '.fanout // "matrix"')
104
- N=$(echo "$PATTERNS" | jq 'length')
105
- if [ "$N" -lt 1 ]; then
106
- echo "::error::lane '$LANE' declares no patterns"
107
- exit 1
108
- fi
109
-
110
- if [ "$FANOUT" = "matrix" ] && [ "$N" -gt 1 ]; then
111
- MATRIX=$(echo "$PATTERNS" | jq -c '[.[] | {pattern: ., fanout: "matrix"}]')
112
- elif [ "$N" -gt 1 ]; then
113
- MATRIX=$(jq -cn --arg fanout "$FANOUT" '[{pattern: "", fanout: $fanout}]')
114
- else
115
- MATRIX='[{"pattern": "", "fanout": "matrix"}]'
116
- fi
117
-
118
- echo "patterns=$PATTERNS" >> "$GITHUB_OUTPUT"
119
- echo "fanout=$FANOUT" >> "$GITHUB_OUTPUT"
120
- echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
121
- {
122
- echo "### Plan"
123
- echo "- lane: \`$LANE\`"
124
- echo "- patterns: $(echo "$PATTERNS" | jq -r '. | join(", ")')"
125
- echo "- fanout: $FANOUT"
126
- } >> "$GITHUB_STEP_SUMMARY"
127
-
128
- run:
129
- name: "ship run --lane ${{ inputs.lane }} (pattern=${{ matrix.entry.pattern }} fanout=${{ matrix.entry.fanout }})"
130
- needs: plan
131
- runs-on: ubuntu-latest
132
- timeout-minutes: 20
133
- strategy:
134
- fail-fast: false
135
- matrix:
136
- entry: ${{ fromJSON(needs.plan.outputs.matrix) }}
137
- steps:
138
- - name: Checkout caller repo
139
- uses: actions/checkout@v4
140
-
141
- - name: Setup Node.js
142
- uses: actions/setup-node@v4
143
- with:
144
- node-version: ${{ inputs.node_version }}
145
-
146
- - name: Install shipctl
147
- run: npm install -g @elmundi/ship-cli@${{ inputs.shipctl_version }}
148
-
149
- - name: Resolve trigger
150
- id: trigger
151
- env:
152
- OVERRIDE: ${{ inputs.trigger }}
153
- EVENT: ${{ github.event_name }}
154
- run: |
155
- set -euo pipefail
156
- if [ -n "${OVERRIDE:-}" ]; then
157
- echo "value=$OVERRIDE" >> "$GITHUB_OUTPUT"
158
- else
159
- case "$EVENT" in
160
- workflow_dispatch) echo "value=manual" >> "$GITHUB_OUTPUT" ;;
161
- schedule) echo "value=schedule" >> "$GITHUB_OUTPUT" ;;
162
- *) echo "value=event" >> "$GITHUB_OUTPUT" ;;
163
- esac
164
- fi
165
-
166
- - name: shipctl run
167
- id: run
168
- env:
169
- SHIP_API_TOKEN: ${{ secrets.SHIP_API_TOKEN }}
170
- SHIP_API_BASE: ${{ secrets.SHIP_API_BASE }}
171
- SHIP_RUN_TOKEN: ${{ secrets.SHIP_RUN_TOKEN }}
172
- SHIP_RUN_ID: ${{ inputs.ship_run_id }}
173
- MATRIX_PATTERN: ${{ matrix.entry.pattern }}
174
- MATRIX_FANOUT: ${{ matrix.entry.fanout }}
175
- run: |
176
- set -euo pipefail
177
- mkdir -p .ship/run-output
178
- ARGS=(run --lane "${{ inputs.lane }}" --trigger "${{ steps.trigger.outputs.value }}")
179
- [ "${{ inputs.dry_run }}" = "true" ] && ARGS+=(--dry-run)
180
- [ "${{ inputs.offline }}" = "true" ] && ARGS+=(--offline)
181
- [ -n "${MATRIX_PATTERN:-}" ] && ARGS+=(--pattern "$MATRIX_PATTERN")
182
- [ -n "${MATRIX_FANOUT:-}" ] && ARGS+=(--fanout "$MATRIX_FANOUT")
183
- [ -n "${SHIP_RUN_ID:-}" ] && ARGS+=(--ship-run-id "$SHIP_RUN_ID")
184
-
185
- set +e
186
- shipctl "${ARGS[@]}" \
187
- 1> .ship/run-output/prompt.md \
188
- 2> .ship/run-output/shipctl.log
189
- STATUS=$?
190
- set -e
191
-
192
- cat .ship/run-output/shipctl.log >&2 || true
193
- echo "status=$STATUS" >> "$GITHUB_OUTPUT"
194
- if [ "$STATUS" -ne 0 ]; then
195
- echo "::error::shipctl run exited with $STATUS"
196
- exit "$STATUS"
197
- fi
198
-
199
- - name: Upload prompt artifact
200
- if: ${{ inputs.upload_prompt && (success() || failure()) }}
201
- uses: actions/upload-artifact@v4
202
- with:
203
- name: ship-prompt-${{ inputs.lane }}${{ matrix.entry.pattern != '' && format('-{0}', matrix.entry.pattern) || '' }}
204
- path: .ship/run-output/
205
- if-no-files-found: warn
206
- retention-days: 14
207
-
208
- aggregate:
209
- name: "ship aggregate --lane ${{ inputs.lane }}"
210
- if: ${{ always() && inputs.ship_callback_url != '' }}
211
- needs: [plan, run]
212
- runs-on: ubuntu-latest
213
- timeout-minutes: 5
214
- steps:
215
- - name: Checkout caller repo
216
- uses: actions/checkout@v4
217
-
218
- - name: Setup Node.js
219
- uses: actions/setup-node@v4
220
- with:
221
- node-version: ${{ inputs.node_version }}
222
-
223
- - name: Install shipctl
224
- run: npm install -g @elmundi/ship-cli@${{ inputs.shipctl_version }}
225
-
226
- - name: Report rollup to Ship
227
- env:
228
- SHIP_RUN_ID: ${{ inputs.ship_run_id }}
229
- SHIP_CALLBACK_URL: ${{ inputs.ship_callback_url }}
230
- SHIP_RUN_TOKEN: ${{ secrets.SHIP_RUN_TOKEN }}
231
- GH_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
232
- RUN_RESULT: ${{ needs.run.result }}
233
- PLAN_PATTERNS: ${{ needs.plan.outputs.patterns }}
234
- PLAN_FANOUT: ${{ needs.plan.outputs.fanout }}
235
- run: |
236
- set -euo pipefail
237
- case "$RUN_RESULT" in
238
- success)
239
- CB=ok
240
- SUMMARY="Ship lane ${{ inputs.lane }} completed ($PLAN_FANOUT over $PLAN_PATTERNS)."
241
- ;;
242
- failure|cancelled|skipped|*)
243
- CB=fail
244
- SUMMARY="Ship lane ${{ inputs.lane }} $RUN_RESULT ($PLAN_FANOUT over $PLAN_PATTERNS)."
245
- ;;
246
- esac
247
- shipctl callback --status "$CB" \
248
- --summary "$SUMMARY" \
249
- --metric "lane_id=${{ inputs.lane }}" \
250
- --metric "fanout=$PLAN_FANOUT" \
251
- --metric "patterns=$(echo "$PLAN_PATTERNS" | jq -r '. | join(",")')" \
252
- --metric "gh_workflow_run_id=${{ github.run_id }}" \
253
- --metric "gh_html_url=$GH_RUN_URL" \
254
- --metric "gh_event=${{ github.event_name }}" || true
@@ -1,78 +0,0 @@
1
- import { fetchManifest } from "../../http.mjs";
2
- import { listCached } from "../../cache/store.mjs";
3
-
4
- export const id = "artifacts-up-to-date";
5
- export const category = "network";
6
- export const description = "Local cache matches latest manifest on the configured channel";
7
-
8
- function newestVersion(versions) {
9
- if (!Array.isArray(versions) || !versions.length) return null;
10
- // Manifest entries are usually already sorted descending; treat the first as latest.
11
- return versions[0];
12
- }
13
-
14
- /**
15
- * @param {import("../registry.mjs").CheckContext} ctx
16
- */
17
- export async function run(ctx) {
18
- let cache;
19
- try {
20
- cache = listCached(ctx.cwd);
21
- } catch {
22
- cache = [];
23
- }
24
- if (!cache.length) {
25
- return { status: "skip", detail: "no cached artifacts to compare" };
26
- }
27
-
28
- const baseUrl = ctx.baseUrl
29
- || (ctx.config && ctx.config.api && ctx.config.api.base_url)
30
- || process.env.SHIP_API_BASE
31
- || "https://ship.elmundi.com";
32
- const channel = (ctx.config && ctx.config.api && ctx.config.api.channel) || "stable";
33
-
34
- let manifest;
35
- try {
36
- manifest = await fetchManifest(baseUrl, { channel });
37
- } catch (e) {
38
- return { status: "warn", detail: `manifest fetch failed: ${e.message}` };
39
- }
40
-
41
- const index = new Map();
42
- for (const m of manifest || []) {
43
- const k = `${m.kind}/${m.id}`;
44
- if (!index.has(k)) index.set(k, m);
45
- }
46
-
47
- const stale = [];
48
- for (const entry of cache) {
49
- const key = `${entry.kind}/${entry.id}`;
50
- const m = index.get(key);
51
- if (!m) continue;
52
- const latest = m.version || (m.versions ? newestVersion(m.versions) : null);
53
- if (latest && entry.version && latest !== entry.version) {
54
- stale.push({
55
- kind: entry.kind,
56
- id: entry.id,
57
- cached: entry.version,
58
- latest,
59
- });
60
- }
61
- }
62
-
63
- if (stale.length) {
64
- const summary = stale
65
- .slice(0, 3)
66
- .map((s) => `${s.kind}/${s.id} ${s.cached}→${s.latest}`)
67
- .join(", ");
68
- return {
69
- status: "warn",
70
- detail: `${stale.length} stale artifact(s): ${summary}${stale.length > 3 ? "…" : ""} — run 'shipctl sync'`,
71
- data: { stale, channel },
72
- };
73
- }
74
- return {
75
- status: "pass",
76
- detail: `all ${cache.length} cached entries are current on channel=${channel}`,
77
- };
78
- }
@@ -1,51 +0,0 @@
1
- import { listCached, verifyCached } from "../../cache/store.mjs";
2
-
3
- export const id = "cache-integrity";
4
- export const category = "local";
5
- export const description = "Cached artifact bodies match their .meta.json sha256";
6
-
7
- /**
8
- * @param {import("../registry.mjs").CheckContext} ctx
9
- */
10
- export async function run(ctx) {
11
- let entries = [];
12
- try {
13
- entries = listCached(ctx.cwd);
14
- } catch (e) {
15
- return { status: "fail", detail: `failed to read cache index: ${e.message}` };
16
- }
17
- if (!entries.length) {
18
- return { status: "skip", detail: "no cached artifacts (.ship/cache/)" };
19
- }
20
-
21
- const tampered = [];
22
- for (const e of entries) {
23
- const res = verifyCached(ctx.cwd, e.kind, e.id, e.version);
24
- if (!res.ok) {
25
- tampered.push({
26
- kind: e.kind,
27
- id: e.id,
28
- version: e.version,
29
- expected: res.expected,
30
- actual: res.actual,
31
- reason: res.reason || "sha256 mismatch",
32
- });
33
- }
34
- }
35
-
36
- if (tampered.length) {
37
- return {
38
- status: "fail",
39
- detail: `${tampered.length}/${entries.length} cached entries tampered: ${tampered
40
- .slice(0, 3)
41
- .map((t) => `${t.kind}/${t.id}@${t.version}`)
42
- .join(", ")}${tampered.length > 3 ? "…" : ""}`,
43
- data: { tampered, total: entries.length },
44
- };
45
- }
46
- return {
47
- status: "pass",
48
- detail: `${entries.length} cached entries verified (sha256 ok)`,
49
- data: { total: entries.length },
50
- };
51
- }
@@ -1,51 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
-
4
- export const id = "gitignore-cache";
5
- export const category = "local";
6
- export const description = ".gitignore contains .ship/cache/";
7
-
8
- /**
9
- * @param {import("../registry.mjs").CheckContext} ctx
10
- */
11
- export async function run(ctx) {
12
- const cacheTracked =
13
- !!(ctx.config && ctx.config.cache && ctx.config.cache.vcs_tracked === true);
14
- const giPath = path.join(ctx.cwd, ".gitignore");
15
- if (!fs.existsSync(giPath)) {
16
- if (cacheTracked) {
17
- return {
18
- status: "pass",
19
- detail: ".gitignore absent but cache.vcs_tracked=true — cache is intentionally committed",
20
- };
21
- }
22
- return {
23
- status: "warn",
24
- detail: ".gitignore not found; add `.ship/cache/` to keep cached artifacts out of git",
25
- };
26
- }
27
- const body = fs.readFileSync(giPath, "utf8");
28
- const lines = new Set(body.split(/\r?\n/).map((l) => l.trim()));
29
- const listed = lines.has(".ship/cache/") || lines.has(".ship/cache");
30
-
31
- if (cacheTracked && listed) {
32
- return {
33
- status: "warn",
34
- detail:
35
- ".ship/cache/ listed in .gitignore but cache.vcs_tracked=true — entries will be ignored by git",
36
- };
37
- }
38
- if (cacheTracked) {
39
- return {
40
- status: "pass",
41
- detail: "cache.vcs_tracked=true; .gitignore does not exclude .ship/cache/",
42
- };
43
- }
44
- if (!listed) {
45
- return {
46
- status: "warn",
47
- detail: ".ship/cache/ not listed in .gitignore — add it to avoid committing cached bodies",
48
- };
49
- }
50
- return { status: "pass", detail: ".ship/cache/ listed in .gitignore" };
51
- }
@@ -1,135 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { KNOWN_AGENTS } from "../../detect.mjs";
4
- import { listCached, readCachedArtifact } from "../../cache/store.mjs";
5
-
6
- export const id = "rules-markers";
7
- export const category = "local";
8
- export const description = "Agent rule files contain current artifacts-protocol markers";
9
-
10
- const RULE_MARKER = "<!-- ship-cli: artifacts-protocol v1 -->";
11
- const FOOTER_PREFIX = "<!-- ship-cli: installed-from ";
12
- const FOOTER_SUFFIX = " -->";
13
-
14
- function readFooter(body) {
15
- const idx = body.lastIndexOf(FOOTER_PREFIX);
16
- if (idx < 0) return null;
17
- const end = body.indexOf(FOOTER_SUFFIX, idx);
18
- if (end < 0) return null;
19
- return body.slice(idx + FOOTER_PREFIX.length, end).trim();
20
- }
21
-
22
- /**
23
- * @param {import("../registry.mjs").CheckContext} ctx
24
- */
25
- export async function run(ctx) {
26
- const agents = (ctx.config && ctx.config.stack && Array.isArray(ctx.config.stack.agents))
27
- ? ctx.config.stack.agents
28
- : [];
29
- if (!agents.length) {
30
- return { status: "skip", detail: "stack.agents is empty" };
31
- }
32
-
33
- let cache = [];
34
- try {
35
- cache = listCached(ctx.cwd);
36
- } catch {
37
- cache = [];
38
- }
39
-
40
- const rows = [];
41
- let hasFail = false;
42
- let hasWarn = false;
43
-
44
- for (const agent of agents) {
45
- const spec = KNOWN_AGENTS[agent];
46
- // Prefer the install_target declared by the cached agent-rules artifact;
47
- // fall back to the hardcoded KNOWN_AGENTS mapping for graceful
48
- // degradation when the cache hasn't been populated yet (RFC-0004).
49
- let rel = null;
50
- let cachedFm = null;
51
- try {
52
- cachedFm = readCachedArtifact(ctx.cwd, "collection", `agent-rules-${agent}`);
53
- } catch {
54
- cachedFm = null;
55
- }
56
- if (!cachedFm) {
57
- rows.push({
58
- agent,
59
- status: "warn",
60
- detail: `no cached agent-rules-${agent}; run shipctl sync`,
61
- });
62
- hasWarn = true;
63
- continue;
64
- }
65
- // v1 single-file artifacts kept install_target at the top level; v2
66
- // moved it under `spec:`. Honour both.
67
- const topLevel = cachedFm.fm && typeof cachedFm.fm.install_target === "string"
68
- ? cachedFm.fm.install_target.trim()
69
- : "";
70
- const nested = cachedFm.spec && typeof cachedFm.spec.install_target === "string"
71
- ? cachedFm.spec.install_target.trim()
72
- : "";
73
- const installTarget = topLevel || nested;
74
- if (installTarget) {
75
- rel = installTarget;
76
- } else if (spec) {
77
- rel = path.join(...spec.targetRel);
78
- } else {
79
- rows.push({ agent, status: "warn", detail: `unknown agent id '${agent}' (no install_target and no KNOWN_AGENTS entry)` });
80
- hasWarn = true;
81
- continue;
82
- }
83
-
84
- const abs = path.join(ctx.cwd, rel);
85
- if (!fs.existsSync(abs)) {
86
- rows.push({ agent, status: "fail", detail: `missing rule file ${rel}` });
87
- hasFail = true;
88
- continue;
89
- }
90
- const body = fs.readFileSync(abs, "utf8");
91
- if (!body.includes(RULE_MARKER)) {
92
- rows.push({ agent, status: "fail", detail: `${rel} has no '${RULE_MARKER}' marker` });
93
- hasFail = true;
94
- continue;
95
- }
96
- const footer = readFooter(body);
97
- if (!footer) {
98
- rows.push({ agent, status: "fail", detail: `${rel} has no 'installed-from' footer` });
99
- hasFail = true;
100
- continue;
101
- }
102
-
103
- // footer looks like "collection/agent-rules-cursor@1.0.1"
104
- const m = /^collection\/agent-rules-[a-z0-9-]+@([0-9A-Za-z.\-+]+)$/.exec(footer);
105
- if (!m) {
106
- rows.push({ agent, status: "warn", detail: `${rel} footer is '${footer}' (non-standard format)` });
107
- hasWarn = true;
108
- continue;
109
- }
110
- const installedVersion = m[1];
111
- const cached = cache.find(
112
- (c) => c.kind === "collection" && c.id === `agent-rules-${agent}`,
113
- );
114
- if (cached && cached.version && cached.version !== installedVersion) {
115
- rows.push({
116
- agent,
117
- status: "warn",
118
- detail: `${rel} footer @${installedVersion}, cache has @${cached.version} — run 'shipctl init --copy-rules'`,
119
- });
120
- hasWarn = true;
121
- } else {
122
- rows.push({ agent, status: "pass", detail: `${rel} @${installedVersion}` });
123
- }
124
- }
125
-
126
- const overall = hasFail ? "fail" : hasWarn ? "warn" : "pass";
127
- const summary = rows
128
- .filter((r) => r.status !== "pass")
129
- .map((r) => `${r.agent}: ${r.detail}`)
130
- .join("; ");
131
- const detail = overall === "pass"
132
- ? `all ${rows.length} agent rule files have correct markers`
133
- : summary || `${rows.length} agent rule files inspected`;
134
- return { status: overall, detail, data: { rows } };
135
- }