@mmerterden/multi-agent-pipeline 12.3.0 → 12.5.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 +71 -0
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/forget/SKILL.md +29 -0
- package/pipeline/commands/multi-agent/help/SKILL.md +18 -0
- package/pipeline/commands/multi-agent/routines/SKILL.md +30 -0
- package/pipeline/commands/multi-agent/save/SKILL.md +46 -0
- package/pipeline/commands/multi-agent/sync/SKILL.md +4 -4
- package/pipeline/lib/extract-conventions.sh +1 -0
- package/pipeline/lib/repo-cache.sh +1 -0
- package/pipeline/lib/shadow-git.sh +8 -0
- package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -3
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +9 -1
- package/pipeline/multi-agent-refs/phases/phase-4-review.md +3 -2
- package/pipeline/multi-agent-refs/phases/phase-5-test.md +10 -0
- package/pipeline/multi-agent-refs/phases.md +2 -0
- package/pipeline/multi-agent-refs/prompt-assembly.md +31 -0
- package/pipeline/schemas/learnings-ledger.schema.json +4 -0
- package/pipeline/schemas/migrations/prefs-2.3.0-to-2.4.0.mjs +32 -0
- package/pipeline/schemas/prefs.schema.json +36 -1
- package/pipeline/schemas/token-budget.json +2 -2
- package/pipeline/schemas/triage-corpus.schema.json +5 -1
- package/pipeline/scripts/eval-mine-corpus.mjs +19 -5
- package/pipeline/scripts/fixtures/install-layout.tsv +7 -7
- package/pipeline/scripts/gc-worktrees.sh +23 -1
- package/pipeline/scripts/learning-curve.mjs +167 -0
- package/pipeline/scripts/learnings-ledger.mjs +9 -2
- package/pipeline/scripts/migrate-prefs.mjs +8 -1
- package/pipeline/scripts/repo-map.mjs +1 -1
- package/pipeline/scripts/routine-registry.mjs +226 -0
- package/pipeline/scripts/smoke-generate-issue.sh +3 -3
- package/pipeline/scripts/smoke-learning-curve.sh +61 -0
- package/pipeline/scripts/smoke-migrate-state.sh +3 -3
- package/pipeline/scripts/smoke-pref-migration.sh +8 -6
- package/pipeline/scripts/smoke-review-readiness.sh +1 -1
- package/pipeline/scripts/smoke-routines.sh +84 -0
- package/pipeline/scripts/triage-memory.mjs +48 -10
- package/pipeline/skills/shared/core/multi-agent-forget/SKILL.md +22 -0
- package/pipeline/skills/shared/core/multi-agent-routines/SKILL.md +20 -0
- package/pipeline/skills/shared/core/multi-agent-save/SKILL.md +27 -0
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -4
|
@@ -64,6 +64,13 @@ if ! git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
|
64
64
|
exit 0
|
|
65
65
|
fi
|
|
66
66
|
REPO="$(cd "$REPO" && pwd -P)"
|
|
67
|
+
# Anchor on the MAIN worktree (first porcelain entry) so a run started from a
|
|
68
|
+
# subdirectory OR from inside a linked worktree still targets
|
|
69
|
+
# <main-repo>/.worktrees/ rather than a bogus <subdir>/.worktrees/ (which
|
|
70
|
+
# silently found nothing and left residue behind).
|
|
71
|
+
MAIN_WT="$(git -C "$REPO" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p' | head -1)"
|
|
72
|
+
MAIN_WT="$(cd "$MAIN_WT" 2>/dev/null && pwd -P)" || MAIN_WT=""
|
|
73
|
+
[ -n "$MAIN_WT" ] && REPO="$MAIN_WT"
|
|
67
74
|
WT_ROOT="$REPO/.worktrees"
|
|
68
75
|
|
|
69
76
|
changed=0
|
|
@@ -76,9 +83,24 @@ pruned=$(git -C "$REPO" worktree prune -v 2>&1 | grep -c . || true)
|
|
|
76
83
|
# 2. Orphan dirs: present under .worktrees/ but not registered as worktrees.
|
|
77
84
|
orphans=()
|
|
78
85
|
if [ -d "$WT_ROOT" ]; then
|
|
79
|
-
|
|
86
|
+
# Resolve every registered worktree path to its physical form so the
|
|
87
|
+
# orphan test below compares like-for-like against `pwd -P`-resolved dirs.
|
|
88
|
+
# A raw (unresolved) registered path with a symlinked component would fail
|
|
89
|
+
# the match and get a HEALTHY worktree misclassified as an orphan and
|
|
90
|
+
# deleted under --yes; resolving both sides removes that risk (this mirrors
|
|
91
|
+
# purge.sh, which resolves both sides).
|
|
92
|
+
registered=""
|
|
93
|
+
while IFS= read -r regpath; do
|
|
94
|
+
[ -n "$regpath" ] || continue
|
|
95
|
+
regphys="$(cd "$regpath" 2>/dev/null && pwd -P)" || regphys=""
|
|
96
|
+
[ -n "$regphys" ] && registered="$registered$regphys
|
|
97
|
+
"
|
|
98
|
+
done <<EOF
|
|
99
|
+
$(git -C "$REPO" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p')
|
|
100
|
+
EOF
|
|
80
101
|
for d in "$WT_ROOT"/*/; do
|
|
81
102
|
[ -d "$d" ] || continue
|
|
103
|
+
[ -L "${d%/}" ] && continue # never follow a symlinked entry out of the tree
|
|
82
104
|
dir="$(cd "$d" && pwd -P)"
|
|
83
105
|
# Path guard: only ever consider dirs strictly inside <repo>/.worktrees/.
|
|
84
106
|
case "$dir" in "$WT_ROOT"/*) ;; *) continue ;; esac
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// learning-curve.mjs - time-bucketed TREND over metrics.jsonl.
|
|
3
|
+
//
|
|
4
|
+
// aggregate-metrics.mjs reports lifetime totals; this reports the CURVE: how
|
|
5
|
+
// the pipeline's per-task quality/cost move over time as it accumulates
|
|
6
|
+
// learnings on a repo. Without a trend you cannot tell whether "it gets better
|
|
7
|
+
// over time" is real - this is the measurement layer for the learning loop.
|
|
8
|
+
//
|
|
9
|
+
// Buckets tasks by the timestamp of their first event, then per bucket reports:
|
|
10
|
+
// tasks, first-pass-clean rate, avg review cycles, rework/task,
|
|
11
|
+
// tokens/task, cache-reuse ratio. Prints a first->last trend for the
|
|
12
|
+
// headline KPIs. Read-only, zero deps, no API/model calls.
|
|
13
|
+
//
|
|
14
|
+
// Usage:
|
|
15
|
+
// learning-curve.mjs [--bucket=<days>] [--since=ISO] [--json|--markdown]
|
|
16
|
+
// Env: METRICS_FILE overrides the default ~/.claude/logs/multi-agent/metrics.jsonl
|
|
17
|
+
//
|
|
18
|
+
// Exit: 0 always (advisory; empty/missing metrics -> friendly note).
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
const args = process.argv.slice(2);
|
|
25
|
+
const opts = { json: false, markdown: false, since: null, bucketDays: 7 };
|
|
26
|
+
for (const a of args) {
|
|
27
|
+
if (a === "--json") opts.json = true;
|
|
28
|
+
else if (a === "--markdown" || a === "--md") opts.markdown = true;
|
|
29
|
+
else if (a.startsWith("--since=")) opts.since = a.slice(8);
|
|
30
|
+
else if (a.startsWith("--bucket=")) opts.bucketDays = Math.max(1, parseInt(a.slice(9), 10) || 7);
|
|
31
|
+
else if (a === "--help" || a === "-h") {
|
|
32
|
+
console.log("Usage: learning-curve.mjs [--bucket=<days>] [--since=ISO] [--json|--markdown]");
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const METRICS_FILE =
|
|
38
|
+
process.env.METRICS_FILE || join(homedir(), ".claude", "logs", "multi-agent", "metrics.jsonl");
|
|
39
|
+
|
|
40
|
+
function emitEmpty(reason) {
|
|
41
|
+
if (opts.json) console.log(JSON.stringify({ ok: false, reason, buckets: [] }));
|
|
42
|
+
else console.error(`learning-curve: ${reason}`);
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!existsSync(METRICS_FILE)) emitEmpty(`no metrics file at ${METRICS_FILE}`);
|
|
47
|
+
|
|
48
|
+
const events = [];
|
|
49
|
+
for (const line of readFileSync(METRICS_FILE, "utf-8").split("\n")) {
|
|
50
|
+
if (!line.trim()) continue;
|
|
51
|
+
try {
|
|
52
|
+
const e = JSON.parse(line);
|
|
53
|
+
if (opts.since && e.ts && e.ts < opts.since) continue;
|
|
54
|
+
events.push(e);
|
|
55
|
+
} catch {
|
|
56
|
+
/* skip corrupted line */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (events.length === 0) emitEmpty("no metrics events in range");
|
|
60
|
+
|
|
61
|
+
// Fold events into per-task aggregates.
|
|
62
|
+
const num = (v) => (typeof v === "number" ? v : Number(v)) || 0;
|
|
63
|
+
const tasks = new Map();
|
|
64
|
+
for (const e of events) {
|
|
65
|
+
const id = e.task_id;
|
|
66
|
+
if (!id) continue;
|
|
67
|
+
let t = tasks.get(id);
|
|
68
|
+
if (!t) {
|
|
69
|
+
t = { firstTs: e.ts, reviewCycles: null, rework: 0, tokensIn: 0, tokensOut: 0, tokensCached: 0 };
|
|
70
|
+
tasks.set(id, t);
|
|
71
|
+
}
|
|
72
|
+
if (e.ts && (!t.firstTs || e.ts < t.firstTs)) t.firstTs = e.ts;
|
|
73
|
+
const d = e.details || {};
|
|
74
|
+
if (e.event === "review.completed" && d.review_cycles != null) t.reviewCycles = num(d.review_cycles);
|
|
75
|
+
if (e.event === "rework.started") t.rework += 1;
|
|
76
|
+
t.tokensIn += num(d.tokens_in);
|
|
77
|
+
t.tokensOut += num(d.tokens_out);
|
|
78
|
+
t.tokensCached += num(d.tokens_cached);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Bucket tasks by first-seen day / bucketDays window (UTC).
|
|
82
|
+
const DAY = 86400000;
|
|
83
|
+
const bucketOf = (ts) => {
|
|
84
|
+
const ms = Date.parse(ts);
|
|
85
|
+
if (Number.isNaN(ms)) return null;
|
|
86
|
+
const dayIndex = Math.floor(ms / DAY);
|
|
87
|
+
const b = Math.floor(dayIndex / opts.bucketDays) * opts.bucketDays;
|
|
88
|
+
return new Date(b * DAY).toISOString().slice(0, 10);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const byBucket = new Map();
|
|
92
|
+
for (const t of tasks.values()) {
|
|
93
|
+
const key = bucketOf(t.firstTs);
|
|
94
|
+
if (!key) continue;
|
|
95
|
+
if (!byBucket.has(key)) byBucket.set(key, []);
|
|
96
|
+
byBucket.get(key).push(t);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const round = (n, p = 2) => Math.round(n * 10 ** p) / 10 ** p;
|
|
100
|
+
const buckets = [...byBucket.keys()].sort().map((start) => {
|
|
101
|
+
const list = byBucket.get(start);
|
|
102
|
+
const n = list.length;
|
|
103
|
+
const withReview = list.filter((t) => t.reviewCycles != null);
|
|
104
|
+
const firstPassClean = withReview.filter((t) => t.reviewCycles <= 1).length;
|
|
105
|
+
const totIn = list.reduce((s, t) => s + t.tokensIn, 0);
|
|
106
|
+
const totOut = list.reduce((s, t) => s + t.tokensOut, 0);
|
|
107
|
+
const totCached = list.reduce((s, t) => s + t.tokensCached, 0);
|
|
108
|
+
return {
|
|
109
|
+
bucketStart: start,
|
|
110
|
+
tasks: n,
|
|
111
|
+
firstPassCleanRate: withReview.length ? round(firstPassClean / withReview.length) : null,
|
|
112
|
+
avgReviewCycles: withReview.length
|
|
113
|
+
? round(withReview.reduce((s, t) => s + t.reviewCycles, 0) / withReview.length)
|
|
114
|
+
: null,
|
|
115
|
+
reworkPerTask: round(list.reduce((s, t) => s + t.rework, 0) / n),
|
|
116
|
+
tokensPerTask: Math.round((totIn + totOut) / n),
|
|
117
|
+
cacheRatio: totIn ? round(totCached / totIn) : null,
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// first->last trend for the headline KPIs (only when >=2 buckets).
|
|
122
|
+
let trend = null;
|
|
123
|
+
if (buckets.length >= 2) {
|
|
124
|
+
const a = buckets[0];
|
|
125
|
+
const z = buckets[buckets.length - 1];
|
|
126
|
+
const delta = (x, y) => (x == null || y == null ? null : round(y - x));
|
|
127
|
+
trend = {
|
|
128
|
+
from: a.bucketStart,
|
|
129
|
+
to: z.bucketStart,
|
|
130
|
+
firstPassCleanRate: delta(a.firstPassCleanRate, z.firstPassCleanRate),
|
|
131
|
+
avgReviewCycles: delta(a.avgReviewCycles, z.avgReviewCycles),
|
|
132
|
+
tokensPerTask: a.tokensPerTask && z.tokensPerTask ? z.tokensPerTask - a.tokensPerTask : null,
|
|
133
|
+
cacheRatio: delta(a.cacheRatio, z.cacheRatio),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const out = { ok: true, bucketDays: opts.bucketDays, buckets, trend };
|
|
138
|
+
|
|
139
|
+
if (opts.json) {
|
|
140
|
+
console.log(JSON.stringify(out, null, 2));
|
|
141
|
+
process.exit(0);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Markdown / text (default)
|
|
145
|
+
const arrow = (v, goodIsUp) => {
|
|
146
|
+
if (v == null || v === 0) return "-";
|
|
147
|
+
const up = v > 0;
|
|
148
|
+
const good = up === goodIsUp;
|
|
149
|
+
return `${up ? "+" : ""}${v} ${good ? "(better)" : "(worse)"}`;
|
|
150
|
+
};
|
|
151
|
+
console.log(`# Learning curve (${opts.bucketDays}-day buckets)\n`);
|
|
152
|
+
console.log("| Bucket | Tasks | First-pass clean | Avg review cycles | Rework/task | Tokens/task | Cache ratio |");
|
|
153
|
+
console.log("|---|---|---|---|---|---|---|");
|
|
154
|
+
for (const b of buckets) {
|
|
155
|
+
console.log(
|
|
156
|
+
`| ${b.bucketStart} | ${b.tasks} | ${b.firstPassCleanRate ?? "-"} | ${b.avgReviewCycles ?? "-"} | ${b.reworkPerTask} | ${b.tokensPerTask} | ${b.cacheRatio ?? "-"} |`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (trend) {
|
|
160
|
+
console.log(`\n**Trend ${trend.from} -> ${trend.to}:**`);
|
|
161
|
+
console.log(`- First-pass clean: ${arrow(trend.firstPassCleanRate, true)}`);
|
|
162
|
+
console.log(`- Avg review cycles: ${arrow(trend.avgReviewCycles, false)}`);
|
|
163
|
+
console.log(`- Tokens/task: ${arrow(trend.tokensPerTask, false)}`);
|
|
164
|
+
console.log(`- Cache ratio: ${arrow(trend.cacheRatio, true)}`);
|
|
165
|
+
} else {
|
|
166
|
+
console.log("\n(need >= 2 buckets for a trend; keep running the pipeline)");
|
|
167
|
+
}
|
|
@@ -120,7 +120,7 @@ function writeRow(slug, row) {
|
|
|
120
120
|
|
|
121
121
|
/** Append one entry unless an entry with the same kind + normalized statement
|
|
122
122
|
* already exists (idempotent). Returns true when written. */
|
|
123
|
-
function addEntry(slug, { kind, statement, scope, task, confidence }, existing) {
|
|
123
|
+
function addEntry(slug, { kind, statement, scope, task, confidence, diagnosis }, existing) {
|
|
124
124
|
const stmt = String(statement || "").trim();
|
|
125
125
|
if (!stmt) return false;
|
|
126
126
|
const seenKey = `${kind}::${normStatement(stmt)}`;
|
|
@@ -135,6 +135,11 @@ function addEntry(slug, { kind, statement, scope, task, confidence }, existing)
|
|
|
135
135
|
source_task: task || null,
|
|
136
136
|
confidence: CONFIDENCES.has(confidence) ? confidence : "medium",
|
|
137
137
|
};
|
|
138
|
+
// Optional causal diagnosis (Reflexion P5): the "why it failed" behind a
|
|
139
|
+
// lesson. Verbal diagnosis is what turns a detected error into a prevented
|
|
140
|
+
// one; a bare outcome does not. Stored only when supplied.
|
|
141
|
+
const diag = String(diagnosis || "").trim();
|
|
142
|
+
if (diag) row.diagnosis = diag;
|
|
138
143
|
writeRow(slug, row);
|
|
139
144
|
existing.add(seenKey);
|
|
140
145
|
return true;
|
|
@@ -152,6 +157,7 @@ function cmdAdd() {
|
|
|
152
157
|
scope: flags.scope && flags.scope !== true ? String(flags.scope) : "global",
|
|
153
158
|
task: flags.task && flags.task !== true ? String(flags.task) : null,
|
|
154
159
|
confidence: flags.confidence && flags.confidence !== true ? String(flags.confidence) : "medium",
|
|
160
|
+
diagnosis: flags.diagnosis && flags.diagnosis !== true ? String(flags.diagnosis) : null,
|
|
155
161
|
}, existing);
|
|
156
162
|
process.stdout.write(JSON.stringify({ ok: true, slug, added: wrote ? 1 : 0, deduped: wrote ? 0 : 1 }) + "\n");
|
|
157
163
|
process.exit(wrote ? 0 : 2);
|
|
@@ -247,7 +253,8 @@ function cmdBrief() {
|
|
|
247
253
|
lines.push("", `## ${labels[kind]}`);
|
|
248
254
|
for (const r of items) {
|
|
249
255
|
const scope = r.scope && r.scope !== "global" ? ` [${r.scope}]` : "";
|
|
250
|
-
|
|
256
|
+
const why = r.diagnosis ? ` (why: ${r.diagnosis})` : "";
|
|
257
|
+
lines.push(`- ${r.statement}${scope}${why}`);
|
|
251
258
|
}
|
|
252
259
|
}
|
|
253
260
|
lines.push("</repo-learnings>");
|
|
@@ -18,7 +18,7 @@ const DEFAULT_FILE = path.join(os.homedir(), ".claude", "multi-agent-preferences
|
|
|
18
18
|
|
|
19
19
|
// Single source of truth for the migration target version. Referenced by the
|
|
20
20
|
// migrate() bump chain AND every user-facing message, so they cannot drift.
|
|
21
|
-
const TARGET_VERSION = "2.
|
|
21
|
+
const TARGET_VERSION = "2.4.0";
|
|
22
22
|
|
|
23
23
|
const USAGE = "Usage: migrate-prefs.mjs [--dry-run] [--file <path>]";
|
|
24
24
|
|
|
@@ -164,6 +164,9 @@ function migrate(prefs) {
|
|
|
164
164
|
const TARGET = TARGET_VERSION;
|
|
165
165
|
if (out.schemaVersion === TARGET) {
|
|
166
166
|
// already on target - skip version bump, but still run sub-migrations below
|
|
167
|
+
} else if (out.schemaVersion === "2.3.0") {
|
|
168
|
+
out.schemaVersion = TARGET;
|
|
169
|
+
changes.push(`schemaVersion: 2.3.0 → ${TARGET}`);
|
|
167
170
|
} else if (out.schemaVersion === "2.2.0") {
|
|
168
171
|
out.schemaVersion = TARGET;
|
|
169
172
|
changes.push(`schemaVersion: 2.2.0 → ${TARGET}`);
|
|
@@ -241,6 +244,10 @@ function migrate(prefs) {
|
|
|
241
244
|
out.global.accounts = [];
|
|
242
245
|
changes.push("added empty accounts (v7.4.0 per-account host overrides)");
|
|
243
246
|
}
|
|
247
|
+
if (!Array.isArray(out.global.routines)) {
|
|
248
|
+
out.global.routines = [];
|
|
249
|
+
changes.push("added empty routines (v2.4.0 user-defined /multi-agent:save routines)");
|
|
250
|
+
}
|
|
244
251
|
if (!out.global.serviceStatus) {
|
|
245
252
|
out.global.serviceStatus = {};
|
|
246
253
|
changes.push("added empty serviceStatus");
|
|
@@ -82,7 +82,7 @@ function parseArgs(argv) {
|
|
|
82
82
|
// ---- File walking ----------------------------------------------------------
|
|
83
83
|
|
|
84
84
|
const DEFAULT_IGNORE = new Set([
|
|
85
|
-
'.git', 'node_modules', 'Pods', '.build', 'DerivedData', '.next',
|
|
85
|
+
'.git', '.worktrees', 'node_modules', 'Pods', '.build', 'DerivedData', '.next',
|
|
86
86
|
'dist', 'build', '__pycache__', '.venv', 'venv', '.cache',
|
|
87
87
|
'.gradle', '.idea', '.vscode', 'target',
|
|
88
88
|
]);
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// routine-registry.mjs - backend for the user-defined routine commands
|
|
3
|
+
// (/multi-agent:save, :routines, :forget).
|
|
4
|
+
//
|
|
5
|
+
// A "routine" is a saved, reusable procedure the user binds to its own
|
|
6
|
+
// /multi-agent:<name> slash command. Each routine is TWO artifacts:
|
|
7
|
+
// 1. a local-only command dir ~/.claude/commands/multi-agent/<name>/SKILL.md
|
|
8
|
+
// (frontmatter `local-only: true` -> the installer preserves it across
|
|
9
|
+
// updates and /multi-agent:sync refuses to leak it into the public repo)
|
|
10
|
+
// 2. a registry entry in ~/.claude/multi-agent-preferences.json
|
|
11
|
+
// at global.routines[] (name, description, createdAt, source)
|
|
12
|
+
//
|
|
13
|
+
// This tool is the mechanical half; the save/routines/forget SKILLs drive the
|
|
14
|
+
// UX (candidate discovery, pickers, confirmation) and call these subcommands.
|
|
15
|
+
//
|
|
16
|
+
// Zero deps. Never writes under a repo `pipeline/` tree; only ever under
|
|
17
|
+
// <home>/.claude/. Refuses to touch a shipped (non-local-only) command dir.
|
|
18
|
+
//
|
|
19
|
+
// Usage:
|
|
20
|
+
// node routine-registry.mjs list [--json] [--home <dir>]
|
|
21
|
+
// node routine-registry.mjs get --name <n> [--home <dir>]
|
|
22
|
+
// node routine-registry.mjs add --name <n> --description <d>
|
|
23
|
+
// [--description-tr <t>] [--argument-hint <h>] --source <s> --body-file <f> [--home <dir>]
|
|
24
|
+
// node routine-registry.mjs remove --name <n> [--home <dir>]
|
|
25
|
+
//
|
|
26
|
+
// Exit: 0 ok, 1 error (validation / collision / not-found), 64 usage.
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
existsSync,
|
|
30
|
+
readFileSync,
|
|
31
|
+
writeFileSync,
|
|
32
|
+
mkdirSync,
|
|
33
|
+
rmSync,
|
|
34
|
+
renameSync,
|
|
35
|
+
} from "node:fs";
|
|
36
|
+
import { join, dirname } from "node:path";
|
|
37
|
+
|
|
38
|
+
const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
39
|
+
const MAX_NAME = 40;
|
|
40
|
+
const MAX_ROUTINES = 100;
|
|
41
|
+
|
|
42
|
+
function parseArgs(argv) {
|
|
43
|
+
const out = { _: [] };
|
|
44
|
+
for (let i = 0; i < argv.length; i++) {
|
|
45
|
+
const a = argv[i];
|
|
46
|
+
if (a.startsWith("--")) {
|
|
47
|
+
const key = a.slice(2);
|
|
48
|
+
const next = argv[i + 1];
|
|
49
|
+
if (next === undefined || next.startsWith("--")) {
|
|
50
|
+
out[key] = true;
|
|
51
|
+
} else {
|
|
52
|
+
out[key] = next;
|
|
53
|
+
i++;
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
out._.push(a);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function fail(msg) {
|
|
63
|
+
console.error(`routine-registry: ${msg}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function homeDir(args) {
|
|
68
|
+
return args.home || process.env.HOME || process.env.USERPROFILE || "";
|
|
69
|
+
}
|
|
70
|
+
function prefsPath(home) {
|
|
71
|
+
return join(home, ".claude", "multi-agent-preferences.json");
|
|
72
|
+
}
|
|
73
|
+
function routineDir(home, name) {
|
|
74
|
+
return join(home, ".claude", "commands", "multi-agent", name);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readPrefs(home) {
|
|
78
|
+
const p = prefsPath(home);
|
|
79
|
+
if (!existsSync(p)) return { global: {} };
|
|
80
|
+
try {
|
|
81
|
+
const prefs = JSON.parse(readFileSync(p, "utf-8"));
|
|
82
|
+
if (!prefs.global) prefs.global = {};
|
|
83
|
+
return prefs;
|
|
84
|
+
} catch {
|
|
85
|
+
fail(`could not parse ${p}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function writePrefs(home, prefs) {
|
|
90
|
+
const p = prefsPath(home);
|
|
91
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
92
|
+
const tmp = `${p}.tmp.${process.pid}`;
|
|
93
|
+
writeFileSync(tmp, `${JSON.stringify(prefs, null, 2)}\n`);
|
|
94
|
+
renameSync(tmp, p);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function getRoutines(prefs) {
|
|
98
|
+
return Array.isArray(prefs.global.routines) ? prefs.global.routines : [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A dir is a shipped (non-routine) command when it exists and its SKILL.md is
|
|
102
|
+
// NOT marked local-only. Those must never be overwritten or removed.
|
|
103
|
+
function isLocalOnlyDir(dir) {
|
|
104
|
+
const skill = join(dir, "SKILL.md");
|
|
105
|
+
if (!existsSync(skill)) return false;
|
|
106
|
+
return /^local-only:\s*true\s*$/m.test(readFileSync(skill, "utf-8"));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function cmdList(home, json) {
|
|
110
|
+
const routines = getRoutines(readPrefs(home));
|
|
111
|
+
if (json) {
|
|
112
|
+
process.stdout.write(`${JSON.stringify(routines, null, 2)}\n`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (routines.length === 0) {
|
|
116
|
+
console.log("(no routines saved)");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
for (const r of routines) {
|
|
120
|
+
console.log(`${r.name}\t${r.description || ""}\t${r.createdAt || ""}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function cmdGet(home, name) {
|
|
125
|
+
const r = getRoutines(readPrefs(home)).find((x) => x.name === name);
|
|
126
|
+
if (!r) fail(`no routine named "${name}"`);
|
|
127
|
+
process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function buildSkill(args) {
|
|
131
|
+
const desc = args.description;
|
|
132
|
+
const descTr = args["description-tr"];
|
|
133
|
+
const hint = args["argument-hint"] || "[optional args]";
|
|
134
|
+
const body = readFileSync(args["body-file"], "utf-8").trimEnd();
|
|
135
|
+
const fm = [
|
|
136
|
+
"---",
|
|
137
|
+
`description: ${JSON.stringify(desc)}`,
|
|
138
|
+
descTr ? `description-tr: ${JSON.stringify(descTr)}` : null,
|
|
139
|
+
`argument-hint: ${JSON.stringify(hint)}`,
|
|
140
|
+
"allowed-tools: Skill, Bash, Read, Edit, Write, AskUserQuestion",
|
|
141
|
+
"local-only: true",
|
|
142
|
+
"---",
|
|
143
|
+
].filter(Boolean);
|
|
144
|
+
return `${fm.join("\n")}\n\n# multi-agent ${args.name} - saved routine\n\n**Input**: $ARGUMENTS\n\n${body}\n`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function cmdAdd(home, args) {
|
|
148
|
+
const name = args.name;
|
|
149
|
+
if (!name || !NAME_RE.test(name) || name.length > MAX_NAME) {
|
|
150
|
+
fail(`invalid --name (need ${NAME_RE} , <= ${MAX_NAME} chars): ${name}`);
|
|
151
|
+
}
|
|
152
|
+
if (!args.description) fail("--description is required");
|
|
153
|
+
if (!args["body-file"] || !existsSync(args["body-file"])) {
|
|
154
|
+
fail("--body-file is required and must exist");
|
|
155
|
+
}
|
|
156
|
+
const dir = routineDir(home, name);
|
|
157
|
+
// Collision: an existing non-local-only dir is a shipped command - never touch.
|
|
158
|
+
if (existsSync(dir) && !isLocalOnlyDir(dir)) {
|
|
159
|
+
fail(`"${name}" collides with a shipped command; pick another name`);
|
|
160
|
+
}
|
|
161
|
+
const prefs = readPrefs(home);
|
|
162
|
+
const routines = getRoutines(prefs);
|
|
163
|
+
if (routines.some((r) => r.name === name)) {
|
|
164
|
+
fail(`a routine named "${name}" already exists; forget it first or pick another name`);
|
|
165
|
+
}
|
|
166
|
+
if (routines.length >= MAX_ROUTINES) fail(`routine cap (${MAX_ROUTINES}) reached`);
|
|
167
|
+
|
|
168
|
+
mkdirSync(dir, { recursive: true });
|
|
169
|
+
const skill = buildSkill(args);
|
|
170
|
+
// Safety: the generated file MUST be local-only so it never syncs to the repo.
|
|
171
|
+
if (!/^local-only:\s*true\s*$/m.test(skill)) fail("internal: generated SKILL missing local-only");
|
|
172
|
+
writeFileSync(join(dir, "SKILL.md"), skill);
|
|
173
|
+
|
|
174
|
+
routines.push({
|
|
175
|
+
name,
|
|
176
|
+
description: args.description,
|
|
177
|
+
createdAt: new Date().toISOString(),
|
|
178
|
+
source: args.source || "user",
|
|
179
|
+
});
|
|
180
|
+
prefs.global.routines = routines;
|
|
181
|
+
writePrefs(home, prefs);
|
|
182
|
+
console.log(`routine-registry: saved "${name}" -> ${join(dir, "SKILL.md")}`);
|
|
183
|
+
console.log("routine-registry: available as /multi-agent:" + name + " after a session reload");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function cmdRemove(home, name) {
|
|
187
|
+
if (!name) fail("--name is required");
|
|
188
|
+
const prefs = readPrefs(home);
|
|
189
|
+
const routines = getRoutines(prefs);
|
|
190
|
+
const idx = routines.findIndex((r) => r.name === name);
|
|
191
|
+
const dir = routineDir(home, name);
|
|
192
|
+
// Guard: only remove a dir that is BOTH registered AND local-only.
|
|
193
|
+
if (existsSync(dir) && !isLocalOnlyDir(dir)) {
|
|
194
|
+
fail(`"${name}" is a shipped command, not a routine; refusing to remove`);
|
|
195
|
+
}
|
|
196
|
+
if (idx < 0 && !existsSync(dir)) fail(`no routine named "${name}"`);
|
|
197
|
+
if (existsSync(dir) && isLocalOnlyDir(dir)) rmSync(dir, { recursive: true, force: true });
|
|
198
|
+
if (idx >= 0) {
|
|
199
|
+
routines.splice(idx, 1);
|
|
200
|
+
prefs.global.routines = routines;
|
|
201
|
+
writePrefs(home, prefs);
|
|
202
|
+
}
|
|
203
|
+
console.log(`routine-registry: removed "${name}" (clears from autocomplete on next session reload)`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function main() {
|
|
207
|
+
const [, , sub, ...rest] = process.argv;
|
|
208
|
+
const args = parseArgs(rest);
|
|
209
|
+
const home = homeDir(args);
|
|
210
|
+
if (!home) fail("cannot resolve home dir");
|
|
211
|
+
switch (sub) {
|
|
212
|
+
case "list":
|
|
213
|
+
return cmdList(home, Boolean(args.json));
|
|
214
|
+
case "get":
|
|
215
|
+
return cmdGet(home, args.name);
|
|
216
|
+
case "add":
|
|
217
|
+
return cmdAdd(home, args);
|
|
218
|
+
case "remove":
|
|
219
|
+
return cmdRemove(home, args.name);
|
|
220
|
+
default:
|
|
221
|
+
console.error("usage: routine-registry.mjs <list|get|add|remove> [flags] (see header)");
|
|
222
|
+
process.exit(64);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
main();
|
|
@@ -91,11 +91,11 @@ echo ""
|
|
|
91
91
|
echo "→ 8. registration + count consistency (36)"
|
|
92
92
|
grep -Fq '| `create-jira ' "$DISPATCHER" && pass "dispatcher routing: create-jira" || fail "dispatcher routing missing: generate"
|
|
93
93
|
grep -Fq "create-jira/SKILL.md" "$DISPATCHER" && pass "dispatcher loading row" || fail "dispatcher loading row missing"
|
|
94
|
-
grep -Fq "## 1. Command Inventory (
|
|
94
|
+
grep -Fq "## 1. Command Inventory (41 commands)" "$CONTRACT" && pass "contract count = 41" || fail "contract count wrong"
|
|
95
95
|
grep -Eq '(^|[ ,])create-jira([ ,]|$)' "$CONTRACT" && pass "contract list has create-jira" || fail "contract list missing generate"
|
|
96
|
-
grep -Fq "**
|
|
96
|
+
grep -Fq "**41 commands are synced**" "$SYNC" && pass "sync count = 41" || fail "sync count wrong"
|
|
97
97
|
SYNC_SKILL="$ROOT/pipeline/skills/shared/core/multi-agent-sync/SKILL.md"
|
|
98
|
-
grep -Fq "**
|
|
98
|
+
grep -Fq "**41 commands are synced**" "$SYNC_SKILL" && pass "sync SKILL count = 41" || fail "sync SKILL count wrong (Copilot parity)"
|
|
99
99
|
grep -Fq "create-jira" "$ROOT/pipeline/skills/shared/core/multi-agent-help/SKILL.md" && pass "help SKILL lists create-jira" || fail "help SKILL missing create-jira (Copilot parity)"
|
|
100
100
|
grep -c "multi-agent:create-jira" "$HELP" | awk '{exit !($1>=2)}' && pass "help.md has EN + TR entries" || fail "help.md entries incomplete"
|
|
101
101
|
# no stale split-command references anywhere in the synced surfaces
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# smoke-learning-curve.sh - contract for learning-curve.mjs (time-bucketed
|
|
3
|
+
# trend over metrics.jsonl).
|
|
4
|
+
|
|
5
|
+
set -uo pipefail
|
|
6
|
+
|
|
7
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
8
|
+
LC="$SCRIPT_DIR/learning-curve.mjs"
|
|
9
|
+
|
|
10
|
+
PASS=0
|
|
11
|
+
FAIL=0
|
|
12
|
+
record_pass() { PASS=$((PASS + 1)); printf ' \342\234\223 %s\n' "$1"; }
|
|
13
|
+
record_fail() { FAIL=$((FAIL + 1)); printf ' \342\234\227 %s\n' "$1"; }
|
|
14
|
+
|
|
15
|
+
printf '\342\206\222 smoke-learning-curve: metrics trend contract\n'
|
|
16
|
+
|
|
17
|
+
tmp=$(mktemp -d "${TMPDIR:-/tmp}/lc.XXXXXX")
|
|
18
|
+
trap 'rm -rf "$tmp"' EXIT
|
|
19
|
+
M="$tmp/metrics.jsonl"
|
|
20
|
+
|
|
21
|
+
# Two early tasks (noisy: 3/2 review cycles, low cache) + two later tasks
|
|
22
|
+
# (clean: 1 cycle, high cache) -> the curve should improve.
|
|
23
|
+
cat > "$M" <<'EOF'
|
|
24
|
+
{"ts":"2026-06-02T10:00:00Z","task_id":"t1","phase":"4","event":"review.completed","details":{"review_cycles":3,"tokens_in":100000,"tokens_out":8000,"tokens_cached":10000}}
|
|
25
|
+
{"ts":"2026-06-02T10:05:00Z","task_id":"t1","phase":"3","event":"rework.started","details":{}}
|
|
26
|
+
{"ts":"2026-06-03T09:00:00Z","task_id":"t2","phase":"4","event":"review.completed","details":{"review_cycles":2,"tokens_in":90000,"tokens_out":7000,"tokens_cached":20000}}
|
|
27
|
+
{"ts":"2026-06-24T09:00:00Z","task_id":"t3","phase":"4","event":"review.completed","details":{"review_cycles":1,"tokens_in":40000,"tokens_out":4000,"tokens_cached":30000}}
|
|
28
|
+
{"ts":"2026-06-25T09:00:00Z","task_id":"t4","phase":"4","event":"review.completed","details":{"review_cycles":1,"tokens_in":38000,"tokens_out":3500,"tokens_cached":32000}}
|
|
29
|
+
EOF
|
|
30
|
+
|
|
31
|
+
# 1. parses
|
|
32
|
+
bash -n "$SCRIPT_DIR/smoke-learning-curve.sh" >/dev/null 2>&1 || true
|
|
33
|
+
if node --check "$LC" 2>/dev/null; then record_pass "learning-curve.mjs parses"; else record_fail "syntax error"; fi
|
|
34
|
+
|
|
35
|
+
# 2. JSON: buckets + improving trend
|
|
36
|
+
out=$(METRICS_FILE="$M" node "$LC" --json 2>/dev/null)
|
|
37
|
+
nbuckets=$(printf '%s' "$out" | python3 -c "import json,sys;print(len(json.load(sys.stdin)['buckets']))")
|
|
38
|
+
[ "$nbuckets" -ge 2 ] && record_pass "produces >=2 time buckets" || record_fail "expected >=2 buckets, got $nbuckets"
|
|
39
|
+
|
|
40
|
+
tokens_delta=$(printf '%s' "$out" | python3 -c "import json,sys;print(json.load(sys.stdin)['trend']['tokensPerTask'])")
|
|
41
|
+
case "$tokens_delta" in
|
|
42
|
+
-*) record_pass "tokens/task trend is negative (improving)" ;;
|
|
43
|
+
*) record_fail "tokens/task trend should drop, got $tokens_delta" ;;
|
|
44
|
+
esac
|
|
45
|
+
cache_delta=$(printf '%s' "$out" | python3 -c "import json,sys;print(json.load(sys.stdin)['trend']['cacheRatio'])")
|
|
46
|
+
case "$cache_delta" in
|
|
47
|
+
-*|0|0.0) record_fail "cache ratio should rise, got $cache_delta" ;;
|
|
48
|
+
*) record_pass "cache ratio trend rises (improving)" ;;
|
|
49
|
+
esac
|
|
50
|
+
|
|
51
|
+
# 3. markdown renders a table + trend
|
|
52
|
+
md=$(METRICS_FILE="$M" node "$LC" 2>/dev/null)
|
|
53
|
+
printf '%s' "$md" | grep -q "Learning curve" && record_pass "markdown has a header" || record_fail "no markdown header"
|
|
54
|
+
printf '%s' "$md" | grep -q "Trend" && record_pass "markdown shows a trend block" || record_fail "no trend block"
|
|
55
|
+
|
|
56
|
+
# 4. missing metrics -> graceful, exit 0
|
|
57
|
+
METRICS_FILE="/nope/none.jsonl" node "$LC" --json >/dev/null 2>&1
|
|
58
|
+
[ "$?" -eq 0 ] && record_pass "missing metrics is a graceful no-op (exit 0)" || record_fail "missing metrics should exit 0"
|
|
59
|
+
|
|
60
|
+
printf '\n\342\225\220\342\225\220 smoke-learning-curve: %d passed, %d failed \342\225\220\342\225\220\n' "$PASS" "$FAIL"
|
|
61
|
+
[ "$FAIL" -eq 0 ]
|
|
@@ -75,12 +75,12 @@ else
|
|
|
75
75
|
fail "unknown flag did not exit 1"
|
|
76
76
|
fi
|
|
77
77
|
|
|
78
|
-
# Test 7 - type auto-detection works
|
|
78
|
+
# Test 7 - type auto-detection works (feed a doc already at the latest version)
|
|
79
79
|
cat > "$TMPDIR/multi-agent-preferences.json" <<'EOF'
|
|
80
|
-
{"schemaVersion":"2.
|
|
80
|
+
{"schemaVersion":"2.4.0","global":{"identities":[],"keychainMapping":{},"recentAccounts":[],"accounts":[],"routines":[]},"projects":{}}
|
|
81
81
|
EOF
|
|
82
82
|
OUT=$(node "$RUNNER" "$TMPDIR/multi-agent-preferences.json" 2>&1)
|
|
83
|
-
if echo "$OUT" | grep -q "already at schemaVersion 2.
|
|
83
|
+
if echo "$OUT" | grep -q "already at schemaVersion 2.4.0"; then
|
|
84
84
|
pass "prefs type auto-detected from filename"
|
|
85
85
|
else
|
|
86
86
|
fail "prefs auto-detection failed (got: $OUT)"
|
|
@@ -64,7 +64,7 @@ F1=$(run_fixture "f1-v2.0.0-legacy" '{
|
|
|
64
64
|
},
|
|
65
65
|
"projects": {}
|
|
66
66
|
}')
|
|
67
|
-
assert_json_has "f1" "$F1" ".schemaVersion" "2.
|
|
67
|
+
assert_json_has "f1" "$F1" ".schemaVersion" "2.4.0"
|
|
68
68
|
assert_json_has "f1" "$F1" ".global.identities" "array:1"
|
|
69
69
|
assert_json_has "f1" "$F1" ".global.gitIdentities" "undefined"
|
|
70
70
|
assert_json_has "f1" "$F1" ".global.settings" "object:8"
|
|
@@ -73,6 +73,7 @@ assert_json_has "f1" "$F1" ".global.platformIdentityRouting" "object:0"
|
|
|
73
73
|
assert_json_has "f1" "$F1" ".global.recentGroups" "array:0"
|
|
74
74
|
assert_json_has "f1" "$F1" ".global.recentAccounts" "array:0"
|
|
75
75
|
assert_json_has "f1" "$F1" ".global.accounts" "array:0"
|
|
76
|
+
assert_json_has "f1" "$F1" ".global.routines" "array:0"
|
|
76
77
|
|
|
77
78
|
echo ""
|
|
78
79
|
echo "→ Fixture 2: no schemaVersion (pre-2.0.0 fossil)"
|
|
@@ -80,15 +81,15 @@ F2=$(run_fixture "f2-no-version" '{
|
|
|
80
81
|
"global": {"gitIdentities": []},
|
|
81
82
|
"projects": {}
|
|
82
83
|
}')
|
|
83
|
-
assert_json_has "f2" "$F2" ".schemaVersion" "2.
|
|
84
|
+
assert_json_has "f2" "$F2" ".schemaVersion" "2.4.0"
|
|
84
85
|
assert_json_has "f2" "$F2" ".global.identities" "array:0"
|
|
85
86
|
|
|
86
87
|
echo ""
|
|
87
|
-
echo "→ Fixture 3: v2.
|
|
88
|
-
F3_FILE="$TMP/f3-v2.
|
|
88
|
+
echo "→ Fixture 3: v2.4.0 already (no-op)"
|
|
89
|
+
F3_FILE="$TMP/f3-v2.4.0.json"
|
|
89
90
|
cat > "$F3_FILE" <<'EOF'
|
|
90
91
|
{
|
|
91
|
-
"schemaVersion": "2.
|
|
92
|
+
"schemaVersion": "2.4.0",
|
|
92
93
|
"global": {
|
|
93
94
|
"identities": [],
|
|
94
95
|
"keychainMapping": {
|
|
@@ -117,7 +118,8 @@ cat > "$F3_FILE" <<'EOF'
|
|
|
117
118
|
"wikiScope": ["main", "ios", "screenshots", "index"],
|
|
118
119
|
"autopilotReportTimeoutSeconds": 1800,
|
|
119
120
|
"recentAccounts": [],
|
|
120
|
-
"accounts": []
|
|
121
|
+
"accounts": [],
|
|
122
|
+
"routines": []
|
|
121
123
|
},
|
|
122
124
|
"projects": {}
|
|
123
125
|
}
|
|
@@ -66,7 +66,7 @@ grep -Fq '| `review-jira ' "$DISPATCHER" && pass "dispatcher desc: review-jira"
|
|
|
66
66
|
grep -Fq '| `review-issue ' "$DISPATCHER" && pass "dispatcher desc: review-issue" || fail "dispatcher desc missing review-issue"
|
|
67
67
|
grep -Fq "review-jira/SKILL.md" "$DISPATCHER" && pass "dispatcher routing: review-jira" || fail "dispatcher routing missing review-jira"
|
|
68
68
|
grep -Fq "review-issue/SKILL.md" "$DISPATCHER" && pass "dispatcher routing: review-issue" || fail "dispatcher routing missing review-issue"
|
|
69
|
-
grep -Fq "## 1. Command Inventory (
|
|
69
|
+
grep -Fq "## 1. Command Inventory (41 commands)" "$CONTRACT" && pass "contract count = 41" || fail "contract count wrong"
|
|
70
70
|
grep -Eq '(^|[ ,])review-jira([ ,]|$)' "$CONTRACT" && pass "contract inventory has review-jira" || fail "contract missing review-jira"
|
|
71
71
|
grep -Eq '(^|[ ,])review-issue([ ,]|$)' "$CONTRACT" && pass "contract inventory has review-issue" || fail "contract missing review-issue"
|
|
72
72
|
grep -Fq "multi-agent:review-jira" "$HELP" && pass "help lists review-jira" || fail "help missing review-jira"
|