agent-control-plane 0.1.12 → 0.1.13

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.
@@ -15,6 +15,8 @@ FLOW_TOOLS_DIR="${FLOW_SKILL_DIR}/tools/bin"
15
15
  DETACHED_LAUNCH_BIN="${FLOW_TOOLS_DIR}/agent-project-detached-launch"
16
16
  RESIDENT_ISSUE_LOOP_BIN="${FLOW_TOOLS_DIR}/start-resident-issue-loop.sh"
17
17
  REPO_SLUG="$(flow_resolve_repo_slug "${CONFIG_YAML}")"
18
+ AGENT_REPO_ROOT="$(flow_resolve_agent_repo_root "${CONFIG_YAML}")"
19
+ DEFAULT_BRANCH="$(flow_resolve_default_branch "${CONFIG_YAML}")"
18
20
  STATE_ROOT="$(flow_resolve_state_root "${CONFIG_YAML}")"
19
21
  PENDING_LAUNCH_DIR="${ACP_PENDING_LAUNCH_DIR:-${F_LOSNING_PENDING_LAUNCH_DIR:-${STATE_ROOT}/pending-launches}}"
20
22
  AGENT_PR_PREFIXES_JSON="$(flow_managed_pr_prefixes_json "${CONFIG_YAML}")"
@@ -24,6 +26,51 @@ AGENT_EXCLUSIVE_LABEL="${AGENT_EXCLUSIVE_LABEL:-agent-exclusive}"
24
26
  CODING_WORKER="${ACP_CODING_WORKER:-${F_LOSNING_CODING_WORKER:-codex}}"
25
27
  HEARTBEAT_ISSUE_JSON_CACHE_DIR="${TMPDIR:-/tmp}/heartbeat-issue-json.$$"
26
28
 
29
+ heartbeat_issue_retry_state_file() {
30
+ local issue_id="${1:?issue id required}"
31
+ printf '%s/retries/issues/%s.env\n' "${STATE_ROOT}" "${issue_id}"
32
+ }
33
+
34
+ heartbeat_reason_requires_baseline_change() {
35
+ local reason="${1:-}"
36
+ case "${reason}" in
37
+ verification-guard-blocked|no-publishable-commits|no-publishable-delta)
38
+ return 0
39
+ ;;
40
+ *)
41
+ return 1
42
+ ;;
43
+ esac
44
+ }
45
+
46
+ heartbeat_current_baseline_head_sha() {
47
+ local head_sha=""
48
+ if [[ -d "${AGENT_REPO_ROOT}" ]]; then
49
+ head_sha="$(git -C "${AGENT_REPO_ROOT}" rev-parse --verify --quiet "origin/${DEFAULT_BRANCH}" 2>/dev/null || true)"
50
+ if [[ -z "${head_sha}" ]]; then
51
+ head_sha="$(git -C "${AGENT_REPO_ROOT}" rev-parse --verify --quiet "${DEFAULT_BRANCH}" 2>/dev/null || true)"
52
+ fi
53
+ fi
54
+ printf '%s\n' "${head_sha}"
55
+ }
56
+
57
+ heartbeat_retry_reason_is_baseline_blocked() {
58
+ local issue_id="${1:?issue id required}"
59
+ local reason="${2:-}"
60
+ local state_file baseline_head current_head
61
+
62
+ heartbeat_reason_requires_baseline_change "${reason}" || return 1
63
+ state_file="$(heartbeat_issue_retry_state_file "${issue_id}")"
64
+ [[ -f "${state_file}" ]] || return 1
65
+
66
+ baseline_head="$(awk -F= '/^BASELINE_HEAD_SHA=/{print substr($0, index($0, "=") + 1); exit}' "${state_file}" 2>/dev/null | tr -d '\r' || true)"
67
+ [[ -n "${baseline_head}" ]] || return 1
68
+ current_head="$(heartbeat_current_baseline_head_sha)"
69
+ [[ -n "${current_head}" ]] || return 1
70
+
71
+ [[ "${baseline_head}" == "${current_head}" ]]
72
+ }
73
+
27
74
  heartbeat_issue_json_cached() {
28
75
  local issue_id="${1:?issue id required}"
29
76
  local cache_file=""
@@ -152,6 +199,9 @@ heartbeat_issue_blocked_recovery_reason() {
152
199
  retry_out="$("${FLOW_TOOLS_DIR}/retry-state.sh" issue "$issue_id" get 2>/dev/null || true)"
153
200
  retry_reason="$(awk -F= '/^LAST_REASON=/{print $2}' <<<"${retry_out:-}")"
154
201
  if [[ -n "${retry_reason:-}" && "${retry_reason}" != "issue-worker-blocked" ]]; then
202
+ if heartbeat_retry_reason_is_baseline_blocked "${issue_id}" "${retry_reason}"; then
203
+ return 0
204
+ fi
155
205
  printf '%s\n' "$retry_reason"
156
206
  return 0
157
207
  fi
@@ -12,7 +12,9 @@ ADAPTER_BIN_DIR="${FLOW_SKILL_DIR}/bin"
12
12
  FLOW_TOOLS_DIR="${FLOW_SKILL_DIR}/tools/bin"
13
13
  REPO_SLUG="$(flow_resolve_repo_slug "${CONFIG_YAML}")"
14
14
  AGENT_ROOT="$(flow_resolve_agent_root "${CONFIG_YAML}")"
15
+ AGENT_REPO_ROOT="$(flow_resolve_agent_repo_root "${CONFIG_YAML}")"
15
16
  STATE_ROOT="$(flow_resolve_state_root "${CONFIG_YAML}")"
17
+ DEFAULT_BRANCH="$(flow_resolve_default_branch "${CONFIG_YAML}")"
16
18
  RUNS_ROOT="$(flow_resolve_runs_root "${CONFIG_YAML}")"
17
19
  BLOCKED_RECOVERY_STATE_DIR="${STATE_ROOT}/blocked-recovery-issues"
18
20
 
@@ -26,6 +28,49 @@ issue_clear_blocked_recovery_state() {
26
28
  rm -f "${BLOCKED_RECOVERY_STATE_DIR}/${ISSUE_ID}.env" 2>/dev/null || true
27
29
  }
28
30
 
31
+ issue_retry_state_file() {
32
+ printf '%s/retries/issues/%s.env\n' "${STATE_ROOT}" "${ISSUE_ID}"
33
+ }
34
+
35
+ issue_reason_requires_baseline_change() {
36
+ local reason="${1:-}"
37
+ case "${reason}" in
38
+ verification-guard-blocked|no-publishable-commits|no-publishable-delta)
39
+ return 0
40
+ ;;
41
+ *)
42
+ return 1
43
+ ;;
44
+ esac
45
+ }
46
+
47
+ issue_current_baseline_head_sha() {
48
+ local head_sha=""
49
+ if [[ -d "${AGENT_REPO_ROOT}" ]]; then
50
+ head_sha="$(git -C "${AGENT_REPO_ROOT}" rev-parse --verify --quiet "origin/${DEFAULT_BRANCH}" 2>/dev/null || true)"
51
+ if [[ -z "${head_sha}" ]]; then
52
+ head_sha="$(git -C "${AGENT_REPO_ROOT}" rev-parse --verify --quiet "${DEFAULT_BRANCH}" 2>/dev/null || true)"
53
+ fi
54
+ fi
55
+ printf '%s\n' "${head_sha}"
56
+ }
57
+
58
+ issue_record_retry_baseline_gate() {
59
+ local reason="${1:-}"
60
+ local state_file head_sha tmp_file
61
+
62
+ issue_reason_requires_baseline_change "${reason}" || return 0
63
+ state_file="$(issue_retry_state_file)"
64
+ [[ -f "${state_file}" ]] || return 0
65
+ head_sha="$(issue_current_baseline_head_sha)"
66
+ [[ -n "${head_sha}" ]] || return 0
67
+
68
+ tmp_file="$(mktemp)"
69
+ grep -v '^BASELINE_HEAD_SHA=' "${state_file}" >"${tmp_file}" || true
70
+ printf 'BASELINE_HEAD_SHA=%s\n' "${head_sha}" >>"${tmp_file}"
71
+ mv "${tmp_file}" "${state_file}"
72
+ }
73
+
29
74
  issue_has_schedule_cadence() {
30
75
  local issue_json issue_body
31
76
  issue_json="$(flow_github_issue_view_json "${REPO_SLUG}" "${ISSUE_ID}" 2>/dev/null || true)"
@@ -155,6 +200,7 @@ issue_schedule_retry() {
155
200
  return 0
156
201
  fi
157
202
  "${FLOW_TOOLS_DIR}/retry-state.sh" issue "$ISSUE_ID" schedule "$reason" >/dev/null || true
203
+ issue_record_retry_baseline_gate "${reason}"
158
204
  }
159
205
 
160
206
  issue_mark_ready() {
@@ -156,7 +156,7 @@ function createExecutionContext(stage) {
156
156
  }
157
157
 
158
158
  function runScriptWithContext(context, scriptRelativePath, forwardedArgs, options = {}) {
159
- const scriptPath = path.join(packageRoot, scriptRelativePath);
159
+ const scriptPath = options.scriptPath || path.join(packageRoot, scriptRelativePath);
160
160
  const stdio = options.stdio || "inherit";
161
161
  const result = spawnSync("bash", [scriptPath, ...forwardedArgs], {
162
162
  stdio,
@@ -175,11 +175,78 @@ function runScriptWithContext(context, scriptRelativePath, forwardedArgs, option
175
175
  };
176
176
  }
177
177
 
178
+ function resolvePersistentSourceHome(context) {
179
+ if (process.env.ACP_PROJECT_RUNTIME_SOURCE_HOME) {
180
+ return process.env.ACP_PROJECT_RUNTIME_SOURCE_HOME;
181
+ }
182
+ if (fs.existsSync(path.join(packageRoot, ".git"))) {
183
+ return packageRoot;
184
+ }
185
+ return context.runtimeHome;
186
+ }
187
+
188
+ function runtimeSkillRoot(context) {
189
+ return path.join(context.runtimeHome, "skills", "openclaw", skillName);
190
+ }
191
+
192
+ function createRuntimeExecutionContext(context) {
193
+ const stableSkillRoot = runtimeSkillRoot(context);
194
+ const persistentSourceHome = resolvePersistentSourceHome(context);
195
+ const runtimeScriptEnv = {
196
+ ACP_PROJECT_RUNTIME_SYNC_SCRIPT:
197
+ context.env.ACP_PROJECT_RUNTIME_SYNC_SCRIPT || path.join(stableSkillRoot, "tools", "bin", "sync-shared-agent-home.sh"),
198
+ ACP_PROJECT_RUNTIME_ENSURE_SYNC_SCRIPT:
199
+ context.env.ACP_PROJECT_RUNTIME_ENSURE_SYNC_SCRIPT || path.join(stableSkillRoot, "tools", "bin", "ensure-runtime-sync.sh"),
200
+ ACP_PROJECT_RUNTIME_BOOTSTRAP_SCRIPT:
201
+ context.env.ACP_PROJECT_RUNTIME_BOOTSTRAP_SCRIPT || path.join(stableSkillRoot, "tools", "bin", "project-launchd-bootstrap.sh"),
202
+ ACP_PROJECT_RUNTIME_SUPERVISOR_SCRIPT:
203
+ context.env.ACP_PROJECT_RUNTIME_SUPERVISOR_SCRIPT || path.join(stableSkillRoot, "tools", "bin", "project-runtime-supervisor.sh"),
204
+ ACP_PROJECT_RUNTIME_KICK_SCRIPT:
205
+ context.env.ACP_PROJECT_RUNTIME_KICK_SCRIPT || path.join(stableSkillRoot, "tools", "bin", "kick-scheduler.sh")
206
+ };
207
+ return {
208
+ ...context,
209
+ stableSkillRoot,
210
+ persistentSourceHome,
211
+ env: {
212
+ ...context.env,
213
+ SHARED_AGENT_HOME: context.runtimeHome,
214
+ AGENT_CONTROL_PLANE_ROOT: stableSkillRoot,
215
+ ACP_ROOT: stableSkillRoot,
216
+ AGENT_FLOW_SOURCE_ROOT: stableSkillRoot,
217
+ ACP_PROJECT_INIT_SOURCE_HOME: persistentSourceHome,
218
+ ACP_PROJECT_RUNTIME_SOURCE_HOME: persistentSourceHome,
219
+ ACP_DASHBOARD_SOURCE_HOME: persistentSourceHome,
220
+ ...runtimeScriptEnv
221
+ }
222
+ };
223
+ }
224
+
225
+ function syncRuntimeHome(context, options = {}) {
226
+ const result = runScriptWithContext(context, "tools/bin/sync-shared-agent-home.sh", [], {
227
+ stdio: options.stdio || "inherit"
228
+ });
229
+ if (result.status !== 0) {
230
+ throw new Error("failed to sync runtime home before command execution");
231
+ }
232
+ }
233
+
178
234
  function runCommand(scriptRelativePath, forwardedArgs) {
179
235
  const stage = stageSharedHome();
180
236
  const context = createExecutionContext(stage);
181
237
 
182
238
  try {
239
+ if (scriptRelativePath !== "tools/bin/sync-shared-agent-home.sh") {
240
+ syncRuntimeHome(context, { stdio: "inherit" });
241
+ const runtimeContext = createRuntimeExecutionContext(context);
242
+ const runtimeScriptPath = path.join(runtimeContext.stableSkillRoot, scriptRelativePath);
243
+ const result = runScriptWithContext(runtimeContext, scriptRelativePath, forwardedArgs, {
244
+ stdio: "inherit",
245
+ scriptPath: runtimeScriptPath
246
+ });
247
+ return result.status;
248
+ }
249
+
183
250
  const result = runScriptWithContext(context, scriptRelativePath, forwardedArgs, { stdio: "inherit" });
184
251
  return result.status;
185
252
  } finally {
@@ -1470,8 +1537,8 @@ async function maybeRunFinalSetupFixups(options, scopedContext, config, currentS
1470
1537
  runtimeStartReason = `doctor-${doctorKv.DOCTOR_STATUS || "not-ok"}`;
1471
1538
  } else {
1472
1539
  actions.push("runtime-start");
1473
- runSetupStep(scopedContext, "Start the runtime", "tools/bin/project-runtimectl.sh", ["start", "--profile-id", config.profileId]);
1474
- const runtimeStatusOutput = runSetupStep(scopedContext, "Read back runtime status", "tools/bin/project-runtimectl.sh", ["status", "--profile-id", config.profileId]);
1540
+ runSetupStep(scopedContext, "Start the runtime", "tools/bin/project-runtimectl.sh", ["start", "--profile-id", config.profileId], { useRuntimeCopy: true });
1541
+ const runtimeStatusOutput = runSetupStep(scopedContext, "Read back runtime status", "tools/bin/project-runtimectl.sh", ["status", "--profile-id", config.profileId], { useRuntimeCopy: true });
1475
1542
  runtimeStatusKv = parseKvOutput(runtimeStatusOutput);
1476
1543
  runtimeStartStatus = "ok";
1477
1544
  runtimeStartReason = "";
@@ -1480,7 +1547,7 @@ async function maybeRunFinalSetupFixups(options, scopedContext, config, currentS
1480
1547
 
1481
1548
  if (config.installLaunchd && process.platform === "darwin" && launchdInstallStatus !== "ok" && runtimeStartStatus === "ok") {
1482
1549
  actions.push("launchd-install");
1483
- runSetupStep(scopedContext, "Install macOS autostart", "tools/bin/install-project-launchd.sh", ["--profile-id", config.profileId]);
1550
+ runSetupStep(scopedContext, "Install macOS autostart", "tools/bin/install-project-launchd.sh", ["--profile-id", config.profileId], { useRuntimeCopy: true });
1484
1551
  launchdInstallStatus = "ok";
1485
1552
  launchdInstallReason = "";
1486
1553
  }
@@ -1603,7 +1670,21 @@ async function collectSetupConfig(options, context) {
1603
1670
 
1604
1671
  function runSetupStep(context, title, scriptRelativePath, args, options = {}) {
1605
1672
  console.log(`\n== ${title} ==`);
1606
- const result = runScriptWithContext(context, scriptRelativePath, args, { stdio: "pipe", env: options.env, cwd: options.cwd });
1673
+ let executionContext = context;
1674
+ let scriptPath = undefined;
1675
+
1676
+ if (options.useRuntimeCopy) {
1677
+ syncRuntimeHome(context, { stdio: "pipe" });
1678
+ executionContext = createRuntimeExecutionContext(context);
1679
+ scriptPath = path.join(executionContext.stableSkillRoot, scriptRelativePath);
1680
+ }
1681
+
1682
+ const result = runScriptWithContext(executionContext, scriptRelativePath, args, {
1683
+ stdio: "pipe",
1684
+ env: options.env,
1685
+ cwd: options.cwd,
1686
+ scriptPath
1687
+ });
1607
1688
  if (result.status !== 0) {
1608
1689
  printFailureDetails(result);
1609
1690
  throw new Error(`${title} failed`);
@@ -1868,8 +1949,8 @@ async function runSetupFlow(forwardedArgs) {
1868
1949
  runtimeStartReason = "gh-auth-not-ready";
1869
1950
  console.log("runtime start skipped: GitHub CLI is not authenticated yet. Run `gh auth login` and start the runtime afterwards.");
1870
1951
  } else {
1871
- runSetupStep(scopedContext, "Start the runtime", "tools/bin/project-runtimectl.sh", ["start", "--profile-id", config.profileId]);
1872
- const runtimeStatusOutput = runSetupStep(scopedContext, "Read back runtime status", "tools/bin/project-runtimectl.sh", ["status", "--profile-id", config.profileId]);
1952
+ runSetupStep(scopedContext, "Start the runtime", "tools/bin/project-runtimectl.sh", ["start", "--profile-id", config.profileId], { useRuntimeCopy: true });
1953
+ const runtimeStatusOutput = runSetupStep(scopedContext, "Read back runtime status", "tools/bin/project-runtimectl.sh", ["status", "--profile-id", config.profileId], { useRuntimeCopy: true });
1873
1954
  runtimeStatusKv = parseKvOutput(runtimeStatusOutput);
1874
1955
  runtimeStartStatus = "ok";
1875
1956
  runtimeStartReason = "";
@@ -1886,7 +1967,7 @@ async function runSetupFlow(forwardedArgs) {
1886
1967
  launchdInstallReason = "runtime-not-started";
1887
1968
  console.log("launchd install skipped: runtime was not started successfully in this setup run.");
1888
1969
  } else {
1889
- runSetupStep(scopedContext, "Install macOS autostart", "tools/bin/install-project-launchd.sh", ["--profile-id", config.profileId]);
1970
+ runSetupStep(scopedContext, "Install macOS autostart", "tools/bin/install-project-launchd.sh", ["--profile-id", config.profileId], { useRuntimeCopy: true });
1890
1971
  launchdInstallStatus = "ok";
1891
1972
  launchdInstallReason = "";
1892
1973
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "Help a repo keep GitHub-driven coding agents running reliably without constant human babysitting",
5
5
  "homepage": "https://github.com/ducminhnguyen0319/agent-control-plane",
6
6
  "bugs": {
@@ -167,6 +167,8 @@ printf -v codex_bin_q '%q' "$codex_bin"
167
167
  printf -v runner_bin_q '%q' "$runner_bin"
168
168
  printf -v safe_profile_q '%q' "$safe_profile"
169
169
  printf -v bypass_profile_q '%q' "$bypass_profile"
170
+ helper_bin_dir="${artifact_dir}/worker-bin"
171
+ printf -v helper_bin_dir_q '%q' "$helper_bin_dir"
170
172
 
171
173
  {
172
174
  printf 'TASK_KIND=%s\n' "$task_kind_q"
@@ -274,6 +276,61 @@ cat >"$inner_script" <<EOF
274
276
  set -euo pipefail
275
277
  ${runtime_exports}
276
278
  ${context_exports}cd ${worktree_realpath_q}
279
+ bootstrap_codex_helper_env() {
280
+ local helper_bin_dir=${helper_bin_dir_q}
281
+ local openspec_shim=""
282
+
283
+ mkdir -p "\${helper_bin_dir}"
284
+
285
+ if [[ -d ${worktree_realpath_q}/node_modules/.bin ]]; then
286
+ export PATH="${worktree_realpath_q}/node_modules/.bin:\${PATH}"
287
+ fi
288
+ export PATH="\${helper_bin_dir}:\${PATH}"
289
+
290
+ if command -v openspec >/dev/null 2>&1; then
291
+ return 0
292
+ fi
293
+
294
+ if [[ ! -d ${worktree_realpath_q}/openspec ]]; then
295
+ return 0
296
+ fi
297
+
298
+ openspec_shim="\${helper_bin_dir}/openspec"
299
+ cat >"\${openspec_shim}" <<'SHIM'
300
+ #!/usr/bin/env bash
301
+ set -euo pipefail
302
+
303
+ repo_root="\${ACP_REPO_ROOT:-\${F_LOSNING_REPO_ROOT:-\$(pwd)}}"
304
+ openspec_root="\${repo_root}/openspec"
305
+
306
+ if [[ ! -d "\${openspec_root}" ]]; then
307
+ echo "openspec directory not found: \${openspec_root}" >&2
308
+ exit 1
309
+ fi
310
+
311
+ command_name="\${1:-}"
312
+ case "\${command_name}" in
313
+ list)
314
+ shift || true
315
+ if [[ "\${1:-}" == "--specs" ]]; then
316
+ find "\${openspec_root}/specs" -mindepth 1 -maxdepth 1 -type d 2>/dev/null \
317
+ | xargs -n1 basename 2>/dev/null \
318
+ | sort
319
+ exit 0
320
+ fi
321
+ find "\${openspec_root}/changes" -mindepth 1 -maxdepth 1 -type d ! -name archive 2>/dev/null \
322
+ | xargs -n1 basename 2>/dev/null \
323
+ | sort
324
+ exit 0
325
+ ;;
326
+ *)
327
+ echo "openspec shim only supports 'list' and 'list --specs'; use direct file reads for other operations" >&2
328
+ exit 64
329
+ ;;
330
+ esac
331
+ SHIM
332
+ chmod +x "\${openspec_shim}"
333
+ }
277
334
  reset_sandbox_run_dir() {
278
335
  mkdir -p ${sandbox_run_dir_q}
279
336
  find ${sandbox_run_dir_q} -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true
@@ -331,6 +388,7 @@ record_final_git_state() {
331
388
  } >>"\${tmp_file}"
332
389
  mv "\${tmp_file}" ${meta_file_q}
333
390
  }
391
+ bootstrap_codex_helper_env
334
392
  reset_sandbox_run_dir
335
393
  set +e
336
394
  bash ${runner_bin_q} \\
@@ -108,6 +108,7 @@ const dependencyInputsChanged = files.some(isDependencyManifest);
108
108
  const apiTouched = productNonTestFiles.some((file) => /^apps\/api\//.test(file));
109
109
  const webTouched = productNonTestFiles.some((file) => /^apps\/web\//.test(file));
110
110
  const mobileTouched = productNonTestFiles.some((file) => /^apps\/mobile\//.test(file));
111
+ const apiProductFiles = productNonTestFiles.filter((file) => /^apps\/api\//.test(file));
111
112
  const packageNames = [
112
113
  ...new Set(
113
114
  productNonTestFiles
@@ -199,11 +200,23 @@ const changedTestCoverage = changedTestFiles.map((file) => {
199
200
  return { file, anchors, covered };
200
201
  });
201
202
  const missingChangedTestFiles = changedTestCoverage.filter(({ covered }) => !covered);
203
+ const apiChangedTests = changedTestCoverage.filter(({ file }) => /^apps\/api\//.test(file));
202
204
 
203
205
  const rootTypecheck = hasCommand(/\bpnpm (?:run )?typecheck\b/, /\bturbo\b.*\btypecheck\b/);
204
206
  const rootBuild = hasCommand(/\bpnpm (?:run )?build\b/, /\bturbo\b.*\bbuild\b/);
205
207
  const rootLint = hasCommand(/\bpnpm (?:run )?lint\b/, /\bturbo\b.*\blint\b/);
206
208
  const rootTest = hasCommand(/\bpnpm (?:run )?test\b/, /\bturbo\b.*\btest\b/);
209
+ const scopedApiTypecheck =
210
+ hasScopedCommand(apiScopePattern, /\btypecheck\b/, /\btsc --noemit\b/, /\btsc --noemit\b/);
211
+ const scopedApiConfidence =
212
+ hasScopedCommand(apiScopePattern, /\blint\b/, /\bbuild\b/, /\btest\b/, /\bjest\b/, /\bvitest\b/);
213
+ const apiNarrowSliceTargetedCoverage =
214
+ apiProductFiles.length > 0 &&
215
+ apiProductFiles.length <= 2 &&
216
+ apiProductFiles.every((file) => /(?:^|\/)(?:services?|utils?|helpers?|policies?)\/|(?:\.service|\.util|\.helper|\.policy)\.[cm]?[jt]s$/.test(file)) &&
217
+ apiChangedTests.length > 0 &&
218
+ apiChangedTests.every(({ covered }) => covered) &&
219
+ scopedApiConfidence;
207
220
 
208
221
  const reasons = [];
209
222
  if (passedCommands.length === 0) {
@@ -242,10 +255,10 @@ if (localeTouched) {
242
255
  }
243
256
 
244
257
  if (apiTouched) {
245
- if (!(hasScopedCommand(apiScopePattern, /\btypecheck\b/, /\btsc --noemit\b/, /\btsc --noemit\b/) || rootTypecheck)) {
258
+ if (!(scopedApiTypecheck || rootTypecheck || apiNarrowSliceTargetedCoverage)) {
246
259
  reasons.push('missing API typecheck or repo typecheck for API changes');
247
260
  }
248
- if (!(hasScopedCommand(apiScopePattern, /\blint\b/, /\bbuild\b/, /\btest\b/, /\bjest\b/, /\bvitest\b/) || rootBuild || rootLint || rootTest)) {
261
+ if (!(scopedApiConfidence || rootBuild || rootLint || rootTest)) {
249
262
  reasons.push('missing API confidence verification (lint, build, or test) for API changes');
250
263
  }
251
264
  }
@@ -84,6 +84,11 @@ resolve_source_skill_dir() {
84
84
  local skill_name=""
85
85
  local root="${1:?source home required}"
86
86
 
87
+ if flow_is_skill_root "${root}"; then
88
+ printf '%s\n' "${root}"
89
+ return 0
90
+ fi
91
+
87
92
  for skill_name in "$(flow_canonical_skill_name)" "$(flow_compat_skill_alias)"; do
88
93
  [[ -n "${skill_name}" ]] || continue
89
94
  candidate="${root}/skills/openclaw/${skill_name}"
@@ -93,11 +98,6 @@ resolve_source_skill_dir() {
93
98
  fi
94
99
  done
95
100
 
96
- if flow_is_skill_root "${root}"; then
97
- printf '%s\n' "${root}"
98
- return 0
99
- fi
100
-
101
101
  return 1
102
102
  }
103
103
 
@@ -727,38 +727,48 @@ for (const line of body.split(/\r?\n/).slice(0, 40)) {
727
727
  }
728
728
  }
729
729
 
730
- if (commands.length === 0 && repoRoot) {
731
- const packageJsonPath = path.join(repoRoot, 'package.json');
732
- if (fs.existsSync(packageJsonPath)) {
733
- try {
734
- const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
735
- if (packageJson?.scripts?.test) {
736
- if (fs.existsSync(path.join(repoRoot, 'pnpm-lock.yaml'))) {
737
- addCommand('pnpm test');
738
- } else if (fs.existsSync(path.join(repoRoot, 'yarn.lock'))) {
739
- addCommand('yarn test');
740
- } else {
741
- addCommand('npm test');
730
+ if (commands.length === 0 && repoRoot) {
731
+ const packageJsonPath = path.join(repoRoot, 'package.json');
732
+ if (fs.existsSync(packageJsonPath)) {
733
+ try {
734
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
735
+ if (packageJson?.scripts?.smoke) {
736
+ addCommand('# If this cycle changes smoke runners, CLI entrypoints, or operator commands, also run the matching smoke command from the repo instructions.');
742
737
  }
738
+ } catch (_error) {
739
+ // Ignore parse errors and fall through to generic guidance.
743
740
  }
744
- } catch (_error) {
745
- // Ignore parse errors and fall through to generic guidance.
746
741
  }
747
742
  }
748
- }
749
743
 
750
- if (commands.length === 0) {
751
- addCommand('pnpm test');
752
- }
744
+ if (commands.length === 0) {
745
+ addCommand('# Pick the narrowest relevant local verification for the files you touch.');
746
+ addCommand('# Do not default to repo-wide pnpm test unless the issue body explicitly requires it.');
747
+ addCommand('# Examples:');
748
+ addCommand('# pnpm --filter @<repo>/api test -- --runInBand <target-spec>');
749
+ addCommand('# pnpm --filter @<repo>/api typecheck');
750
+ addCommand('# pnpm --filter @<repo>/web test -- --run <target-spec>');
751
+ addCommand('# pnpm --filter @<repo>/web typecheck');
752
+ addCommand('# pnpm --filter @<repo>/mobile test -- --runInBand <target-spec>');
753
+ addCommand('# pnpm --filter @<repo>/mobile typecheck');
754
+ addCommand('# pnpm --filter @<repo>/<package> test -- --run <target-spec>');
755
+ addCommand('# pnpm --filter @<repo>/<package> typecheck');
756
+ addCommand('# After each successful command, record it with record-verification.sh exactly as shown below.');
757
+ }
753
758
 
754
759
  const escapeDoubleQuotes = (value) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
755
760
  const snippet = commands
756
- .map((command) =>
757
- command + '\n' +
758
- 'bash "$ACP_FLOW_TOOLS_DIR/record-verification.sh" --run-dir "$ACP_RUN_DIR" --status pass --command "' +
759
- escapeDoubleQuotes(command) +
760
- '"',
761
- )
761
+ .map((command) => {
762
+ if (command.startsWith('#')) {
763
+ return command;
764
+ }
765
+ return (
766
+ command + '\n' +
767
+ 'bash "$ACP_FLOW_TOOLS_DIR/record-verification.sh" --run-dir "$ACP_RUN_DIR" --status pass --command "' +
768
+ escapeDoubleQuotes(command) +
769
+ '"'
770
+ );
771
+ })
762
772
  .join('\n\n');
763
773
 
764
774
  process.stdout.write(snippet);
@@ -32,12 +32,21 @@ if [[ ! -d "${FLOW_SKILL_SOURCE}" && -n "${COMPAT_FLOW_SKILL_ALIAS}" ]]; then
32
32
  fi
33
33
 
34
34
  FLOW_SKILL_SOURCE="$(cd "${FLOW_SKILL_SOURCE}" && pwd -P)"
35
+ SOURCE_HOME="$(cd "${SOURCE_HOME}" && pwd -P)"
36
+
37
+ remove_tree_force() {
38
+ local target="${1:-}"
39
+ [[ -n "${target}" ]] || return 0
40
+ [[ -e "${target}" || -L "${target}" ]] || return 0
41
+ chmod -R u+w "${target}" 2>/dev/null || true
42
+ rm -rf "${target}" 2>/dev/null || true
43
+ }
35
44
 
36
45
  sync_tree_copy_mode() {
37
46
  local source_dir="${1:?source dir required}"
38
47
  local target_dir="${2:?target dir required}"
39
48
  [[ -d "${source_dir}" ]] || return 0
40
- rm -rf "${target_dir}"
49
+ remove_tree_force "${target_dir}"
41
50
  mkdir -p "${target_dir}"
42
51
  (
43
52
  cd "${source_dir}"
@@ -57,13 +66,15 @@ sync_tree_into_target() {
57
66
  }
58
67
 
59
68
  sync_skill_copies() {
60
- sync_tree_into_target "${FLOW_SKILL_SOURCE}" "${SOURCE_FLOW_CANONICAL_ALIAS}"
61
- sync_tree_into_target "${FLOW_SKILL_SOURCE}" "${FLOW_SKILL_TARGET}"
62
-
63
- if [[ -n "${SOURCE_FLOW_COMPAT_ALIAS}" ]]; then
64
- sync_tree_into_target "${FLOW_SKILL_SOURCE}" "${SOURCE_FLOW_COMPAT_ALIAS}"
69
+ if ! flow_is_skill_root "${SOURCE_HOME}"; then
70
+ sync_tree_into_target "${FLOW_SKILL_SOURCE}" "${SOURCE_FLOW_CANONICAL_ALIAS}"
71
+ if [[ -n "${SOURCE_FLOW_COMPAT_ALIAS}" ]]; then
72
+ sync_tree_into_target "${FLOW_SKILL_SOURCE}" "${SOURCE_FLOW_COMPAT_ALIAS}"
73
+ fi
65
74
  fi
66
75
 
76
+ sync_tree_into_target "${FLOW_SKILL_SOURCE}" "${FLOW_SKILL_TARGET}"
77
+
67
78
  if [[ -n "${TARGET_FLOW_COMPAT_ALIAS}" ]]; then
68
79
  sync_tree_into_target "${FLOW_SKILL_SOURCE}" "${TARGET_FLOW_COMPAT_ALIAS}"
69
80
  fi
@@ -200,18 +211,21 @@ sync_tree_rsync() {
200
211
  }
201
212
 
202
213
  reset_runtime_skill_targets() {
203
- rm -rf "${FLOW_SKILL_TARGET}"
214
+ remove_tree_force "${FLOW_SKILL_TARGET}"
204
215
  if [[ -n "${TARGET_FLOW_COMPAT_ALIAS}" ]]; then
205
- rm -rf "${TARGET_FLOW_COMPAT_ALIAS}"
216
+ remove_tree_force "${TARGET_FLOW_COMPAT_ALIAS}"
206
217
  fi
207
218
  }
208
219
 
209
220
  reset_source_skill_targets() {
221
+ if flow_is_skill_root "${SOURCE_HOME}"; then
222
+ return 0
223
+ fi
210
224
  if [[ "${FLOW_SKILL_SOURCE}" != "${SOURCE_FLOW_CANONICAL_ALIAS}" ]]; then
211
- rm -rf "${SOURCE_FLOW_CANONICAL_ALIAS}"
225
+ remove_tree_force "${SOURCE_FLOW_CANONICAL_ALIAS}"
212
226
  fi
213
227
  if [[ -n "${SOURCE_FLOW_COMPAT_ALIAS}" && "${FLOW_SKILL_SOURCE}" != "${SOURCE_FLOW_COMPAT_ALIAS}" ]]; then
214
- rm -rf "${SOURCE_FLOW_COMPAT_ALIAS}"
228
+ remove_tree_force "${SOURCE_FLOW_COMPAT_ALIAS}"
215
229
  fi
216
230
  }
217
231
 
@@ -22,7 +22,10 @@ Follow this order:
22
22
  bash "$ACP_FLOW_TOOLS_DIR/create-follow-up-issue.sh" --parent {ISSUE_ID} --title "..." --body-file /tmp/follow-up.md
23
23
  ```
24
24
  3. Implement the smallest root-cause fix in this worktree only.
25
- 4. Run verification and record every successful command with `record-verification.sh`.
25
+ 4. Run the narrowest relevant local verification for the files you changed, and record every successful command with `record-verification.sh`.
26
+
27
+ - Do not default to repo-wide verification such as `pnpm test` unless the issue body explicitly requires it.
28
+ - If unrelated repo-wide suites are already red, keep the cycle focused on targeted verification for your slice and let the host verification guard decide whether publication is safe.
26
29
 
27
30
  ```bash
28
31
  {ISSUE_VERIFICATION_COMMAND_SNIPPET}