@mmerterden/multi-agent-pipeline 20.30.0 → 20.33.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 (120) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +45 -0
  3. package/README.tr.md +59 -0
  4. package/docs/facts.json +3 -3
  5. package/install/_unattended-profile.mjs +61 -0
  6. package/install/catalog-history.json +1 -1
  7. package/install/claude.mjs +9 -2
  8. package/install/index.mjs +10 -1
  9. package/install/templates/claude-hooks.json +12 -1
  10. package/manifest.json +123 -82
  11. package/package.json +2 -1
  12. package/pipeline/commands/multi-agent/analysis/SKILL.md +4 -4
  13. package/pipeline/commands/multi-agent/office-on/SKILL.md +8 -7
  14. package/pipeline/commands/multi-agent/office-status/SKILL.md +1 -1
  15. package/pipeline/commands/multi-agent/prune-logs/SKILL.md +4 -1
  16. package/pipeline/commands/multi-agent/review/SKILL.md +4 -3
  17. package/pipeline/commands/multi-agent/scenario-audit/SKILL.md +4 -2
  18. package/pipeline/commands/multi-agent/sync/SKILL.md +12 -0
  19. package/pipeline/commands/multi-agent/test-gen/SKILL.md +29 -3
  20. package/pipeline/contract/CHANGELOG.md +46 -0
  21. package/pipeline/contract/README.md +22 -8
  22. package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
  23. package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
  24. package/pipeline/contract/fixtures/error-unsigned.json +1 -1
  25. package/pipeline/contract/fixtures/launch-plan.json +1 -1
  26. package/pipeline/contract/fixtures/runs-awaiting-question.json +6 -1
  27. package/pipeline/contract/fixtures/runs-empty.json +1 -1
  28. package/pipeline/contract/fixtures/runs-failed.json +6 -1
  29. package/pipeline/contract/fixtures/runs-old-schema.json +6 -1
  30. package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +5 -1
  31. package/pipeline/contract/fixtures/runs-pr-opened.json +6 -1
  32. package/pipeline/contract/fixtures/runs-running.json +6 -1
  33. package/pipeline/contract/manifest.json +153 -19
  34. package/pipeline/contract/types/index.d.ts +304 -6
  35. package/pipeline/lib/bg-launch.mjs +106 -0
  36. package/pipeline/lib/code-digest.mjs +49 -0
  37. package/pipeline/lib/credential-resolve.mjs +42 -2
  38. package/pipeline/multi-agent-refs/features/office-threat-model.md +24 -6
  39. package/pipeline/multi-agent-refs/features/unattended-security.md +1 -1
  40. package/pipeline/multi-agent-refs/help/en.md +2 -2
  41. package/pipeline/multi-agent-refs/help/tr.md +2 -2
  42. package/pipeline/multi-agent-refs/phases/modes.md +7 -7
  43. package/pipeline/multi-agent-refs/phases/phase-5-report.md +16 -10
  44. package/pipeline/multi-agent-refs/review-standalone-tracker.md +37 -0
  45. package/pipeline/multi-agent-refs/tracker-contract.md +8 -3
  46. package/pipeline/office/NOTICE.md +4 -1
  47. package/pipeline/office/assets/ARCHIVE_BOXES.png +0 -0
  48. package/pipeline/office/assets/COFFEE_MACHINE.png +0 -0
  49. package/pipeline/office/assets/COUNTER_SINK.png +0 -0
  50. package/pipeline/office/assets/DEVICE_STAND.png +0 -0
  51. package/pipeline/office/assets/FILING_CABINET.png +0 -0
  52. package/pipeline/office/assets/FRIDGE.png +0 -0
  53. package/pipeline/office/assets/MAILBOX.png +0 -0
  54. package/pipeline/office/assets/MOP_BUCKET.png +0 -0
  55. package/pipeline/office/assets/PRINTER.png +0 -0
  56. package/pipeline/office/assets/RECEPTION_DESK.png +0 -0
  57. package/pipeline/office/assets/SERVER_RACK.png +0 -0
  58. package/pipeline/office/assets/SERVER_RACK_2.png +0 -0
  59. package/pipeline/office/assets/TOILET_STALL.png +0 -0
  60. package/pipeline/office/assets/VENDING.png +0 -0
  61. package/pipeline/office/assets/WASHBASIN.png +0 -0
  62. package/pipeline/office/assets/WATER_COOLER.png +0 -0
  63. package/pipeline/office/index.html +3996 -884
  64. package/pipeline/office/toolkit.json +3 -0
  65. package/pipeline/schemas/activity.schema.json +188 -4
  66. package/pipeline/schemas/agent-state.schema.json +493 -115
  67. package/pipeline/schemas/contract-error.schema.json +12 -1
  68. package/pipeline/schemas/job-change.schema.json +72 -0
  69. package/pipeline/schemas/job-request.schema.json +38 -0
  70. package/pipeline/schemas/launch.json +4 -4
  71. package/pipeline/schemas/office-credentials.schema.json +71 -0
  72. package/pipeline/schemas/office-jobs.json +253 -0
  73. package/pipeline/schemas/office-jobs.schema.json +62 -0
  74. package/pipeline/schemas/office-memory.schema.json +105 -0
  75. package/pipeline/schemas/office-settings-request.schema.json +30 -0
  76. package/pipeline/schemas/office-settings.schema.json +67 -0
  77. package/pipeline/schemas/phases.json +10 -2
  78. package/pipeline/schemas/phases.schema.json +86 -19
  79. package/pipeline/schemas/prs.schema.json +5 -0
  80. package/pipeline/schemas/prune-logs-request.schema.json +26 -0
  81. package/pipeline/schemas/prune-logs.schema.json +52 -0
  82. package/pipeline/schemas/repo-change.schema.json +54 -0
  83. package/pipeline/schemas/repo-request.schema.json +29 -0
  84. package/pipeline/schemas/repos.schema.json +49 -1
  85. package/pipeline/schemas/runs-index.schema.json +257 -46
  86. package/pipeline/schemas/system-status.schema.json +111 -0
  87. package/pipeline/schemas/tools.schema.json +5 -0
  88. package/pipeline/schemas/unattended-profile.json +7 -2
  89. package/pipeline/scripts/_active-run.mjs +2 -1
  90. package/pipeline/scripts/_spawn-async.mjs +9 -5
  91. package/pipeline/scripts/activity-snapshot.mjs +219 -23
  92. package/pipeline/scripts/autopilot-runner.mjs +7 -5
  93. package/pipeline/scripts/contract-server.mjs +551 -25
  94. package/pipeline/scripts/gen-mode-dispatch.mjs +3 -1
  95. package/pipeline/scripts/job-launch.mjs +477 -0
  96. package/pipeline/scripts/launch-request.mjs +94 -9
  97. package/pipeline/scripts/office-build.mjs +148 -11
  98. package/pipeline/scripts/office-control.mjs +34 -7
  99. package/pipeline/scripts/office-credentials.mjs +106 -0
  100. package/pipeline/scripts/office-memory.mjs +200 -0
  101. package/pipeline/scripts/office-prune.mjs +125 -0
  102. package/pipeline/scripts/office-settings.mjs +145 -0
  103. package/pipeline/scripts/phase-tracker.sh +12 -5
  104. package/pipeline/scripts/prune-logs.sh +53 -2
  105. package/pipeline/scripts/runs-index.mjs +62 -2
  106. package/pipeline/scripts/system-status.mjs +196 -0
  107. package/pipeline/scripts/task-mirror.mjs +150 -0
  108. package/pipeline/scripts/task-mirror.sh +20 -0
  109. package/pipeline/scripts/toolkit-tools.mjs +33 -7
  110. package/pipeline/scripts/transcript-index.mjs +4 -1
  111. package/pipeline/scripts/work-items.mjs +191 -22
  112. package/pipeline/skills/.skill-manifest.json +6 -6
  113. package/pipeline/skills/.skills-index.json +1 -1
  114. package/pipeline/skills/shared/README.md +1 -1
  115. package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +3 -4
  116. package/pipeline/skills/shared/core/multi-agent-prune-logs/SKILL.md +4 -1
  117. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +19 -0
  118. package/pipeline/skills/shared/core/multi-agent-scenario-audit/SKILL.md +4 -2
  119. package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +26 -3
  120. package/pipeline/skills/skills-index.md +1 -1
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file office-prune.mjs - old run logs, cleared from the restroom.
4
+ *
5
+ * A thin wrapper over prune-logs.sh, the same script /multi-agent:prune-logs
6
+ * runs: its dry run is the preview (which task log folders would go and the
7
+ * space freed). On the office's route (pruneApply) the confirmed step lists
8
+ * the folders again, refuses with code "changed" when they differ from the
9
+ * preview, and then prunes only that list (prune-logs.sh --only-from); the
10
+ * CLI's `apply` prunes the age directly, as /multi-agent:prune-logs does.
11
+ * Everything prune-logs.sh guarantees holds here: the audit trail,
12
+ * the metrics corpus and the task counters are never touched, the log root is
13
+ * refused when it is /, the home folder or a git checkout, a folder written
14
+ * in the last minutes is spared so a live run keeps its log, and so is a run
15
+ * that can still be resumed.
16
+ *
17
+ * The age is one of a fixed set of days; nothing else reaches the script.
18
+ *
19
+ * Usage:
20
+ * office-prune.mjs preview <days> [--json]
21
+ * office-prune.mjs apply <days> [--json]
22
+ *
23
+ * PRUNE_LOG_ROOT overrides the log root, as for prune-logs.sh.
24
+ *
25
+ * Exit: 0 printed, 1 prune-logs.sh refused, 2 usage.
26
+ *
27
+ * @module pipeline/scripts/office-prune
28
+ */
29
+
30
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
31
+ import { tmpdir } from "node:os";
32
+ import { dirname, join } from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+
35
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
36
+ import { spawnAsync } from "./_spawn-async.mjs";
37
+
38
+ export const CONTRACT_VERSION = "1.0.0";
39
+ export const AGES = [7, 30, 90];
40
+ const HERE = dirname(fileURLToPath(import.meta.url));
41
+ const MAX_LISTED = 200;
42
+
43
+ /** What a prune-logs.sh run printed: the folders and the space freed. */
44
+ export function parsePrune(text) {
45
+ const lines = String(text).split("\n");
46
+ const head = lines.find((l) => /would remove|removed/i.test(l)) || "";
47
+ const removed = /removed (\d+)\/\d+ task log dir/.exec(head);
48
+ const n = Number(removed ? removed[1] : ((/(\d+) task log dir/.exec(head) || [])[1] ?? 0));
49
+ const size = /~(\d+(?:\.\d+)?) ?(KB|MB)/.exec(head);
50
+ const mb = size
51
+ ? size[2] === "KB"
52
+ ? Math.round((Number(size[1]) / 1024) * 10) / 10
53
+ : Number(size[1])
54
+ : 0;
55
+ const all = lines
56
+ .map((l) => /^\s{2}(\S(?:.*\S)?)\s*$/.exec(l))
57
+ .filter(Boolean)
58
+ .map((m) => m[1]);
59
+ return { count: n || all.length, freedMb: mb, folders: all.slice(0, MAX_LISTED), all };
60
+ }
61
+
62
+ // The page sees at most MAX_LISTED folders; the confirmation compares the
63
+ // whole list.
64
+ async function run(days, apply, env, { keepAll = false, only = null } = {}) {
65
+ if (!AGES.includes(days)) throw new Error(`age must be one of ${AGES.join(", ")}`);
66
+ const dir = only ? mkdtempSync(join(tmpdir(), "office-prune-")) : null;
67
+ const list = dir ? join(dir, "only.txt") : null;
68
+ if (list) writeFileSync(list, only.join("\n") + "\n", { mode: 0o600 });
69
+ const r = await spawnAsync(
70
+ "bash",
71
+ [
72
+ join(HERE, "prune-logs.sh"),
73
+ `--older-than=${days}`,
74
+ ...(list ? [`--only-from=${list}`] : []),
75
+ ...(apply ? ["--yes"] : []),
76
+ ],
77
+ { env, timeoutMs: 120_000 },
78
+ ).finally(() => dir && rmSync(dir, { recursive: true, force: true }));
79
+ if (r.status !== 0) throw new Error("prune-logs.sh refused (exit " + r.status + ")");
80
+ const { all, ...parsed } = parsePrune(r.stdout);
81
+ return {
82
+ contractVersion: CONTRACT_VERSION,
83
+ olderThanDays: days,
84
+ applied: apply,
85
+ ...parsed,
86
+ ...(keepAll ? { all } : {}),
87
+ };
88
+ }
89
+
90
+ export const prunePreview = (days, env = process.env, opts = {}) => run(days, false, env, opts);
91
+
92
+ /**
93
+ * Prunes only what the preview showed: the folders are listed again and the
94
+ * prune runs only when they are the same set, and then only on that list, so
95
+ * a run that aged into the window or a folder that appeared since is never
96
+ * removed unseen.
97
+ */
98
+ export async function pruneApply(days, env = process.env, expected = null) {
99
+ if (expected && Array.isArray(expected.all)) {
100
+ const now = await run(days, false, env, { keepAll: true });
101
+ const want = new Set(expected.all);
102
+ const same =
103
+ now.count === expected.count &&
104
+ now.all.length === want.size &&
105
+ now.all.every((f) => want.has(f));
106
+ if (!same) {
107
+ const e = new Error("the folders changed since the preview; preview again");
108
+ e.code = "changed";
109
+ throw e;
110
+ }
111
+ return run(days, true, env, { only: expected.all });
112
+ }
113
+ return run(days, true, env);
114
+ }
115
+
116
+ if (invokedDirectly(import.meta.url)) {
117
+ const [cmd, d, flag] = process.argv.slice(2);
118
+ const days = Number(d);
119
+ if (!["preview", "apply"].includes(cmd) || !AGES.includes(days) || (flag && flag !== "--json")) {
120
+ console.error(`usage: office-prune.mjs preview|apply <${AGES.join("|")}> [--json]`);
121
+ process.exit(2);
122
+ }
123
+ const doc = await run(days, cmd === "apply", process.env);
124
+ process.stdout.write(JSON.stringify(doc) + "\n");
125
+ }
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file office-settings.mjs - the three switches the office can flip, read
4
+ * and changed through the same code the terminal commands use.
5
+ *
6
+ * language prefs.global.outputLanguage, en or tr, as /multi-agent:language
7
+ * writes it (promptLanguage forced back to en in the same write),
8
+ * then localize-commands.mjs swaps the installed picker text
9
+ * model the fable rung, through lib/model-rung.sh on|off
10
+ * route model routing, through lib/route-state.sh on|off; `on` keeps
11
+ * the saved configuration, and anything that would widen it (a
12
+ * free provider, new rules) stays in the terminal
13
+ *
14
+ * Every value is one of a fixed set; nothing else reaches a script.
15
+ *
16
+ * Usage:
17
+ * office-settings.mjs [--json]
18
+ * office-settings.mjs set <language|model|route> <value> [--json]
19
+ *
20
+ * MULTI_AGENT_PREFS overrides the preferences file, as for the scripts it calls.
21
+ *
22
+ * Exit: 0 printed or applied, 1 the change failed, 2 usage.
23
+ *
24
+ * @module pipeline/scripts/office-settings
25
+ */
26
+
27
+ import { existsSync } from "node:fs";
28
+ import { homedir } from "node:os";
29
+ import { dirname, join } from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+
32
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
33
+ import { updateJsonFileSync } from "../lib/json-file-lock.mjs";
34
+ import { readRegularJson } from "../lib/regular-file.mjs";
35
+ import { spawnAsync } from "./_spawn-async.mjs";
36
+
37
+ export const CONTRACT_VERSION = "1.0.0";
38
+ export const CHOICES = {
39
+ language: ["en", "tr"],
40
+ model: ["on", "off"],
41
+ route: ["on", "off"],
42
+ };
43
+ const STRATEGIES = ["manual", "task-fit", "cost-ceiling"];
44
+ const HERE = dirname(fileURLToPath(import.meta.url));
45
+
46
+ const prefsPath = (env) =>
47
+ env.MULTI_AGENT_PREFS || join(env.HOME || homedir(), ".claude", "multi-agent-preferences.json");
48
+
49
+ function libScript(name) {
50
+ const p = join(HERE, "..", "lib", name);
51
+ return existsSync(p) ? p : null;
52
+ }
53
+
54
+ /** What the switches are set to now. */
55
+ export function readSettings(env = process.env) {
56
+ const g = readRegularJson(prefsPath(env), {})?.global ?? {};
57
+ return {
58
+ contractVersion: CONTRACT_VERSION,
59
+ language: g.outputLanguage === "tr" ? "tr" : "en",
60
+ model: g.modelFallback?.fableEnabled === true ? "on" : "off",
61
+ route: g.modelRouting?.enabled === true ? "on" : "off",
62
+ };
63
+ }
64
+
65
+ /**
66
+ * The free provider chain a saved routing configuration keeps across `route
67
+ * off`, which `route on` brings back; null when none is saved.
68
+ */
69
+ export function freeChainOf(env = process.env) {
70
+ const free = readRegularJson(prefsPath(env), {})?.global?.modelRouting?.free;
71
+ if (free?.enabled !== true) return null;
72
+ return { enabled: true, allowTraining: free.allowTraining === true };
73
+ }
74
+
75
+ export class SettingsError extends Error {}
76
+
77
+ // route-state.sh on resets the strategy to manual unless told otherwise; the
78
+ // saved one is passed back so `on` changes only the switch.
79
+ function routeArgs(setting, value, env) {
80
+ if (setting !== "route" || value !== "on") return [];
81
+ const saved = readRegularJson(prefsPath(env), {})?.global?.modelRouting?.strategy;
82
+ return [`--strategy=${STRATEGIES.includes(saved) ? saved : "manual"}`];
83
+ }
84
+
85
+ /** Flip one switch to one of its values; returns the settings afterwards. */
86
+ export async function applySetting(setting, value, env = process.env) {
87
+ if (!CHOICES[setting]?.includes(value)) throw new SettingsError(`${setting}: not a choice`);
88
+ if (setting === "language") {
89
+ const path = prefsPath(env);
90
+ if (!existsSync(path)) throw new SettingsError("no preferences file");
91
+ let written = false;
92
+ updateJsonFileSync(path, (cur) => {
93
+ if (!cur || typeof cur !== "object" || Array.isArray(cur)) return undefined;
94
+ written = true;
95
+ const global = { ...(cur.global ?? {}), promptLanguage: "en", outputLanguage: value };
96
+ return { ...cur, global };
97
+ });
98
+ if (!written)
99
+ throw new SettingsError("language: the preferences file is not an object, not written");
100
+ const loc = join(HERE, "localize-commands.mjs");
101
+ if (existsSync(loc)) {
102
+ const r = await spawnAsync(process.execPath, [loc, value === "tr" ? "apply" : "restore"], {
103
+ env,
104
+ timeoutMs: 30_000,
105
+ });
106
+ if (r.status !== 0)
107
+ throw new SettingsError(
108
+ `language: saved, but the command descriptions were not switched (exit ${r.status})`,
109
+ );
110
+ }
111
+ } else {
112
+ const script = libScript(setting === "model" ? "model-rung.sh" : "route-state.sh");
113
+ if (!script) throw new SettingsError(`${setting}: script not installed`);
114
+ const r = await spawnAsync("bash", [script, value, ...routeArgs(setting, value, env)], {
115
+ env,
116
+ timeoutMs: 30_000,
117
+ });
118
+ if (r.status !== 0)
119
+ throw new SettingsError(`${setting}: the script refused (exit ${r.status})`);
120
+ }
121
+ return readSettings(env);
122
+ }
123
+
124
+ if (invokedDirectly(import.meta.url)) {
125
+ const args = process.argv.slice(2).filter((a) => a !== "--json");
126
+ const json = process.argv.includes("--json");
127
+ const out = (doc) =>
128
+ process.stdout.write(
129
+ json
130
+ ? JSON.stringify(doc) + "\n"
131
+ : `language ${doc.language}, model ${doc.model}, route ${doc.route}\n`,
132
+ );
133
+ if (!args.length) out(readSettings());
134
+ else if (args[0] === "set" && args.length === 3) {
135
+ try {
136
+ out(await applySetting(args[1], args[2]));
137
+ } catch (e) {
138
+ console.error(String(e.message));
139
+ process.exit(1);
140
+ }
141
+ } else {
142
+ console.error("usage: office-settings.mjs [set <language|model|route> <value>] [--json]");
143
+ process.exit(2);
144
+ }
145
+ }
@@ -86,7 +86,7 @@ usage:
86
86
  phase-tracker.sh model <phase_id> <model_name>
87
87
  phase-tracker.sh meta <phase_id> <key> <value>
88
88
  phase-tracker.sh now <phase_id> "<text>"
89
- phase-tracker.sh task <phase_id> <task_id> <pending|in_progress|completed> [--subject S] [--active-form A]
89
+ phase-tracker.sh task <phase_id> <task_id> <pending|in_progress|completed|deleted> [--subject S] [--active-form A]
90
90
  phase-tracker.sh cost <phase_id>|total
91
91
  phase-tracker.sh tiles
92
92
  phase-tracker.sh report
@@ -1427,11 +1427,11 @@ EOF
1427
1427
  # client behind it - sees the same list. Quiet like `now`: the host already
1428
1428
  # rendered the change, so this only records it.
1429
1429
  need_jq
1430
- [ "$#" -ge 3 ] || { echo "task needs <phase_id> <task_id> <status> [--subject S] [--active-form A]" >&2; exit 64; }
1430
+ [ "$#" -ge 3 ] || { echo "task needs <phase_id> <task_id> <status|deleted> [--subject S] [--active-form A]" >&2; exit 64; }
1431
1431
  PID="$1"; TID="$2"; TSTATUS="$3"; shift 3
1432
1432
  case "$TSTATUS" in
1433
- pending|in_progress|completed) ;;
1434
- *) echo "task: status must be pending, in_progress or completed" >&2; exit 64 ;;
1433
+ pending|in_progress|completed|deleted) ;;
1434
+ *) echo "task: status must be pending, in_progress, completed or deleted" >&2; exit 64 ;;
1435
1435
  esac
1436
1436
  TSUBJECT=""; HAS_SUBJECT=0; TACTIVE=""; HAS_ACTIVE=0
1437
1437
  while [ "$#" -gt 0 ]; do
@@ -1452,6 +1452,11 @@ EOF
1452
1452
  fi
1453
1453
  EXISTS=$(printf '%s' "$state" | jq -r --arg pid "$PID" --arg tid "$TID" \
1454
1454
  'any(.phases[] | select(.id == $pid) | .tasks[]?; .id == $tid)')
1455
+ # A deleted task leaves the list; one never recorded is nothing to delete.
1456
+ if [ "$TSTATUS" = "deleted" ] && [ "$EXISTS" != "true" ]; then
1457
+ release_state_lock
1458
+ exit 0
1459
+ fi
1455
1460
  if [ "$EXISTS" != "true" ] && [ "$HAS_SUBJECT" -eq 0 ]; then
1456
1461
  release_state_lock
1457
1462
  echo "task: a new task needs --subject" >&2
@@ -1463,7 +1468,9 @@ EOF
1463
1468
  --arg act "$TACTIVE" --argjson ha "$HAS_ACTIVE" '
1464
1469
  .phases |= map(
1465
1470
  if .id == $pid then
1466
- if any(.tasks[]?; .id == $tid) then
1471
+ if $st == "deleted" then
1472
+ .tasks |= map(select(.id != $tid))
1473
+ elif any(.tasks[]?; .id == $tid) then
1467
1474
  .tasks |= map(if .id == $tid then
1468
1475
  .status = $st
1469
1476
  | (if $hs == 1 then .subject = $subj else . end)
@@ -23,6 +23,9 @@
23
23
  # PRUNE_GRACE_MIN minutes (default 10) are spared, so pruning while a
24
24
  # run is still writing its log dir cannot break resume. An explicit
25
25
  # --task=ID bypasses the grace; PRUNE_GRACE_MIN=0 disables it.
26
+ # - resumable runs: a task dir whose agent-state.json status is not
27
+ # finished, abandoned or cancelled is spared however old it is
28
+ # (an unreadable status counts as resumable); --task bypasses this too.
26
29
  #
27
30
  # Usage:
28
31
  # prune-logs.sh # dry-run: every idle task dir
@@ -31,6 +34,8 @@
31
34
  # prune-logs.sh --project=my-ios-app # only one project's task dirs
32
35
  # prune-logs.sh --task=PROJ-123 # only one task dir
33
36
  # prune-logs.sh --older-than=30 --yes
37
+ # prune-logs.sh --only-from=FILE # only the folders FILE lists, one per
38
+ # # line as the dry run prints them
34
39
  #
35
40
  # Env:
36
41
  # PRUNE_LOG_ROOT override the log root (default $HOME/.claude/logs/multi-agent)
@@ -46,6 +51,7 @@ DELETE=0
46
51
  OLDER_DAYS=0
47
52
  PROJECT=""
48
53
  TASK=""
54
+ ONLY_FROM=""
49
55
 
50
56
  usage() {
51
57
  grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'
@@ -82,6 +88,13 @@ for arg in "$@"; do
82
88
  exit 2
83
89
  fi
84
90
  ;;
91
+ --only-from=*)
92
+ ONLY_FROM="${arg#*=}"
93
+ if [ ! -f "$ONLY_FROM" ]; then
94
+ echo "prune-logs: --only-from needs a readable list file" >&2
95
+ exit 2
96
+ fi
97
+ ;;
85
98
  -h | --help)
86
99
  usage
87
100
  exit 0
@@ -132,6 +145,31 @@ is_reserved_name() {
132
145
  return 1
133
146
  }
134
147
 
148
+ # A run that can still be resumed keeps its log however old it is: only a
149
+ # finished, abandoned or cancelled run (or a dir with no recorded status) is
150
+ # pruned; a failed run is one /multi-agent:resume picks up again. --task is
151
+ # an explicit target and bypasses this too.
152
+ run_status() {
153
+ local f="$1/agent-state.json"
154
+ [ -f "$f" ] || return 0
155
+ # A state that cannot be read is treated as resumable ("?"): pruning is
156
+ # the one step here that cannot be undone.
157
+ node -e 'try{const j=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(j&&typeof j==="object"&&typeof j.status==="string"?j.status:"")}catch{process.stdout.write("?")}' "$f" 2>/dev/null || printf '?'
158
+ }
159
+ is_resumable() {
160
+ case "$(run_status "$1")" in
161
+ "" | complete | completed | done | abandoned | cancelled) return 1 ;;
162
+ esac
163
+ return 0
164
+ }
165
+
166
+ # --only-from narrows the match to a confirmed list; every other check still
167
+ # applies, so a listed folder that became active or resumable is spared.
168
+ ONLY_LIST=""
169
+ if [ -n "$ONLY_FROM" ]; then
170
+ ONLY_LIST=$'\n'"$(cat "$ONLY_FROM")"$'\n'
171
+ fi
172
+
135
173
  MTIME_ARGS=()
136
174
  if [ "$OLDER_DAYS" -gt 0 ]; then
137
175
  MTIME_ARGS=(-mtime "+$OLDER_DAYS")
@@ -139,6 +177,7 @@ fi
139
177
 
140
178
  matches=()
141
179
  skipped_active=0
180
+ skipped_resumable=0
142
181
  while IFS= read -r -d '' d; do
143
182
  base="$(basename "$d")"
144
183
  if is_reserved_name "$base"; then continue; fi
@@ -147,6 +186,12 @@ while IFS= read -r -d '' d; do
147
186
  if [ -n "$TASK" ] && [ "$base" != "$TASK" ]; then continue; fi
148
187
  # --project: the immediate parent dir name must equal the project
149
188
  if [ -n "$PROJECT" ] && [ "$(basename "$(dirname "$d")")" != "$PROJECT" ]; then continue; fi
189
+ if [ -n "$ONLY_FROM" ]; then
190
+ case "$ONLY_LIST" in
191
+ *$'\n'"${d#"$LOG_ROOT"/}"$'\n'*) ;;
192
+ *) continue ;;
193
+ esac
194
+ fi
150
195
  # Active-task grace: spare dirs a run touched in the last GRACE_MIN minutes
151
196
  # so a concurrent run keeps a resumable log dir. --task is an explicit,
152
197
  # deliberate target and bypasses the grace.
@@ -155,12 +200,16 @@ while IFS= read -r -d '' d; do
155
200
  skipped_active=$((skipped_active + 1))
156
201
  continue
157
202
  fi
203
+ if [ -z "$TASK" ] && is_resumable "$d"; then
204
+ skipped_resumable=$((skipped_resumable + 1))
205
+ continue
206
+ fi
158
207
  matches+=("$d")
159
208
  done < <(find "$LOG_ROOT" -mindepth 1 -maxdepth 2 -type d ${MTIME_ARGS[@]+"${MTIME_ARGS[@]}"} -print0 2>/dev/null)
160
209
 
161
210
  if [ "${#matches[@]}" -eq 0 ]; then
162
- if [ "$skipped_active" -gt 0 ]; then
163
- echo "prune-logs: only active task dirs matched (touched in the last ${GRACE_MIN}m) - spared, nothing to do"
211
+ if [ "$skipped_active" -gt 0 ] || [ "$skipped_resumable" -gt 0 ]; then
212
+ echo "prune-logs: only active or resumable task dirs matched ($skipped_active touched in the last ${GRACE_MIN}m, $skipped_resumable resumable) - spared, nothing to do"
164
213
  else
165
214
  echo "prune-logs: no task logs matched under $LOG_ROOT - nothing to do"
166
215
  fi
@@ -179,6 +228,7 @@ if [ "$DELETE" -eq 0 ]; then
179
228
  echo "DRY-RUN - would remove ${#matches[@]} task log dir(s) (frees ~${human}):"
180
229
  for d in "${matches[@]}"; do echo " ${d#"$LOG_ROOT"/}"; done
181
230
  [ "$skipped_active" -gt 0 ] && echo "Spared $skipped_active active dir(s) touched in the last ${GRACE_MIN}m."
231
+ [ "$skipped_resumable" -gt 0 ] && echo "Spared $skipped_resumable run(s) that can still be resumed."
182
232
  echo "Audit trail + metrics corpus are preserved. Re-run with --yes to delete."
183
233
  exit 0
184
234
  fi
@@ -199,5 +249,6 @@ for d in "${matches[@]}"; do
199
249
  done
200
250
  spared_note=""
201
251
  [ "$skipped_active" -gt 0 ] && spared_note=", spared $skipped_active active"
252
+ [ "$skipped_resumable" -gt 0 ] && spared_note="${spared_note}, spared $skipped_resumable resumable"
202
253
  echo "══ prune-logs: removed ${removed}/${#matches[@]} task log dir(s)${spared_note}, freed ~${human} (audit + metrics preserved) ══"
203
254
  exit 0
@@ -81,12 +81,12 @@ const PHASE_WAITING_FROM = PHASE_CONTRACT.thresholds.waitingFromPhase;
81
81
  */
82
82
  const LAST_PHASE = PHASE_CONTRACT.phases[PHASE_CONTRACT.phases.length - 1].id;
83
83
 
84
- export const CONTRACT_VERSION = "1.2.0";
84
+ export const CONTRACT_VERSION = "1.3.0";
85
85
 
86
86
  const STATE_MAX_BYTES = 16 * 1024 * 1024;
87
87
 
88
88
  /** The run fields runs-index.schema.json marks x-sensitivity local. */
89
- export const LOCAL_RUN_FIELDS = ["dir", "worktreePath", "prUrl", "duplicateOf"];
89
+ export const LOCAL_RUN_FIELDS = ["dir", "worktreePath", "prUrl", "duplicateOf", "channelUrls"];
90
90
 
91
91
  const GROUPS = {
92
92
  waiting: "Waiting on you",
@@ -226,6 +226,57 @@ function runTypeOf(state, kind) {
226
226
  return "development";
227
227
  }
228
228
 
229
+ const JIRA_KEY = /^[A-Z][A-Z0-9_]{0,30}-[0-9]{1,9}$/;
230
+ const GH_PART = /^[A-Za-z0-9_.-]{1,100}$/;
231
+
232
+ /**
233
+ * Where a run's work came from: its Jira key, its GitHub issue as
234
+ * owner/repo#N, or free text. A GitHub issue that got a Jira carries both.
235
+ */
236
+ export function sourceOf(state) {
237
+ const jira =
238
+ typeof state?.jiraId === "string" && JIRA_KEY.test(state.jiraId) ? state.jiraId : null;
239
+ const g = state?.githubIssue;
240
+ const issue =
241
+ g &&
242
+ GH_PART.test(String(g.owner)) &&
243
+ GH_PART.test(String(g.repo)) &&
244
+ Number.isInteger(g.number) &&
245
+ g.number > 0
246
+ ? `${g.owner}/${g.repo}#${g.number}`
247
+ : null;
248
+ if (issue) return { kind: "github", jira, issue };
249
+ if (jira) return { kind: "jira", jira, issue: null };
250
+ if (state?.inputType === "free-text") return { kind: "text", jira: null, issue: null };
251
+ return null;
252
+ }
253
+
254
+ const CHANNELS = ["pr", "jira", "confluence", "wiki", "issue"];
255
+ const CHANNEL_STATES = new Set(["posted", "skipped", "failed"]);
256
+
257
+ /** Where Phase 5 sent the report: each adapter's outcome, in a fixed order. */
258
+ export function channelsOf(state) {
259
+ const r = state?.channelResults;
260
+ if (!r || typeof r !== "object") return { channels: [], urls: {} };
261
+ const channels = [];
262
+ const urls = {};
263
+ for (const c of CHANNELS) {
264
+ const v = r[c];
265
+ if (!v || !CHANNEL_STATES.has(v.status)) continue;
266
+ channels.push({ channel: c, status: v.status });
267
+ if (typeof v.url === "string" && /^https:\/\/\S+$/.test(v.url)) urls[c] = v.url;
268
+ }
269
+ return { channels, urls };
270
+ }
271
+
272
+ /** The run's PR number, from the state or its PR address; null when neither names one. */
273
+ export function prNumberOf(state, prUrl) {
274
+ const n = state?.pr?.number;
275
+ if (Number.isInteger(n) && n > 0) return n;
276
+ const m = /\/(?:pull|pull-requests)\/(\d{1,9})(?:[/?#]|$)/.exec(String(prUrl ?? ""));
277
+ return m && Number(m[1]) > 0 ? Number(m[1]) : null;
278
+ }
279
+
229
280
  /** The issue summary Phase 0 recorded, else the input the run was launched with. */
230
281
  function titleOf(state) {
231
282
  return textOrNull(state?.title) ?? textOrNull(state?.launchRequest?.input);
@@ -388,6 +439,7 @@ export function buildIndex() {
388
439
  const tracker = readJson(trackerPath);
389
440
  const phases = summarisePhases(tracker);
390
441
  const prUrl = state?.pr?.url ?? state?.prUrl ?? null;
442
+ const prNumber = prNumberOf(state, prUrl);
391
443
  const kind = statePath ? "pipeline" : trackerPath ? "tracker-only" : "empty";
392
444
  const currentPhase = Number.isFinite(Number(state?.currentPhase))
393
445
  ? Number(state.currentPhase)
@@ -443,7 +495,15 @@ export function buildIndex() {
443
495
  // exactly what nobody measured.
444
496
  unlockedWrites: Number.isInteger(tracker?.unlockedWrites) ? tracker.unlockedWrites : 0,
445
497
  runType: runTypeOf(state, kind),
498
+ mode:
499
+ typeof state?.mode === "string" && /^[a-z][a-z-]{0,30}$/.test(state.mode)
500
+ ? state.mode
501
+ : null,
446
502
  title: titleOf(state),
503
+ source: sourceOf(state),
504
+ prNumber,
505
+ channels: channelsOf(state).channels,
506
+ channelUrls: channelsOf(state).urls,
447
507
  progress: runProgress(phases.phases),
448
508
  currentTask: currentTaskOf(phases.phases, currentPhase),
449
509
  // Whether the session the run was launched under is alive, from