@mmerterden/multi-agent-pipeline 20.30.0 → 20.34.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 (195) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +47 -1
  3. package/README.tr.md +61 -1
  4. package/docs/adr/0018-cross-repo-links-not-a-global-graph.md +43 -0
  5. package/docs/adr/README.md +2 -1
  6. package/docs/facts.json +6 -5
  7. package/docs/features.md +1 -1
  8. package/install/_dev-only-files.mjs +1 -0
  9. package/install/_unattended-profile.mjs +61 -0
  10. package/install/catalog-history.json +1 -1
  11. package/install/claude.mjs +16 -2
  12. package/install/index.mjs +10 -1
  13. package/install/templates/claude-hooks.json +23 -1
  14. package/manifest.json +198 -134
  15. package/package.json +4 -1
  16. package/pipeline/agents/code-reviewer.md +9 -0
  17. package/pipeline/agents/explorer.md +4 -0
  18. package/pipeline/agents/plan-critic.md +4 -2
  19. package/pipeline/commands/multi-agent/SKILL.md +17 -136
  20. package/pipeline/commands/multi-agent/analysis/SKILL.md +4 -4
  21. package/pipeline/commands/multi-agent/channels/SKILL.md +15 -297
  22. package/pipeline/commands/multi-agent/graph/SKILL.md +5 -2
  23. package/pipeline/commands/multi-agent/language/SKILL.md +3 -3
  24. package/pipeline/commands/multi-agent/office-on/SKILL.md +8 -7
  25. package/pipeline/commands/multi-agent/office-status/SKILL.md +1 -1
  26. package/pipeline/commands/multi-agent/prune-logs/SKILL.md +4 -1
  27. package/pipeline/commands/multi-agent/resume/SKILL.md +8 -22
  28. package/pipeline/commands/multi-agent/review/SKILL.md +11 -134
  29. package/pipeline/commands/multi-agent/scenario-audit/SKILL.md +4 -2
  30. package/pipeline/commands/multi-agent/setup/SKILL.md +19 -224
  31. package/pipeline/commands/multi-agent/sync/SKILL.md +12 -0
  32. package/pipeline/commands/multi-agent/test-gen/SKILL.md +29 -3
  33. package/pipeline/contract/CHANGELOG.md +46 -0
  34. package/pipeline/contract/README.md +27 -10
  35. package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
  36. package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
  37. package/pipeline/contract/fixtures/error-unsigned.json +1 -1
  38. package/pipeline/contract/fixtures/launch-plan.json +1 -1
  39. package/pipeline/contract/fixtures/runs-awaiting-question.json +6 -1
  40. package/pipeline/contract/fixtures/runs-empty.json +1 -1
  41. package/pipeline/contract/fixtures/runs-failed.json +6 -1
  42. package/pipeline/contract/fixtures/runs-old-schema.json +6 -1
  43. package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +5 -1
  44. package/pipeline/contract/fixtures/runs-pr-opened.json +6 -1
  45. package/pipeline/contract/fixtures/runs-running.json +6 -1
  46. package/pipeline/contract/manifest.json +153 -19
  47. package/pipeline/contract/types/index.d.ts +304 -6
  48. package/pipeline/lib/bg-launch.mjs +106 -0
  49. package/pipeline/lib/code-digest.mjs +49 -0
  50. package/pipeline/lib/credential-resolve.mjs +42 -2
  51. package/pipeline/multi-agent-refs/channels/generator.md +146 -0
  52. package/pipeline/multi-agent-refs/channels/menu.md +55 -0
  53. package/pipeline/multi-agent-refs/channels/multi-repo.md +6 -0
  54. package/pipeline/multi-agent-refs/channels/preview.md +25 -0
  55. package/pipeline/multi-agent-refs/channels/reference.md +98 -0
  56. package/pipeline/multi-agent-refs/features/autopilot-circuit-breaker.md +1 -1
  57. package/pipeline/multi-agent-refs/features/code-graph.md +27 -0
  58. package/pipeline/multi-agent-refs/features/office-threat-model.md +24 -6
  59. package/pipeline/multi-agent-refs/features/plan-critic.md +5 -2
  60. package/pipeline/multi-agent-refs/features/review-delta.md +4 -1
  61. package/pipeline/multi-agent-refs/features/scope-check.md +10 -1
  62. package/pipeline/multi-agent-refs/features/unattended-security.md +1 -1
  63. package/pipeline/multi-agent-refs/help/en.md +2 -2
  64. package/pipeline/multi-agent-refs/help/tr.md +2 -2
  65. package/pipeline/multi-agent-refs/knowledge.md +9 -0
  66. package/pipeline/multi-agent-refs/orchestrator/dispatcher.md +163 -0
  67. package/pipeline/multi-agent-refs/phases/modes.md +7 -7
  68. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +4 -4
  69. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +9 -8
  70. package/pipeline/multi-agent-refs/phases/phase-3-review.md +12 -7
  71. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -3
  72. package/pipeline/multi-agent-refs/phases/phase-5-report.md +19 -10
  73. package/pipeline/multi-agent-refs/picker-contract.md +1 -1
  74. package/pipeline/multi-agent-refs/resume/details.md +35 -0
  75. package/pipeline/multi-agent-refs/review/inputs.md +87 -0
  76. package/pipeline/multi-agent-refs/review/module-guides.md +25 -0
  77. package/pipeline/multi-agent-refs/review/valven.md +42 -0
  78. package/pipeline/multi-agent-refs/review-standalone-tracker.md +37 -0
  79. package/pipeline/multi-agent-refs/setup/accounts.md +27 -0
  80. package/pipeline/multi-agent-refs/setup/app-store-connect.md +5 -0
  81. package/pipeline/multi-agent-refs/setup/firebase.md +5 -0
  82. package/pipeline/multi-agent-refs/setup/google-play.md +4 -0
  83. package/pipeline/multi-agent-refs/setup/hooks-and-plugins.md +21 -0
  84. package/pipeline/multi-agent-refs/setup/language.md +46 -0
  85. package/pipeline/multi-agent-refs/setup/multi-repo-group.md +51 -0
  86. package/pipeline/multi-agent-refs/setup/summaries.md +63 -0
  87. package/pipeline/multi-agent-refs/setup/token-save-flow.md +51 -0
  88. package/pipeline/multi-agent-refs/tracker-contract.md +8 -3
  89. package/pipeline/office/NOTICE.md +4 -1
  90. package/pipeline/office/assets/ARCHIVE_BOXES.png +0 -0
  91. package/pipeline/office/assets/COFFEE_MACHINE.png +0 -0
  92. package/pipeline/office/assets/COUNTER_SINK.png +0 -0
  93. package/pipeline/office/assets/DEVICE_STAND.png +0 -0
  94. package/pipeline/office/assets/FILING_CABINET.png +0 -0
  95. package/pipeline/office/assets/FRIDGE.png +0 -0
  96. package/pipeline/office/assets/MAILBOX.png +0 -0
  97. package/pipeline/office/assets/MOP_BUCKET.png +0 -0
  98. package/pipeline/office/assets/PRINTER.png +0 -0
  99. package/pipeline/office/assets/RECEPTION_DESK.png +0 -0
  100. package/pipeline/office/assets/SERVER_RACK.png +0 -0
  101. package/pipeline/office/assets/SERVER_RACK_2.png +0 -0
  102. package/pipeline/office/assets/TOILET_STALL.png +0 -0
  103. package/pipeline/office/assets/VENDING.png +0 -0
  104. package/pipeline/office/assets/WASHBASIN.png +0 -0
  105. package/pipeline/office/assets/WATER_COOLER.png +0 -0
  106. package/pipeline/office/index.html +3936 -819
  107. package/pipeline/office/toolkit.json +3 -0
  108. package/pipeline/rules/response-shape.md +6 -7
  109. package/pipeline/schemas/activity.schema.json +188 -4
  110. package/pipeline/schemas/agent-state.schema.json +511 -115
  111. package/pipeline/schemas/contract-error.schema.json +12 -1
  112. package/pipeline/schemas/job-change.schema.json +72 -0
  113. package/pipeline/schemas/job-request.schema.json +38 -0
  114. package/pipeline/schemas/launch.json +4 -4
  115. package/pipeline/schemas/office-credentials.schema.json +71 -0
  116. package/pipeline/schemas/office-jobs.json +253 -0
  117. package/pipeline/schemas/office-jobs.schema.json +62 -0
  118. package/pipeline/schemas/office-memory.schema.json +105 -0
  119. package/pipeline/schemas/office-settings-request.schema.json +30 -0
  120. package/pipeline/schemas/office-settings.schema.json +67 -0
  121. package/pipeline/schemas/phases.json +14 -6
  122. package/pipeline/schemas/phases.schema.json +86 -19
  123. package/pipeline/schemas/plan-critique.schema.json +9 -2
  124. package/pipeline/schemas/planning-output.schema.json +25 -2
  125. package/pipeline/schemas/prefs.schema.json +24 -1
  126. package/pipeline/schemas/prs.schema.json +5 -0
  127. package/pipeline/schemas/prune-logs-request.schema.json +26 -0
  128. package/pipeline/schemas/prune-logs.schema.json +52 -0
  129. package/pipeline/schemas/repo-change.schema.json +54 -0
  130. package/pipeline/schemas/repo-request.schema.json +29 -0
  131. package/pipeline/schemas/repos.schema.json +49 -1
  132. package/pipeline/schemas/reviewer-output.schema.json +20 -2
  133. package/pipeline/schemas/runs-index.schema.json +257 -46
  134. package/pipeline/schemas/system-status.schema.json +111 -0
  135. package/pipeline/schemas/token-budget.json +4 -4
  136. package/pipeline/schemas/tools.schema.json +5 -0
  137. package/pipeline/schemas/triage-output.schema.json +20 -2
  138. package/pipeline/schemas/unattended-profile.json +7 -2
  139. package/pipeline/scripts/_active-run.mjs +2 -1
  140. package/pipeline/scripts/_graph-analysis.mjs +270 -0
  141. package/pipeline/scripts/_memory-policy.mjs +165 -0
  142. package/pipeline/scripts/_secret-scan.mjs +105 -0
  143. package/pipeline/scripts/_spawn-async.mjs +9 -5
  144. package/pipeline/scripts/_unified-diff.mjs +2 -2
  145. package/pipeline/scripts/activity-snapshot.mjs +219 -23
  146. package/pipeline/scripts/autopilot-runner.mjs +7 -5
  147. package/pipeline/scripts/capture-flush.sh +12 -2
  148. package/pipeline/scripts/check-read-size.sh +9 -0
  149. package/pipeline/scripts/code-graph-rules/android.json +8 -3
  150. package/pipeline/scripts/contract-server.mjs +575 -28
  151. package/pipeline/scripts/debt-ledger.mjs +323 -0
  152. package/pipeline/scripts/file-context.mjs +261 -0
  153. package/pipeline/scripts/file-context.sh +20 -0
  154. package/pipeline/scripts/gen-mode-dispatch.mjs +3 -1
  155. package/pipeline/scripts/graph-links.mjs +230 -0
  156. package/pipeline/scripts/graph-query.mjs +133 -1
  157. package/pipeline/scripts/graph-report.mjs +65 -0
  158. package/pipeline/scripts/job-launch.mjs +477 -0
  159. package/pipeline/scripts/launch-request.mjs +94 -9
  160. package/pipeline/scripts/learn-from-transcripts.mjs +12 -48
  161. package/pipeline/scripts/learnings-ledger.mjs +143 -24
  162. package/pipeline/scripts/office-build.mjs +148 -11
  163. package/pipeline/scripts/office-control.mjs +34 -7
  164. package/pipeline/scripts/office-credentials.mjs +106 -0
  165. package/pipeline/scripts/office-memory.mjs +200 -0
  166. package/pipeline/scripts/office-prune.mjs +125 -0
  167. package/pipeline/scripts/office-settings.mjs +145 -0
  168. package/pipeline/scripts/phase-tracker.sh +12 -5
  169. package/pipeline/scripts/plan-critique-gate.mjs +11 -2
  170. package/pipeline/scripts/prune-logs.sh +53 -2
  171. package/pipeline/scripts/rework-rulings.mjs +106 -0
  172. package/pipeline/scripts/runs-index.mjs +62 -2
  173. package/pipeline/scripts/scope-check-gate.mjs +149 -2
  174. package/pipeline/scripts/system-status.mjs +196 -0
  175. package/pipeline/scripts/task-mirror.mjs +150 -0
  176. package/pipeline/scripts/task-mirror.sh +20 -0
  177. package/pipeline/scripts/toolkit-tools.mjs +33 -7
  178. package/pipeline/scripts/transcript-index.mjs +4 -1
  179. package/pipeline/scripts/triage-memory.mjs +64 -37
  180. package/pipeline/scripts/validate-planning.mjs +68 -4
  181. package/pipeline/scripts/validate-reviewer.mjs +20 -0
  182. package/pipeline/scripts/validate-triage.mjs +107 -2
  183. package/pipeline/scripts/work-items.mjs +191 -22
  184. package/pipeline/skills/.skill-manifest.json +9 -9
  185. package/pipeline/skills/.skills-index.json +1 -1
  186. package/pipeline/skills/shared/README.md +1 -1
  187. package/pipeline/skills/shared/core/multi-agent-graph/SKILL.md +5 -2
  188. package/pipeline/skills/shared/core/multi-agent-language/SKILL.md +4 -4
  189. package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +3 -4
  190. package/pipeline/skills/shared/core/multi-agent-prune-logs/SKILL.md +4 -1
  191. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +21 -0
  192. package/pipeline/skills/shared/core/multi-agent-scenario-audit/SKILL.md +4 -2
  193. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +4 -4
  194. package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +26 -3
  195. package/pipeline/skills/skills-index.md +1 -1
@@ -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)
@@ -4,7 +4,9 @@
4
4
  * answer per objection, and a verdict decided by constitution rule id.
5
5
  *
6
6
  * At the end of Phase 1 the plan-critic persona writes objections to the plan
7
- * under four lenses (scope, feasibility, security, a cheaper alternative), and
7
+ * under six lenses (scope, feasibility, security, a cheaper alternative, an
8
+ * input the spec implies but no test exercises, a decision left to the
9
+ * implementer), and
8
10
  * the planner answers each one exactly once: accept and revise the plan, or
9
11
  * rebut with evidence. Both halves live in one file,
10
12
  * `<worktree>/.pipeline/plan-critique.json` (schemas/plan-critique.schema.json).
@@ -59,7 +61,14 @@ import { requirementIds } from "./spec-consistency-gate.mjs";
59
61
  import { resolveOne } from "./verify-citations.mjs";
60
62
 
61
63
  export const GATE = "plan-critique";
62
- export const LENSES = ["scope", "feasibility", "security", "alternative"];
64
+ export const LENSES = [
65
+ "scope",
66
+ "feasibility",
67
+ "security",
68
+ "alternative",
69
+ "unstated-input",
70
+ "underspecified",
71
+ ];
63
72
  export const ANCHOR_KINDS = ["file", "plan", "analysis", "inference"];
64
73
  export const ACTIONS = ["accept", "rebut"];
65
74
  const OBJ_ID = /^OBJ-\d{2,3}$/;
@@ -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
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * rework-rulings.mjs - what happens to each open finding when the rework cap
4
+ * is reached.
5
+ *
6
+ * Phase 2 allows a fixed number of rework rounds. At the cap every accepted
7
+ * finding still open gets one ruling, recorded, never dropped:
8
+ *
9
+ * escalate load-bearing: blocking severity, or a security finding
10
+ * (ruleId SEC-* / CON-SEC-*, a security criteriaSource, or the
11
+ * security-auditor's `security` object). The run stops and the
12
+ * user decides.
13
+ * parked anything else. It leaves the loop as a follow-up with its
14
+ * reason, shown before commit (Phase 4 Step 0b), carried in the
15
+ * PR follow-ups and recorded in the debt list, so the run can finish instead of stopping on a
16
+ * finding that is safe to defer.
17
+ *
18
+ * The run escalates when any ruling is `escalate`; otherwise it continues.
19
+ *
20
+ * Usage:
21
+ * rework-rulings.mjs --triage <triage-output.json> [--state <agent-state.json>]
22
+ *
23
+ * --state records the result as reviewIterations[-1].rulings.
24
+ *
25
+ * Output: {escalate, parked, rulings[]} as JSON on stdout.
26
+ * Exit: 0 decided (either way) · 2 usage or unreadable triage · 3 state write failed
27
+ *
28
+ * @module pipeline/scripts/rework-rulings
29
+ */
30
+
31
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
32
+ import { readRegularJson } from "../lib/regular-file.mjs";
33
+ import { updateState } from "./gate-ledger.mjs";
34
+
35
+ const SECURITY_RULE = /^(CON-)?SEC-/i;
36
+
37
+ /** True when a finding may not leave the loop without the user. */
38
+ export function loadBearing(f) {
39
+ if (f?.severity === "blocking") return true;
40
+ if (typeof f?.ruleId === "string" && SECURITY_RULE.test(f.ruleId)) return true;
41
+ if (f?.security && typeof f.security === "object") return true;
42
+ return typeof f?.criteriaSource === "string" && /security/i.test(f.criteriaSource);
43
+ }
44
+
45
+ /** One ruling per accepted finding of a triage output. */
46
+ export function rule(triage) {
47
+ const rulings = (triage?.accepted ?? []).map((f) => {
48
+ const escalate = loadBearing(f);
49
+ return {
50
+ fingerprint: f.fingerprint ?? null,
51
+ file: f.file ?? null,
52
+ line: Number.isInteger(f.line) ? f.line : null,
53
+ severity: f.severity ?? null,
54
+ ruling: escalate ? "escalate" : "parked",
55
+ reason: escalate
56
+ ? f.severity === "blocking"
57
+ ? "blocking finding open at the rework cap"
58
+ : "security finding open at the rework cap"
59
+ : `rework cap reached; ${f.severity ?? "unrated"} finding parked as a follow-up`,
60
+ issue: f.issue ?? "",
61
+ };
62
+ });
63
+ return {
64
+ escalate: rulings.some((r) => r.ruling === "escalate"),
65
+ parked: rulings.filter((r) => r.ruling === "parked").length,
66
+ rulings,
67
+ };
68
+ }
69
+
70
+ function flag(argv, name) {
71
+ const i = argv.indexOf(`--${name}`);
72
+ return i >= 0 ? argv[i + 1] : undefined;
73
+ }
74
+
75
+ if (invokedDirectly(import.meta.url)) {
76
+ const argv = process.argv.slice(2);
77
+ const triagePath = flag(argv, "triage");
78
+ if (!triagePath) {
79
+ console.error("usage: rework-rulings.mjs --triage <path> [--state <path>]");
80
+ process.exit(2);
81
+ }
82
+ const triage = readRegularJson(triagePath, null);
83
+ if (!triage || !Array.isArray(triage.accepted)) {
84
+ console.error(`rework-rulings: no accepted[] in ${triagePath}`);
85
+ process.exit(2);
86
+ }
87
+ const result = rule(triage);
88
+ const statePath = flag(argv, "state");
89
+ if (statePath) {
90
+ try {
91
+ await updateState(statePath, (s) => {
92
+ const iterations = Array.isArray(s.reviewIterations) ? [...s.reviewIterations] : [];
93
+ if (!iterations.length) iterations.push({ iteration: 1 });
94
+ iterations[iterations.length - 1] = {
95
+ ...iterations[iterations.length - 1],
96
+ rulings: result.rulings,
97
+ };
98
+ return { ...s, reviewIterations: iterations };
99
+ });
100
+ } catch (e) {
101
+ console.error(`rework-rulings: state not written: ${e.message}`);
102
+ process.exitCode = 3;
103
+ }
104
+ }
105
+ process.stdout.write(JSON.stringify(result) + "\n");
106
+ }
@@ -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
@@ -10,21 +10,29 @@
10
10
  * Usage:
11
11
  * git diff --name-only <base>...HEAD | node scope-check-gate.mjs --check .pipeline/scope-check.json --diff-files -
12
12
  * node scope-check-gate.mjs --check scope-check.json --diff-files files.txt [--advisory]
13
+ * [--patch <unified diff> | --repo <dir> --patch-range <a>...<b>]
13
14
  *
14
- * Output (stdout, JSON): {ok, justified, unjustified[], unlisted[], notDone}
15
+ * Output (stdout, JSON): {ok, justified, unjustified[], unlisted[], notDone, formatOnly[]}
15
16
  * unjustified = in the diff, missing from files[] (or with an empty reason)
16
17
  * unlisted = in files[], not in the diff (stale entry; reported, never fatal)
18
+ * formatOnly = with --patch, hunks whose removed and added lines differ only
19
+ * in whitespace: a reformat of lines the task did not need to
20
+ * touch. Reported, never fatal. --patch-range reads that diff
21
+ * with git itself, its output format pinned, so a caller needs
22
+ * no file or process substitution for it.
17
23
  *
18
24
  * Exit codes: 0 every diff file justified (or --advisory), 1 otherwise or on
19
25
  * unreadable input, 64 usage.
20
26
  */
21
27
 
28
+ import { execFileSync } from "node:child_process";
22
29
  import { readFileSync } from "node:fs";
23
30
 
24
31
  import { normalizeFile } from "./_fingerprint.mjs";
32
+ import { parseUnifiedDiff } from "./_unified-diff.mjs";
25
33
 
26
34
  const USAGE =
27
- "usage: scope-check-gate.mjs --check <scope-check.json> --diff-files <path|-> [--advisory]";
35
+ "usage: scope-check-gate.mjs --check <scope-check.json> --diff-files <path|-> [--advisory] [--patch <diff> | --repo <dir> --patch-range <a>...<b>]";
28
36
 
29
37
  function parseArgs(argv) {
30
38
  const opts = {};
@@ -33,15 +41,145 @@ function parseArgs(argv) {
33
41
  if (a === "--check") opts.check = argv[++i];
34
42
  else if (a === "--diff-files") opts.diffFiles = argv[++i];
35
43
  else if (a === "--advisory") opts.advisory = true;
44
+ else if (a === "--patch") opts.patch = argv[++i];
45
+ else if (a === "--patch-range") opts.patchRange = argv[++i];
46
+ else if (a === "--repo") opts.repo = argv[++i];
36
47
  else return null;
37
48
  }
49
+ if (opts.patchRange !== undefined && !/^[A-Za-z0-9_./~^@{}-]+$/.test(opts.patchRange))
50
+ return null;
51
+ if (opts.patchRange?.startsWith("-")) return null;
38
52
  return opts.check && opts.diffFiles ? opts : null;
39
53
  }
40
54
 
55
+ /** The diff of a revision range, in the one shape formatOnlyHunks reads. */
56
+ function rangeDiff(repo, range) {
57
+ return execFileSync(
58
+ "git",
59
+ [
60
+ "-C",
61
+ repo || ".",
62
+ "-c",
63
+ "core.quotePath=false",
64
+ "diff",
65
+ "--no-ext-diff",
66
+ "--no-color",
67
+ "--no-textconv",
68
+ "--src-prefix=a/",
69
+ "--dst-prefix=b/",
70
+ range,
71
+ "--",
72
+ ],
73
+ { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, timeout: 30_000 },
74
+ );
75
+ }
76
+
41
77
  function readText(path) {
42
78
  return path === "-" ? readFileSync(0, "utf-8") : readFileSync(path, "utf-8");
43
79
  }
44
80
 
81
+ const INDENT_SENSITIVE = /(\.(py|pyi|ya?ml|coffee|haml|slim|pug)|(^|\/)(GNU)?[Mm]akefile)$/;
82
+
83
+ const LINE_COMMENT = /(\.(py|pyi|ya?ml|sh|bash|zsh|rb|toml|coffee)|(^|\/)(GNU)?[Mm]akefile)$/;
84
+ // Languages where a space next to a bracket, comma, semicolon or colon never
85
+ // carries meaning. YAML, shell, Makefile and CSS are left out on purpose.
86
+ const TIGHT_LANGS =
87
+ /\.(js|jsx|mjs|cjs|ts|tsx|java|go|cs|swift|kt|kts|json|rs|dart|scala|py|pyi|c|h|cc|cpp|hpp|m|mm)$/;
88
+ const LEADING_OPERATOR_LANGS = /\.(kt|kts|swift|go|scala)$/;
89
+ const BREAK_KEEPERS = /\b(return|throw|yield|break|continue)$/;
90
+ const LEADING_OPERATOR = /^[+\-*/%&|^.?]/;
91
+ const TIGHT = /\s*([(){}[\],;:])\s*/g;
92
+
93
+ /** One line with string literals and comments verbatim and every other whitespace run as one space. */
94
+ function foldLine(line, hashComments) {
95
+ const out = [];
96
+ let quote = null;
97
+ let gap = false;
98
+ for (let i = 0; i < line.length; i++) {
99
+ const c = line[i];
100
+ if (quote) {
101
+ out.push(c);
102
+ if (c === "\\") out.push(line[++i] ?? "");
103
+ else if (c === quote) quote = null;
104
+ continue;
105
+ }
106
+ if (c === " " || c === "\t" || c === "\r") {
107
+ gap = true;
108
+ continue;
109
+ }
110
+ if (gap && out.length) out.push(" ");
111
+ gap = false;
112
+ if ((c === "/" && line[i + 1] === "/") || (hashComments && c === "#")) {
113
+ out.push(line.slice(i));
114
+ break;
115
+ }
116
+ if (c === '"' || c === "'" || c === "`") quote = c;
117
+ out.push(c);
118
+ }
119
+ return out.join("");
120
+ }
121
+
122
+ /**
123
+ * The layout-free form of some lines. Each line is folded on its own (linear
124
+ * in the hunk), then the lines are joined by a space, except that a break
125
+ * after return, throw, yield, break or continue, and in Kotlin, Swift, Go or
126
+ * Scala a break before a leading operator, stays a break. Space next to
127
+ * brackets, commas, semicolons and colons is dropped only in TIGHT_LANGS, and
128
+ * never in a hunk that holds a C preprocessor line.
129
+ */
130
+ function outsideStrings(text, file = "") {
131
+ const hashComments = LINE_COMMENT.test(file);
132
+ const lines = text.split("\n").map((l) => foldLine(l, hashComments));
133
+ let joined = "";
134
+ lines.forEach((line, i) => {
135
+ if (i === 0) {
136
+ joined = line;
137
+ return;
138
+ }
139
+ const keepBreak =
140
+ BREAK_KEEPERS.test(lines[i - 1]) ||
141
+ (LEADING_OPERATOR_LANGS.test(file) && LEADING_OPERATOR.test(line));
142
+ joined += (keepBreak ? "\u23ce" : " ") + line;
143
+ });
144
+ const preprocessor = lines.some((l) => l.startsWith("#")) && !hashComments;
145
+ const tight = TIGHT_LANGS.test(file) && !preprocessor;
146
+ return (tight ? joined.replace(TIGHT, "$1") : joined).trim();
147
+ }
148
+
149
+ const indentOf = (line) => /^[ \t]*/.exec(line)[0];
150
+
151
+ /** True when the removed and added lines differ only in layout. */
152
+ function layoutOnly(file, removed, added) {
153
+ if (!removed.length || !added.length) return false;
154
+ if (INDENT_SENSITIVE.test(file)) {
155
+ if (removed.length !== added.length) return false;
156
+ return removed.every(
157
+ (r, i) =>
158
+ indentOf(r.text) === indentOf(added[i].text) &&
159
+ outsideStrings(r.text, file) === outsideStrings(added[i].text, file),
160
+ );
161
+ }
162
+ const joined = (lines) => outsideStrings(lines.map((d) => d.text).join("\n"), file);
163
+ return joined(removed) === joined(added);
164
+ }
165
+
166
+ /**
167
+ * Hunks of a unified diff whose change is layout only: whitespace outside
168
+ * string literals, and in indentation-sensitive files never a leading indent.
169
+ */
170
+ export function formatOnlyHunks(patch) {
171
+ const found = [];
172
+ for (const [file, diff] of parseUnifiedDiff(patch)) {
173
+ for (const h of diff.hunks) {
174
+ if (!h.removed.length && !h.added.length) continue;
175
+ if (layoutOnly(file, h.removed, h.added)) {
176
+ found.push({ file: normalizeFile(file), hunk: h.header });
177
+ }
178
+ }
179
+ }
180
+ return found;
181
+ }
182
+
45
183
  export function evaluate(check, diffFiles) {
46
184
  const files = Array.isArray(check?.files) ? check.files : [];
47
185
  const reasons = new Map();
@@ -81,6 +219,15 @@ function main() {
81
219
  return fail;
82
220
  }
83
221
  const result = evaluate(check, diffFiles);
222
+ if (opts.patch || opts.patchRange) {
223
+ try {
224
+ const patch = opts.patch ? readText(opts.patch) : rangeDiff(opts.repo, opts.patchRange);
225
+ result.formatOnly = formatOnlyHunks(patch);
226
+ } catch (err) {
227
+ result.formatOnly = [];
228
+ result.patchError = err.message;
229
+ }
230
+ }
84
231
  process.stdout.write(JSON.stringify(result) + "\n");
85
232
  return result.ok ? 0 : fail;
86
233
  }