@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/package.json +1 -1
  3. package/pipeline/commands/multi-agent/forget/SKILL.md +29 -0
  4. package/pipeline/commands/multi-agent/help/SKILL.md +18 -0
  5. package/pipeline/commands/multi-agent/routines/SKILL.md +30 -0
  6. package/pipeline/commands/multi-agent/save/SKILL.md +46 -0
  7. package/pipeline/commands/multi-agent/sync/SKILL.md +4 -4
  8. package/pipeline/lib/extract-conventions.sh +1 -0
  9. package/pipeline/lib/repo-cache.sh +1 -0
  10. package/pipeline/lib/shadow-git.sh +8 -0
  11. package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -3
  12. package/pipeline/multi-agent-refs/phases/phase-0-init.md +9 -1
  13. package/pipeline/multi-agent-refs/phases/phase-4-review.md +3 -2
  14. package/pipeline/multi-agent-refs/phases/phase-5-test.md +10 -0
  15. package/pipeline/multi-agent-refs/phases.md +2 -0
  16. package/pipeline/multi-agent-refs/prompt-assembly.md +31 -0
  17. package/pipeline/schemas/learnings-ledger.schema.json +4 -0
  18. package/pipeline/schemas/migrations/prefs-2.3.0-to-2.4.0.mjs +32 -0
  19. package/pipeline/schemas/prefs.schema.json +36 -1
  20. package/pipeline/schemas/token-budget.json +2 -2
  21. package/pipeline/schemas/triage-corpus.schema.json +5 -1
  22. package/pipeline/scripts/eval-mine-corpus.mjs +19 -5
  23. package/pipeline/scripts/fixtures/install-layout.tsv +7 -7
  24. package/pipeline/scripts/gc-worktrees.sh +23 -1
  25. package/pipeline/scripts/learning-curve.mjs +167 -0
  26. package/pipeline/scripts/learnings-ledger.mjs +9 -2
  27. package/pipeline/scripts/migrate-prefs.mjs +8 -1
  28. package/pipeline/scripts/repo-map.mjs +1 -1
  29. package/pipeline/scripts/routine-registry.mjs +226 -0
  30. package/pipeline/scripts/smoke-generate-issue.sh +3 -3
  31. package/pipeline/scripts/smoke-learning-curve.sh +61 -0
  32. package/pipeline/scripts/smoke-migrate-state.sh +3 -3
  33. package/pipeline/scripts/smoke-pref-migration.sh +8 -6
  34. package/pipeline/scripts/smoke-review-readiness.sh +1 -1
  35. package/pipeline/scripts/smoke-routines.sh +84 -0
  36. package/pipeline/scripts/triage-memory.mjs +48 -10
  37. package/pipeline/skills/shared/core/multi-agent-forget/SKILL.md +22 -0
  38. package/pipeline/skills/shared/core/multi-agent-routines/SKILL.md +20 -0
  39. package/pipeline/skills/shared/core/multi-agent-save/SKILL.md +27 -0
  40. 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
- registered=$(git -C "$REPO" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p')
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
- lines.push(`- ${r.statement}${scope}`);
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.3.0";
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 (38 commands)" "$CONTRACT" && pass "contract count = 38" || fail "contract count wrong"
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 "**38 commands are synced**" "$SYNC" && pass "sync count = 38" || fail "sync count wrong"
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 "**38 commands are synced**" "$SYNC_SKILL" && pass "sync SKILL count = 38" || fail "sync SKILL count wrong (Copilot parity)"
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.3.0","global":{"identities":[],"keychainMapping":{},"recentAccounts":[],"accounts":[]},"projects":{}}
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.3.0"; then
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.3.0"
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.3.0"
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.3.0 already (no-op)"
88
- F3_FILE="$TMP/f3-v2.3.0.json"
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.3.0",
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 (38 commands)" "$CONTRACT" && pass "contract count = 38" || fail "contract count wrong"
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"