@mmerterden/multi-agent-pipeline 17.6.0 → 18.0.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 (103) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +43 -1
  3. package/README.tr.md +41 -0
  4. package/docs/adr/0011-dormant-ci.md +25 -1
  5. package/docs/server-readiness.md +188 -0
  6. package/index.js +16 -1
  7. package/install/_common.mjs +42 -17
  8. package/install/_dev-only-files.mjs +8 -0
  9. package/install/_unattended-profile.mjs +113 -0
  10. package/install/index.mjs +48 -0
  11. package/manifest.json +1049 -0
  12. package/package.json +5 -2
  13. package/pipeline/commands/multi-agent/status/SKILL.md +52 -21
  14. package/pipeline/lib/_jira-auth.sh +8 -0
  15. package/pipeline/lib/analysis-jira-write.sh +32 -0
  16. package/pipeline/lib/ask-choice.sh +13 -2
  17. package/pipeline/lib/autopilot-state.sh +8 -0
  18. package/pipeline/lib/fatal.mjs +129 -0
  19. package/pipeline/lib/figma-mcp-refresh.sh +18 -0
  20. package/pipeline/lib/figma-screenshot.sh +18 -0
  21. package/pipeline/lib/invoked-directly.mjs +43 -0
  22. package/pipeline/lib/jira-publish.sh +42 -0
  23. package/pipeline/lib/md2confluence-v3.py +47 -0
  24. package/pipeline/lib/outbound-gate.mjs +175 -0
  25. package/pipeline/lib/plan-todos.sh +27 -6
  26. package/pipeline/lib/post-pr-review.sh +77 -8
  27. package/pipeline/lib/repo-hygiene.sh +8 -3
  28. package/pipeline/lib/require-jq.sh +40 -0
  29. package/pipeline/lib/run-paths.sh +335 -0
  30. package/pipeline/multi-agent-refs/features/autopilot-circuit-breaker.md +70 -0
  31. package/pipeline/multi-agent-refs/features/cost-analysis.md +93 -0
  32. package/pipeline/multi-agent-refs/features/doctor.md +45 -0
  33. package/pipeline/multi-agent-refs/features/verify.md +83 -0
  34. package/pipeline/multi-agent-refs/phases/operations.md +13 -2
  35. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  36. package/pipeline/multi-agent-refs/unattended-contract.md +129 -0
  37. package/pipeline/scripts/_run-paths.mjs +372 -0
  38. package/pipeline/scripts/aggregate-metrics.mjs +64 -64
  39. package/pipeline/scripts/autopilot-arming.mjs +2 -1
  40. package/pipeline/scripts/autopilot-intake.mjs +2 -1
  41. package/pipeline/scripts/autopilot-runner.mjs +206 -2
  42. package/pipeline/scripts/build-references.mjs +2 -1
  43. package/pipeline/scripts/build-stack-plugins.mjs +10 -2
  44. package/pipeline/scripts/capture-evidence.sh +7 -2
  45. package/pipeline/scripts/classify-plan-safety.mjs +2 -1
  46. package/pipeline/scripts/cost-analyze.mjs +600 -0
  47. package/pipeline/scripts/cost-budget-check.mjs +4 -12
  48. package/pipeline/scripts/council-view.mjs +2 -1
  49. package/pipeline/scripts/crush-json.mjs +2 -1
  50. package/pipeline/scripts/diff-explain.mjs +6 -9
  51. package/pipeline/scripts/diff-risk-score.mjs +2 -1
  52. package/pipeline/scripts/doctor.mjs +138 -4
  53. package/pipeline/scripts/evidence-gate.mjs +9 -3
  54. package/pipeline/scripts/feedback-send.mjs +12 -2
  55. package/pipeline/scripts/gc-abandoned.sh +29 -13
  56. package/pipeline/scripts/gc-worktrees.sh +11 -4
  57. package/pipeline/scripts/github-ssh-setup.sh +64 -7
  58. package/pipeline/scripts/graph-mermaid.mjs +4 -2
  59. package/pipeline/scripts/keychain-save.sh +101 -30
  60. package/pipeline/scripts/learn-from-transcripts.mjs +2 -1
  61. package/pipeline/scripts/learning-curve.mjs +34 -29
  62. package/pipeline/scripts/make-manifest.mjs +199 -0
  63. package/pipeline/scripts/migrate-prefs.mjs +2 -1
  64. package/pipeline/scripts/migrate-state.mjs +94 -4
  65. package/pipeline/scripts/phase-banner.sh +6 -2
  66. package/pipeline/scripts/phase-tracker.sh +41 -3
  67. package/pipeline/scripts/plan-coverage-gate.mjs +6 -2
  68. package/pipeline/scripts/pre-commit-check.sh +7 -0
  69. package/pipeline/scripts/pre-push-check.sh +7 -0
  70. package/pipeline/scripts/purge.sh +23 -6
  71. package/pipeline/scripts/render-agent-log-cost.sh +9 -2
  72. package/pipeline/scripts/render-cost-summary.sh +9 -2
  73. package/pipeline/scripts/render-work-summary.sh +11 -4
  74. package/pipeline/scripts/review-file-filter.mjs +4 -2
  75. package/pipeline/scripts/review-scope.mjs +2 -1
  76. package/pipeline/scripts/routine-registry.mjs +2 -1
  77. package/pipeline/scripts/run-aggregator.mjs +13 -14
  78. package/pipeline/scripts/run-metrics.mjs +3 -1
  79. package/pipeline/scripts/runs-index.mjs +343 -0
  80. package/pipeline/scripts/scorecard-snapshot.mjs +178 -0
  81. package/pipeline/scripts/search-logs.sh +18 -0
  82. package/pipeline/scripts/test-gap-scan.mjs +2 -1
  83. package/pipeline/scripts/test-integrity-gate.mjs +2 -1
  84. package/pipeline/scripts/update-issue-progress.sh +56 -7
  85. package/pipeline/scripts/usage-report.mjs +12 -1
  86. package/pipeline/scripts/validate-analysis-doc.mjs +2 -1
  87. package/pipeline/scripts/validate-code-graph.mjs +6 -3
  88. package/pipeline/scripts/validate-complaint-doc.mjs +2 -1
  89. package/pipeline/scripts/validate-diff-risk.mjs +6 -3
  90. package/pipeline/scripts/validate-test-gap.mjs +6 -3
  91. package/pipeline/scripts/validate-triage.mjs +3 -1
  92. package/pipeline/scripts/verify-citations.mjs +4 -2
  93. package/pipeline/scripts/verify.mjs +327 -0
  94. package/pipeline/scripts/worktree-finalize.sh +13 -4
  95. package/pipeline/scripts/write-state.mjs +154 -15
  96. package/pipeline/skills/.skill-manifest.json +2 -2
  97. package/pipeline/skills/.skills-index.json +56 -1
  98. package/pipeline/skills/shared/README.md +8 -3
  99. package/pipeline/skills/shared/core/multi-agent-status/SKILL.md +33 -9
  100. package/pipeline/skills/shared/external/macos-spm-app-packaging/assets/templates/package_app.sh +4 -1
  101. package/pipeline/skills/shared/external/macos-spm-app-packaging/assets/templates/setup_dev_signing.sh +4 -1
  102. package/pipeline/skills/shared/external/macos-spm-app-packaging/assets/templates/sign-and-notarize.sh +2 -1
  103. package/pipeline/skills/skills-index.md +6 -1
@@ -369,8 +369,9 @@ function main() {
369
369
  files: TOP ? rows.slice(0, Number(TOP)) : rows,
370
370
  };
371
371
 
372
+ // Returning, not exiting: this payload carries a row per changed file and
373
+ // process.exit() would cut it at the pipe buffer.
372
374
  process.stdout.write(PRETTY ? JSON.stringify(out, null, 2) + "\n" : JSON.stringify(out) + "\n");
373
- process.exit(0);
374
375
  }
375
376
 
376
377
  try {
@@ -53,6 +53,7 @@ import { join, dirname } from "node:path";
53
53
  import { homedir } from "node:os";
54
54
  import { execFileSync } from "node:child_process";
55
55
  import { fileURLToPath, pathToFileURL } from "node:url";
56
+ import { runMain } from "../lib/fatal.mjs";
56
57
 
57
58
  const HERE = dirname(fileURLToPath(import.meta.url));
58
59
  const HOME = homedir();
@@ -63,6 +64,12 @@ const JSON_OUT = argv.includes("--json");
63
64
  const PROBE = argv.includes("--probe");
64
65
  const EXPLAIN = argv.includes("--explain");
65
66
  const LIST = argv.includes("--list-checks");
67
+ // `--profile server` adds checks that only matter when nobody is at the
68
+ // keyboard. They are ADDITIVE: the default run is byte-for-byte what it was,
69
+ // because a laptop being told it fails a server check would teach people to
70
+ // ignore doctor.
71
+ const profileFlag = argv.find((a) => a.startsWith("--profile="));
72
+ const PROFILE = profileFlag ? profileFlag.slice("--profile=".length) : "";
66
73
  const taskFlag = argv.find((a) => a.startsWith("--task-tools="));
67
74
  const TASK_TOOLS = taskFlag ? taskFlag.split("=")[1] : null;
68
75
 
@@ -89,6 +96,15 @@ const CHECK_IDS = [
89
96
  "worktree-residue",
90
97
  ];
91
98
 
99
+ // Only under `--profile server`. Kept in their own list so `--list-checks`
100
+ // can say which ones a default run does not include.
101
+ const SERVER_CHECK_IDS = [
102
+ "unattended-contract",
103
+ "unattended-permissions",
104
+ "scheduler",
105
+ "keychain-unlock",
106
+ ];
107
+
92
108
  // Only these six may return BLOCK. Enforced below, not merely documented: a
93
109
  // check that returns BLOCK without being listed here is downgraded and the
94
110
  // downgrade is reported, because an unenforced rule is a comment.
@@ -782,21 +798,138 @@ function checkWorktreeResidue() {
782
798
 
783
799
  /* ------------------------------------------------------------------ main -- */
784
800
 
801
+ /**
802
+ * Checks that only matter when nobody is at the keyboard.
803
+ *
804
+ * Every one of them is a way a server run stops WITHOUT SAYING SO - the
805
+ * failure mode this whole profile exists for. A laptop hits none of them
806
+ * because a human is there to answer, unlock and restart, which is exactly why
807
+ * they are not in the default run.
808
+ *
809
+ * None may BLOCK: `MAY_BLOCK` is a closed set and these are readiness, not
810
+ * correctness. A machine that fails all four still runs the pipeline fine with
811
+ * someone watching it.
812
+ *
813
+ * @param {{root: string}|any} resolved the install layout from checkInstallPresent
814
+ */
815
+ function checkServerProfile(resolved) {
816
+ const root = resolved?.root || join(homedir(), ".claude");
817
+
818
+ // 1. The contract exists on this machine. Without it nothing tells the
819
+ // operator which entry points honour MULTI_AGENT_UNATTENDED.
820
+ const contract = join(root, "multi-agent-refs", "unattended-contract.md");
821
+ if (existsSync(contract)) {
822
+ ok("unattended-contract");
823
+ } else {
824
+ report(
825
+ "unattended-contract",
826
+ "WARN",
827
+ "unattended-contract.md is not installed, so nothing here documents what an unattended run resolves",
828
+ "run /multi-agent:update to refresh the reference tree",
829
+ );
830
+ }
831
+
832
+ // 2. The permission posture. autopilot spawns `claude --permission-prompts
833
+ // none`, but the tools it then calls still have to be allowed somewhere,
834
+ // and on a fresh machine they are not - the run stops at the first
835
+ // prompt with nobody to answer it.
836
+ const settings = join(root, "settings.json");
837
+ let allow = [];
838
+ try {
839
+ allow = JSON.parse(readFileSync(settings, "utf-8"))?.permissions?.allow ?? [];
840
+ } catch {
841
+ allow = [];
842
+ }
843
+ const needed = ["Bash", "Edit", "Write"];
844
+ const missing = needed.filter((t) => !allow.some((a) => String(a).startsWith(t)));
845
+ if (!missing.length) {
846
+ ok("unattended-permissions");
847
+ } else {
848
+ report(
849
+ "unattended-permissions",
850
+ "WARN",
851
+ `permissions.allow does not cover ${missing.join(", ")}; an unattended run stops at the first prompt`,
852
+ "run /multi-agent:setup, or add them to permissions.allow in settings.json",
853
+ { settings, allow },
854
+ );
855
+ }
856
+
857
+ // 3. Something has to start the work. launchd is the only scheduler this
858
+ // ships a template for; a server with no agent loaded runs nothing and
859
+ // looks idle rather than broken.
860
+ let loaded = false;
861
+ try {
862
+ loaded = execFileSync("launchctl", ["list"], { encoding: "utf-8", timeout: 5000 }).includes(
863
+ "multi-agent",
864
+ );
865
+ } catch {
866
+ // launchctl absent or refusing: treat as "nothing is loaded", which is the
867
+ // conservative read - claiming a scheduler we could not see would be worse.
868
+ }
869
+ if (loaded) {
870
+ ok("scheduler");
871
+ } else {
872
+ report(
873
+ "scheduler",
874
+ "WARN",
875
+ "no multi-agent launchd agent is loaded, so nothing starts a queued run on its own",
876
+ "run /multi-agent:autopilot to install the agent, or schedule the runner yourself",
877
+ );
878
+ }
879
+
880
+ // 4. The keychain. Every credential this pipeline reads lives there, and a
881
+ // LaunchDaemon started before login gets a LOCKED keychain: each fetch
882
+ // fails, and the failures surface far away as 401s that blame the token.
883
+ // Reading a real credential here would be a network call and a side
884
+ // effect; the presence of a login-keychain path is the cheap proxy.
885
+ let keychain = "";
886
+ try {
887
+ keychain = execFileSync("security", ["default-keychain"], {
888
+ encoding: "utf-8",
889
+ timeout: 5000,
890
+ }).trim();
891
+ } catch {
892
+ // Same shape: unreachable reads as absent.
893
+ }
894
+ if (keychain) {
895
+ ok("keychain-unlock");
896
+ } else {
897
+ report(
898
+ "keychain-unlock",
899
+ "WARN",
900
+ "no default keychain is reachable from this session; a boot-time daemon sees a locked one and every credential read fails as a 401",
901
+ "run the LaunchAgent at login rather than a LaunchDaemon at boot (docs/server-readiness.md)",
902
+ );
903
+ }
904
+ }
905
+
785
906
  function main() {
786
907
  if (LIST) {
908
+ // Lists what THIS invocation would run, which is what the name promises
909
+ // and what the registry gate compares against the output of a real run.
910
+ // Listing the server checks unconditionally made `--list-checks` disagree
911
+ // with a default run by four, and the gate that counts printed lines
912
+ // against listed checks caught it immediately.
787
913
  for (const id of CHECK_IDS) process.stdout.write(`${id}\n`);
914
+ if (PROFILE === "server") for (const id of SERVER_CHECK_IDS) process.stdout.write(`${id}\n`);
788
915
  return;
789
916
  }
790
917
  const unknown = argv.filter(
791
- (a) => a.startsWith("--") && !/^--(probe|json|explain|list-checks|task-tools=)/.test(a),
918
+ (a) =>
919
+ a.startsWith("--") && !/^--(probe|json|explain|list-checks|task-tools=|profile=)/.test(a),
792
920
  );
793
921
  if (unknown.length) {
794
922
  process.stderr.write(
795
- `usage: doctor.mjs [--probe] [--json] [--explain] [--task-tools=yes|no] | --list-checks\n`,
923
+ `usage: doctor.mjs [--probe] [--json] [--explain] [--task-tools=yes|no] [--profile=server] | --list-checks\n`,
796
924
  );
797
925
  process.exitCode = 3;
798
926
  return;
799
927
  }
928
+ if (PROFILE && PROFILE !== "server") {
929
+ process.stderr.write(`usage: --profile takes "server" (got "${PROFILE}")\n`);
930
+ process.exitCode = 3;
931
+ return;
932
+ }
800
933
  if (taskFlag && TASK_TOOLS !== "yes" && TASK_TOOLS !== "no") {
801
934
  process.stderr.write("usage: --task-tools takes yes or no\n");
802
935
  process.exitCode = 3;
@@ -837,6 +970,7 @@ function main() {
837
970
  checkMcpSurface();
838
971
  checkDiskSpace();
839
972
  checkWorktreeResidue();
973
+ if (PROFILE === "server") checkServerProfile(resolved);
840
974
 
841
975
  const blocked = results.filter((r) => r.severity === "BLOCK");
842
976
  const warned = results.filter((r) => r.severity === "WARN");
@@ -852,7 +986,7 @@ function main() {
852
986
  // Every check prints, in registry order, including the ones that found
853
987
  // nothing and the ones that were not run. A list that shows only problems
854
988
  // cannot be told apart from a list that was never produced.
855
- for (const id of CHECK_IDS) {
989
+ for (const id of [...CHECK_IDS, ...SERVER_CHECK_IDS]) {
856
990
  const r = results.find((x) => x.id === id);
857
991
  if (!r) continue;
858
992
  if (r.severity === "OK") {
@@ -897,5 +1031,5 @@ function invokedDirectly() {
897
1031
  }
898
1032
 
899
1033
  if (invokedDirectly()) {
900
- main();
1034
+ runMain("doctor", main);
901
1035
  }
@@ -35,7 +35,7 @@
35
35
  // 1 - pass claim is UNVERIFIED (no evidence / empty / shows failure / no success marker) -> caller must NOT record passed
36
36
  // 2 - usage error
37
37
 
38
- import { existsSync, readFileSync, statSync } from "node:fs";
38
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
39
39
 
40
40
  // Failure markers are kept DEFINITIVE (not broad) because failure now overrides
41
41
  // success (see the decision logic): a bare "error:" or stray "FAILED" in an
@@ -103,12 +103,18 @@ function parseArgs(argv) {
103
103
  return out;
104
104
  }
105
105
 
106
+ // Both of these have to stop the run where they are called - the gate's whole
107
+ // contract is "verdict, then nothing else happens" - so process.exit() stays.
108
+ // What changes is the write: console.log to a pipe is asynchronous and exit
109
+ // discards what has not drained, while writeFileSync to fd 1 returns only once
110
+ // the bytes are gone. The verdict is short today; the verdict is also the only
111
+ // thing a caller reads, so losing half of it is the one failure that matters.
106
112
  function fail(msg) {
107
- console.log(JSON.stringify({ ok: false, code: 1, reason: msg }));
113
+ writeFileSync(1, `${JSON.stringify({ ok: false, code: 1, reason: msg })}\n`);
108
114
  process.exit(1);
109
115
  }
110
116
  function ok(msg) {
111
- console.log(JSON.stringify({ ok: true, code: 0, reason: msg }));
117
+ writeFileSync(1, `${JSON.stringify({ ok: true, code: 0, reason: msg })}\n`);
112
118
  process.exit(0);
113
119
  }
114
120
  function usage(msg) {
@@ -138,8 +138,10 @@ async function main() {
138
138
  };
139
139
 
140
140
  if (process.argv.includes("--dry-run")) {
141
+ // return, not process.exit(0): the whole point of --dry-run is to read this
142
+ // payload, and exiting truncates it whenever stdout is a pipe.
141
143
  process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
142
- process.exit(0);
144
+ return;
143
145
  }
144
146
 
145
147
  if (!endpointAllowed(endpoint)) {
@@ -180,4 +182,12 @@ async function main() {
180
182
  }
181
183
  }
182
184
 
183
- main();
185
+ // `main()` is async, and calling it bare leaves any rejection it does not
186
+ // handle itself as an unhandled rejection: Node prints its own stack trace and
187
+ // exits 1, so the user of a feedback command sees a crash dump instead of the
188
+ // one line this file writes for every other failure. The catch keeps the
189
+ // failure in this file's own voice.
190
+ main().catch((err) => {
191
+ process.stderr.write(`ERROR: feedback-send failed - ${err?.message ?? err}\n`);
192
+ process.exit(1);
193
+ });
@@ -64,7 +64,7 @@
64
64
  #
65
65
  # Exit: 0 on success (including "nothing to do"), 2 on usage error.
66
66
 
67
- set -uo pipefail
67
+ set -euo pipefail
68
68
 
69
69
  LOGS="$HOME/.claude/logs/multi-agent"
70
70
  REPOS="$HOME"
@@ -121,20 +121,25 @@ HANDLED=""
121
121
  # sweep that walks one layout leaves the other copy claiming the run is live.
122
122
  while IFS= read -r state; do
123
123
  [ -f "$state" ] || continue
124
- status=$(jq -r '.status // ""' "$state" 2>/dev/null)
124
+ # Every jq read in this loop is forgiven. A malformed state file is exactly
125
+ # what a sweep for abandoned runs expects to meet, and under `set -e` the
126
+ # first one would end the sweep - leaving every run AFTER it untouched and
127
+ # reporting a clean tree. An empty value already means "unknown", and the
128
+ # rules below refuse to delete on unknown.
129
+ status=$(jq -r '.status // ""' "$state" 2>/dev/null || true)
125
130
  # Only runs that CLAIM to be running. A file with no status is unknown, and
126
131
  # unknown is not a licence to delete.
127
132
  case "$status" in in_progress | paused | awaiting_input) ;; *) continue ;; esac
128
133
 
129
- task=$(jq -r '.taskId // .jiraId // ""' "$state" 2>/dev/null)
134
+ task=$(jq -r '.taskId // .jiraId // ""' "$state" 2>/dev/null || true)
130
135
  [ -n "$task" ] || continue
131
- phase=$(jq -r '.currentPhase // "?"' "$state" 2>/dev/null)
136
+ phase=$(jq -r '.currentPhase // "?"' "$state" 2>/dev/null || true)
132
137
  # `.pr` is an object in the schema and a bare URL string in three state files
133
138
  # on disk. `jq '.pr.url'` on a string errors, and with stderr suppressed that
134
139
  # reads as "no PR" - so a run whose PR is already open would be classed as dead
135
140
  # and reaped. Read the shape, do not assume it.
136
- pr=$(jq -r 'if (.pr|type)=="string" then .pr elif (.pr|type)=="object" then (.pr.url // .pr.number // "") else "" end | tostring' "$state" 2>/dev/null)
137
- wt=$(jq -r '.worktreePath // ""' "$state" 2>/dev/null)
141
+ pr=$(jq -r 'if (.pr|type)=="string" then .pr elif (.pr|type)=="object" then (.pr.url // .pr.number // "") else "" end | tostring' "$state" 2>/dev/null || true)
142
+ wt=$(jq -r '.worktreePath // ""' "$state" 2>/dev/null || true)
138
143
 
139
144
  # Rule 1: waiting for you is not residue.
140
145
  if [ "$status" = "awaiting_input" ] || [ -n "$pr" ] || [ "$phase" = "6" ] || [ "$phase" = "7" ]; then
@@ -142,7 +147,8 @@ while IFS= read -r state; do
142
147
  continue
143
148
  fi
144
149
 
145
- mtime=$(_mtime "$state")
150
+ mtime=$(_mtime "$state" || true)
151
+ [ -n "$mtime" ] || continue
146
152
  age_days=$(((NOW - mtime) / 86400))
147
153
  if [ "$phase" = "0" ]; then
148
154
  limit="$PHASE0_DAYS"; kind="left at a Phase 0 question"
@@ -155,7 +161,8 @@ while IFS= read -r state; do
155
161
  # root recorded in worktreePath is the shape that would delete a checkout.
156
162
  safe_wt=""
157
163
  if [ -n "$wt" ] && [ -d "$wt" ] && [ ! -L "$wt" ]; then
158
- real=$(cd "$wt" 2>/dev/null && pwd -P)
164
+ # A worktree path that no longer resolves is the ordinary case here.
165
+ real=$(cd "$wt" 2>/dev/null && pwd -P || true)
159
166
  case "$real" in
160
167
  */.worktrees/*) safe_wt="$real" ;;
161
168
  *) skipped_unsafe=$((skipped_unsafe + 1)) ;;
@@ -187,8 +194,11 @@ $safe_wt"
187
194
  fi
188
195
 
189
196
  run_dir=$(dirname "$state")
190
- mkdir -p "$run_dir/artifacts" 2>/dev/null
191
- cp "$state" "$run_dir/artifacts/agent-state.json" 2>/dev/null
197
+ # Forgiven, both of them: this is the salvage copy, and a run whose artefacts
198
+ # cannot be copied is still a run that has to be marked abandoned. Under
199
+ # `set -e` an unreadable state file here would end the entire sweep.
200
+ mkdir -p "$run_dir/artifacts" 2>/dev/null || true
201
+ cp "$state" "$run_dir/artifacts/agent-state.json" 2>/dev/null || true
192
202
  tmp="$state.tmp.$$"
193
203
  if jq --arg r "abandoned by gc-abandoned after ${age_days}d idle" \
194
204
  '.status = "failed" | .haltReason = $r' "$state" > "$tmp" 2>/dev/null; then
@@ -209,7 +219,8 @@ $safe_wt"
209
219
  if [ -n "$safe_wt" ]; then
210
220
  repo=${safe_wt%%/.worktrees/*}
211
221
  git -C "$repo" worktree remove --force "$safe_wt" >/dev/null 2>&1 || rm -rf "$safe_wt"
212
- git -C "$repo" worktree prune >/dev/null 2>&1
222
+ # Forgiven: one flaky prune must not end the sweep before its summary line.
223
+ git -C "$repo" worktree prune >/dev/null 2>&1 || true
213
224
  printf '→ removed: %s (%s, idle %sd)\n' "$task" "$human" "$age_days"
214
225
  freed_kb=$((freed_kb + ${size_kb:-0}))
215
226
  else
@@ -233,7 +244,11 @@ if [ "$STATE_ONLY" -eq 0 ] && [ -d "$REPOS" ]; then
233
244
  # re-walk of the log tree for each of 28 directories.
234
245
  index=$(find "$LOGS" -name agent-state.json -type f -maxdepth 4 -not -path '*/artifacts/*' 2>/dev/null |
235
246
  while IFS= read -r f; do
236
- jq -r '[(.worktreePath // ""), (.taskId // .jiraId // ""), (.status // ""), (if (.pr|type)=="string" then .pr elif (.pr|type)=="object" then (.pr.url // .pr.number // "") else "" end | tostring), ((.currentPhase // "") | tostring)] | @tsv' "$f" 2>/dev/null
247
+ # One malformed state file must not truncate the index: under `set -e`
248
+ # a jq failure inside this subshell ends the loop, and the entries after
249
+ # it would silently not exist - which reads as "no worktree matches" and
250
+ # protects nothing.
251
+ jq -r '[(.worktreePath // ""), (.taskId // .jiraId // ""), (.status // ""), (if (.pr|type)=="string" then .pr elif (.pr|type)=="object" then (.pr.url // .pr.number // "") else "" end | tostring), ((.currentPhase // "") | tostring)] | @tsv' "$f" 2>/dev/null || true
237
252
  done)
238
253
 
239
254
  for wtroot in "$REPOS"/*/.worktrees; do
@@ -322,7 +337,8 @@ if [ "$STATE_ONLY" -eq 0 ] && [ -d "$REPOS" ]; then
322
337
  continue
323
338
  fi
324
339
  git -C "$repo" worktree remove --force "$real" >/dev/null 2>&1 || rm -rf "$real"
325
- git -C "$repo" worktree prune >/dev/null 2>&1
340
+ # Same reason as the first pass: a failed prune is not a reason to stop.
341
+ git -C "$repo" worktree prune >/dev/null 2>&1 || true
326
342
  printf '→ removed worktree: %s (%s, %s, idle %sd)\n' "$id" "$why" "$human" "$age_days"
327
343
  freed_kb=$((freed_kb + ${size_kb:-0}))
328
344
  removed=$((removed + 1))
@@ -34,7 +34,7 @@
34
34
  #
35
35
  # Exit: 0 on success (including "nothing to do"), 2 on usage error.
36
36
 
37
- set -uo pipefail
37
+ set -euo pipefail
38
38
 
39
39
  REPO="$PWD"
40
40
  DELETE=0
@@ -69,7 +69,9 @@ if ! git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
69
69
  echo "gc-worktrees: $REPO is not a git work tree - nothing to do"
70
70
  exit 0
71
71
  fi
72
- REPO="$(cd "$REPO" && pwd -P)"
72
+ # A repo path that cannot be entered is a usage error with a message, not a
73
+ # silent exit 1 three lines before the message exists.
74
+ REPO="$(cd "$REPO" && pwd -P)" || { echo "gc-worktrees: cannot enter $REPO" >&2; exit 2; }
73
75
  # Anchor on the MAIN worktree (first porcelain entry) so a run started from a
74
76
  # subdirectory OR from inside a linked worktree still targets
75
77
  # <main-repo>/.worktrees/ rather than a bogus <subdir>/.worktrees/ (which
@@ -107,7 +109,9 @@ EOF
107
109
  for d in "$WT_ROOT"/*/; do
108
110
  [ -d "$d" ] || continue
109
111
  [ -L "${d%/}" ] && continue # never follow a symlinked entry out of the tree
110
- dir="$(cd "$d" && pwd -P)"
112
+ # A directory that vanished between the glob and this line is ordinary on a
113
+ # machine where runs are finishing; skipping it is the answer, not aborting.
114
+ dir="$(cd "$d" && pwd -P)" || continue
111
115
  # Path guard: only ever consider dirs strictly inside <repo>/.worktrees/.
112
116
  case "$dir" in "$WT_ROOT"/*) ;; *) continue ;; esac
113
117
  if printf '%s\n' "$registered" | grep -qxF "$dir"; then
@@ -120,7 +124,10 @@ EOF
120
124
  done
121
125
  fi
122
126
  for dir in ${orphans+"${orphans[@]}"}; do
123
- size=$(du -sh "$dir" 2>/dev/null | cut -f1)
127
+ # Cosmetic: the size goes in a report line and prints as "?" when du cannot
128
+ # read it. Under `set -e` with pipefail an unreadable directory would end the
129
+ # sweep instead.
130
+ size=$(du -sh "$dir" 2>/dev/null | cut -f1 || true)
124
131
  if [ "$DELETE" -eq 1 ]; then
125
132
  rm -rf "$dir" && { echo "→ removed orphan worktree dir: ${dir#"$REPO"/} (${size:-?})"; changed=$((changed + 1)); }
126
133
  else
@@ -1,16 +1,60 @@
1
1
  #!/bin/bash
2
2
  # GitHub SSH Key Oluşturma ve Kurulum Scripti
3
+ #
4
+ # HEADLESS: her soru bir env değişkeniyle önceden cevaplanabilir ve terminal
5
+ # yoksa script SORMAZ. Üç `read -p` koruma olmadan duruyordu; bir launchd
6
+ # ajanının ya da uzak bir kabuğun altında ilki sonsuza kadar bekler ve dışarı
7
+ # hiçbir şey yazmaz, yani kurulum "asılı kaldı" diye görünür. Bir sunucuda
8
+ # çalışması hedeflenen bir aracın sessizce beklemesi, açıkça reddetmesinden
9
+ # kötüdür.
10
+ #
11
+ # MULTI_AGENT_UNATTENDED=1 terminal olsa bile soru sorulmaz
12
+ # SSH_SETUP_EMAIL anahtarın yorum alanına yazılacak e-posta (zorunlu)
13
+ # SSH_SETUP_OVERWRITE e|h - var olan anahtarın üzerine yazılsın mı (varsayılan h)
14
+ # SSH_SETUP_TEST e|h - sonunda github.com'a bağlantı denensin mi (varsayılan h)
3
15
 
4
16
  set -euo pipefail
5
17
 
18
+ # Soruyu sor, ama yalnız soracak birisi varsa. Terminal yoksa env'deki cevabı
19
+ # kullan; o da yoksa boş dön ve kararı çağırana bırak.
20
+ ask() {
21
+ local prompt="$1" preset="$2" __var="$3" reply=""
22
+ if [ -n "$preset" ]; then
23
+ printf '%s%s [env]\n' "$prompt" "$preset"
24
+ printf -v "$__var" '%s' "$preset"
25
+ return 0
26
+ fi
27
+ # "Terminal yok" başsızlığın olağan biçimi, tek biçimi değil: screen,
28
+ # tmux ya da bir sunucudaki login kabuğunun terminali VARDIR ve önünde
29
+ # kimse yoktur. MULTI_AGENT_UNATTENDED=1 operatörün bunu açıkça söylemesi;
30
+ # refs/unattended-contract.md. Tanımsızken hiçbir şey değişmez.
31
+ if [ ! -t 0 ] || [ "${MULTI_AGENT_UNATTENDED:-}" = "1" ]; then
32
+ printf -v "$__var" '%s' ""
33
+ return 0
34
+ fi
35
+ # EOF (Ctrl-D) is an answer, not a crash: without `|| true` `set -e`
36
+ # turns a user pressing Ctrl-D into an exit with no message at all.
37
+ read -r -p "$prompt" reply || true
38
+ printf -v "$__var" '%s' "$reply"
39
+ }
40
+
6
41
  echo "=== GitHub SSH Key Kurulumu ==="
7
42
  echo ""
8
43
 
9
44
  # E-posta adresi al
10
- read -p "GitHub e-posta adresinizi girin: " EMAIL
45
+ ask "GitHub e-posta adresinizi girin: " "${SSH_SETUP_EMAIL:-}" EMAIL
11
46
 
12
47
  if [ -z "$EMAIL" ]; then
13
- echo "Hata: E-posta adresi boş olamaz."
48
+ if [ -t 0 ] && [ "${MULTI_AGENT_UNATTENDED:-}" != "1" ]; then
49
+ echo "Hata: E-posta adresi boş olamaz."
50
+ else
51
+ if [ "${MULTI_AGENT_UNATTENDED:-}" = "1" ]; then
52
+ echo "Hata: MULTI_AGENT_UNATTENDED=1 ve SSH_SETUP_EMAIL tanımlı değil." >&2
53
+ else
54
+ echo "Hata: terminal yok ve SSH_SETUP_EMAIL tanımlı değil." >&2
55
+ fi
56
+ echo " Başsız çalıştırmak için: SSH_SETUP_EMAIL=you@example.com $0" >&2
57
+ fi
14
58
  exit 1
15
59
  fi
16
60
 
@@ -26,7 +70,9 @@ chmod 700 "$HOME/.ssh"
26
70
  if [ -f "$KEY_PATH" ]; then
27
71
  echo ""
28
72
  echo "Uyari: $KEY_PATH zaten mevcut."
29
- read -p "Üzerine yazmak istiyor musunuz? (e/h): " OVERWRITE
73
+ # Varsayılan HAYIR. Başsız bir koşuda cevapsız kalan bir "üzerine yazayım
74
+ # mı" sorusunun güvenli tarafı, var olan anahtarı korumaktır.
75
+ ask "Üzerine yazmak istiyor musunuz? (e/h): " "${SSH_SETUP_OVERWRITE:-}" OVERWRITE
30
76
  if [ "$OVERWRITE" != "e" ]; then
31
77
  echo "İşlem iptal edildi."
32
78
  exit 0
@@ -36,7 +82,11 @@ fi
36
82
  # SSH key oluştur (Ed25519 - modern ve güvenli)
37
83
  echo ""
38
84
  echo "SSH key oluşturuluyor..."
39
- ssh-keygen -t ed25519 -C "$EMAIL" -f "$KEY_PATH" -N ""
85
+ # ssh-keygen var olan bir dosyayı görünce KENDİ "Overwrite (y/n)?" sorusunu
86
+ # sorar - yani yukarıdaki onayı geçtikten sonra başsız koşu ikinci bir
87
+ # soruda asılır. Onay zaten alındı; dosyayı önce kaldır.
88
+ rm -f "$KEY_PATH" "${KEY_PATH}.pub"
89
+ ssh-keygen -t ed25519 -C "$EMAIL" -f "$KEY_PATH" -N "" -q
40
90
 
41
91
  echo ""
42
92
  echo "SSH key başarıyla oluşturuldu!"
@@ -66,7 +116,12 @@ EOF
66
116
  fi
67
117
 
68
118
  # Key'i agent'a ekle (macOS Keychain ile)
69
- ssh-add --apple-use-keychain "$KEY_PATH" 2>/dev/null || ssh-add "$KEY_PATH"
119
+ # Agent yoksa ekleme başarısız olur; anahtar yazıldıktan sonra bunun için
120
+ # çıkmak, kurulumu yarıda bırakıp kullanıcıya hiçbir sonraki adımı
121
+ # göstermemek demek.
122
+ ssh-add --apple-use-keychain "$KEY_PATH" 2>/dev/null \
123
+ || ssh-add "$KEY_PATH" 2>/dev/null \
124
+ || echo "[Uyari: anahtar ssh-agent'a eklenemedi - elle: ssh-add $KEY_PATH]"
70
125
 
71
126
  # Public key'i göster ve kopyala
72
127
  echo ""
@@ -97,11 +152,13 @@ echo "4. 'Add SSH key' butonuna tıklayın"
97
152
  echo ""
98
153
 
99
154
  # GitHub'a key eklendikten sonra test
100
- read -p "Key'i GitHub'a ekledikten sonra test etmek ister misiniz? (e/h): " TEST
155
+ ask "Key'i GitHub'a ekledikten sonra test etmek ister misiniz? (e/h): " "${SSH_SETUP_TEST:-}" TEST
101
156
  if [ "$TEST" = "e" ]; then
102
157
  echo ""
103
158
  echo "GitHub bağlantısı test ediliyor..."
104
- ssh -T git@github.com 2>&1 || true
159
+ # Bilinmeyen host anahtarı üçüncü bir interaktif soru demek; başsız
160
+ # koşuda orada asılırdı.
161
+ ssh -o StrictHostKeyChecking=accept-new -T git@github.com 2>&1 || true
105
162
  fi
106
163
 
107
164
  echo ""
@@ -41,6 +41,8 @@ import { resolve } from "node:path";
41
41
  import { parseFlags } from "./graph-build.mjs";
42
42
  import { loadGraph, defaultGraphPath } from "./graph-query.mjs";
43
43
  import { findByName, affected } from "./graph-affected.mjs";
44
+ import { runMain } from "../lib/fatal.mjs";
45
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
44
46
 
45
47
  const DEFAULT_MAX_NODES = 25;
46
48
 
@@ -246,6 +248,6 @@ function main() {
246
248
  }
247
249
  }
248
250
 
249
- if (import.meta.url === `file://${process.argv[1]}`) {
250
- main();
251
+ if (invokedDirectly(import.meta.url)) {
252
+ runMain("graph-mermaid", main);
251
253
  }