crewly 1.20.376 → 1.20.377

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 (46) hide show
  1. package/config/hooks/destructive-git-guard/guard.mjs +139 -9
  2. package/config/hooks/destructive-git-guard/local-gates.json +12 -0
  3. package/config/skills/agent/core/record-local-gate/SKILL.md +46 -0
  4. package/config/skills/agent/core/record-local-gate/execute.sh +87 -0
  5. package/config/skills/agent/core/record-local-gate/execute.test.sh +64 -0
  6. package/dist/backend/backend/src/constants.d.ts +4 -0
  7. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  8. package/dist/backend/backend/src/constants.js +4 -0
  9. package/dist/backend/backend/src/constants.js.map +1 -1
  10. package/dist/backend/backend/src/controllers/experiments/experiments.routes.d.ts +1 -1
  11. package/dist/backend/backend/src/controllers/experiments/experiments.routes.js +1 -1
  12. package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.d.ts.map +1 -1
  13. package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.js +4 -1
  14. package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.js.map +1 -1
  15. package/dist/backend/backend/src/controllers/trace/trace.controller.d.ts.map +1 -1
  16. package/dist/backend/backend/src/controllers/trace/trace.controller.js +60 -7
  17. package/dist/backend/backend/src/controllers/trace/trace.controller.js.map +1 -1
  18. package/dist/backend/backend/src/services/experiments/experiment.service.d.ts +24 -1
  19. package/dist/backend/backend/src/services/experiments/experiment.service.d.ts.map +1 -1
  20. package/dist/backend/backend/src/services/experiments/experiment.service.js +49 -0
  21. package/dist/backend/backend/src/services/experiments/experiment.service.js.map +1 -1
  22. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.d.ts +30 -2
  23. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.d.ts.map +1 -1
  24. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.js +63 -7
  25. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.js.map +1 -1
  26. package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt-data.d.ts +20 -1
  27. package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt-data.d.ts.map +1 -1
  28. package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt-data.js +30 -3
  29. package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt-data.js.map +1 -1
  30. package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.renderer.d.ts +7 -0
  31. package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.renderer.d.ts.map +1 -1
  32. package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.renderer.js +8 -1
  33. package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.renderer.js.map +1 -1
  34. package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.types.d.ts +11 -1
  35. package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.types.d.ts.map +1 -1
  36. package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.types.js +1 -0
  37. package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.types.js.map +1 -1
  38. package/dist/backend/backend/src/types/experiment.types.d.ts +14 -0
  39. package/dist/backend/backend/src/types/experiment.types.d.ts.map +1 -1
  40. package/dist/backend/backend/src/types/experiment.types.js.map +1 -1
  41. package/dist/backend/build-info.json +2 -2
  42. package/dist/cli/backend/src/constants.d.ts +4 -0
  43. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  44. package/dist/cli/backend/src/constants.js +4 -0
  45. package/dist/cli/backend/src/constants.js.map +1 -1
  46. package/package.json +1 -1
@@ -22,11 +22,25 @@
22
22
  // The check asks GitHub through `gh` (CREWLY_GUARD_GH overrides the binary); when it cannot
23
23
  // ask, the merge is blocked. CREWLY_GUARD_MERGE_CHECK=0 turns it off.
24
24
  //
25
+ // Local gate (private repos whose CI cannot run): a merge that CI would block (a check not green,
26
+ // or none to show the merge works) is accepted when ALL of: the repo has an entry in
27
+ // local-gates.json; every changed file (and every renamed-from path) matches that entry's
28
+ // contentPaths; `record-local-gate` recorded exitCode 0 for exactly the PR head SHA; the record
29
+ // was made against the current base head (or the PR is not behind); and no check really failed
30
+ // (a job that never started because GitHub billing/spending limits stopped it counts as absent).
31
+ // Anything else is blocked as before, and the message says what is missing. The record is a file
32
+ // the agent could write by hand: a speed bump, not a boundary. A PR more than
33
+ // CREWLY_GUARD_MERGE_MAX_BEHIND commits behind is still blocked.
34
+ //
25
35
  // Coverage limits: an interpreter one-liner (python -c, node -e), a command built at
26
36
  // runtime ($VAR, base64, a script the agent writes then runs) and `$(...)` inside
27
37
  // double quotes are NOT seen. A speed bump, not a boundary (same as control-plane-guard).
28
38
 
29
39
  import { execFileSync } from 'node:child_process';
40
+ import { existsSync, readFileSync } from 'node:fs';
41
+ import { homedir } from 'node:os';
42
+ import { dirname, join } from 'node:path';
43
+ import { fileURLToPath } from 'node:url';
30
44
 
31
45
  const PREFIX = 'destructive-git-guard:';
32
46
  const PROTECTED_BRANCHES = new Set(['main', 'master']);
@@ -183,6 +197,108 @@ function checkState(c) {
183
197
  return { name, green: done && GREEN.has(String(c.conclusion || '').toUpperCase()), at: c.completedAt || c.startedAt };
184
198
  }
185
199
 
200
+
201
+ /** Repos with a local-gate path (config next to this file; CREWLY_GUARD_LOCAL_GATES overrides). */
202
+ const LOCAL_GATES_FILE = process.env.CREWLY_GUARD_LOCAL_GATES || join(dirname(fileURLToPath(import.meta.url)), 'local-gates.json');
203
+ /** Where `record-local-gate` writes its records. */
204
+ const LOCAL_GATES_DIR = join(process.env.CREWLY_HOME || join(homedir(), '.crewly'), 'local-gates');
205
+ /** GitHub's own wording for a job that never started because of billing or the spending limit. */
206
+ const NOT_STARTED_RE = /was not started because/i;
207
+ /** The files endpoint stops at 3000 files; a PR that large is not a content PR. */
208
+ const FILES_CAP = 3000;
209
+
210
+ /** Glob (`**` crosses directories, `*` does not) to an anchored RegExp. */
211
+ function globToRegExp(glob) {
212
+ let re = '';
213
+ for (let i = 0; i < glob.length; i += 1) {
214
+ const c = glob[i];
215
+ if (c === '*' && glob[i + 1] === '*') {
216
+ if (glob[i + 2] === '/') {
217
+ re += '(?:.*/)?';
218
+ i += 2;
219
+ } else {
220
+ re += '.*';
221
+ i += 1;
222
+ }
223
+ } else if (c === '*') re += '[^/]*';
224
+ else re += c.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
225
+ }
226
+ return new RegExp(`^${re}$`);
227
+ }
228
+
229
+ /** The local-gates entry for `slug` (owner/name), or null (no file, bad file, no entry). */
230
+ function localGateEntry(slug) {
231
+ try {
232
+ const entry = JSON.parse(readFileSync(LOCAL_GATES_FILE, 'utf8'))?.[slug];
233
+ if (!entry || !Array.isArray(entry.contentPaths) || entry.contentPaths.length === 0) return null;
234
+ return entry;
235
+ } catch {
236
+ return null;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * True when a CI check did not run at all: a finished job with no steps whose annotation says GitHub
242
+ * never started it (billing / spending limit). Anything else non-green, including a check we cannot
243
+ * inspect, is a real failure (fail closed). Two cheap `gh api` calls, only for non-green checks.
244
+ */
245
+ function checkNeverRan(c, api, dir) {
246
+ try {
247
+ const m = /\/actions\/runs\/\d+\/job\/(\d+)/.exec(String(c.detailsUrl || ''));
248
+ if (!m || c.__typename === 'StatusContext' || String(c.status || '').toUpperCase() !== 'COMPLETED') return false;
249
+ if (Number(gh(['api', `${api}/actions/jobs/${m[1]}`, '--jq', '.steps | length'], dir)) !== 0) return false;
250
+ return NOT_STARTED_RE.test(gh(['api', `${api}/check-runs/${m[1]}/annotations`, '--jq', '[.[].message] | join("\n")'], dir));
251
+ } catch {
252
+ return false;
253
+ }
254
+ }
255
+
256
+ /**
257
+ * May this merge go ahead on a recorded local green run instead of CI?
258
+ * @returns {{ok: true, note: string} | {ok: false, why: string}}
259
+ */
260
+ function judgeLocalGate(pr, slug, api, base, behind, realFailures, dir) {
261
+ const entry = localGateEntry(slug);
262
+ if (!entry) return { ok: false, why: `no local-gates entry for ${slug}` };
263
+ if (realFailures.length > 0) return { ok: false, why: `a check really failed (${realFailures.slice(0, 5).join(', ')}); a local run does not override it` };
264
+ const matchers = entry.contentPaths.map(globToRegExp);
265
+ let files;
266
+ try {
267
+ files = gh(['api', '--paginate', `${api}/pulls/${pr.number}/files`, '--jq', '.[] | [.filename, (.previous_filename // "")] | @tsv'], dir)
268
+ .split('\n')
269
+ .filter(Boolean)
270
+ .map((l) => l.split('\t'));
271
+ } catch {
272
+ return { ok: false, why: 'could not list the PR files to check it is content-only' };
273
+ }
274
+ if (files.length === 0 || files.length >= FILES_CAP) return { ok: false, why: `the PR file list is empty or too long to check (${files.length})` };
275
+ const outside = [...new Set(files.flatMap(([f, prev]) => [f, prev]).filter(Boolean).filter((f) => !matchers.some((re) => re.test(f))))];
276
+ if (outside.length > 0) {
277
+ return { ok: false, why: `not content-only, non-content files: ${outside.slice(0, 5).join(', ')}${outside.length > 5 ? `, +${outside.length - 5} more` : ''}` };
278
+ }
279
+ const file = join(LOCAL_GATES_DIR, slug.replace('/', '__'), `pr-${pr.number}-${pr.headRefOid}.json`);
280
+ const fix = `run record-local-gate --repo ${slug} --pr ${pr.number}`;
281
+ if (!existsSync(file)) return { ok: false, why: `no local-gate record at ${String(pr.headRefOid).slice(0, 10)} (${fix})` };
282
+ let rec;
283
+ try {
284
+ rec = JSON.parse(readFileSync(file, 'utf8'));
285
+ } catch {
286
+ return { ok: false, why: `the local-gate record ${file} is unreadable (${fix})` };
287
+ }
288
+ if (rec?.sha !== pr.headRefOid) return { ok: false, why: `the local-gate record is for another commit (${fix})` };
289
+ if (rec.exitCode !== 0) return { ok: false, why: `the local gate at ${String(pr.headRefOid).slice(0, 10)} failed (exit ${rec.exitCode})` };
290
+ if (behind > 0) {
291
+ let baseHead;
292
+ try {
293
+ baseHead = gh(['api', `${api}/commits/${encodeURIComponent(base)}`, '--jq', '.sha'], dir);
294
+ } catch {
295
+ return { ok: false, why: `could not read the head of ${base} to check the record is not stale` };
296
+ }
297
+ if (rec.baseSha !== baseHead) return { ok: false, why: `stale: the record was made against ${String(rec.baseSha).slice(0, 10)} but ${base} is now at ${baseHead.slice(0, 10)} (re-run record-local-gate)` };
298
+ }
299
+ return { ok: true, note: `local gate accepted for PR #${pr.number} in place of CI: record ${file} (head ${String(pr.headRefOid).slice(0, 10)}, exit 0, finished ${rec.finishedAt ?? '?'}, ${files.length} content file(s))` };
300
+ }
301
+
186
302
  /** @returns {string|null} why `gh pr merge <rest>` is not a verified merge, or null. */
187
303
  function judgePrMerge(rest, dir) {
188
304
  if (!MERGE_CHECK_ON || rest.includes('--disable-auto')) return null;
@@ -217,17 +333,31 @@ function judgePrMerge(rest, dir) {
217
333
  if (!Number.isFinite(behind)) return `could not compare PR #${pr.number} with ${base} on GitHub`;
218
334
  const n = pr.number;
219
335
  if (behind > MERGE_MAX_BEHIND) return `PR #${n} is ${behind} commits behind ${base} (more than ${MERGE_MAX_BEHIND}); its CI did not run on what the merge would produce`;
220
- const checks = (Array.isArray(pr.statusCheckRollup) ? pr.statusCheckRollup : []).map(checkState);
221
- const notGreen = checks.filter((c) => !c.green).map((c) => c.name);
222
- if (notGreen.length > 0) return `CI is not green on PR #${n} (${notGreen.slice(0, 5).join(', ')})`;
223
- if (behind > 0) {
224
- if (checks.length === 0) return `PR #${n} is ${behind} commit(s) behind ${base} and has no CI run to show the merge result works`;
225
- const latest = Math.max(...checks.map((c) => Date.parse(c.at || '')).filter((t) => Number.isFinite(t)));
226
- if (!Number.isFinite(latest) || (Number.isFinite(baseAt) && latest < baseAt)) {
227
- return `PR #${n}'s latest CI run predates the head of ${base} (the PR is ${behind} commit(s) behind)`;
336
+ const rollup = Array.isArray(pr.statusCheckRollup) ? pr.statusCheckRollup : [];
337
+ const checks = rollup.map((c) => ({ ...checkState(c), raw: c }));
338
+ const notGreen = checks.filter((c) => !c.green);
339
+ // A CI-state problem (not the behind-limit above): the local gate may stand in for CI.
340
+ let ciReason = null;
341
+ let realFailures = [];
342
+ if (notGreen.length > 0) {
343
+ ciReason = `CI is not green on PR #${n} (${notGreen.slice(0, 5).map((c) => c.name).join(', ')})`;
344
+ realFailures = notGreen.filter((c) => !checkNeverRan(c.raw, api, dir)).map((c) => c.name);
345
+ } else if (behind > 0) {
346
+ if (checks.length === 0) ciReason = `PR #${n} is ${behind} commit(s) behind ${base} and has no CI run to show the merge result works`;
347
+ else {
348
+ const latest = Math.max(...checks.map((c) => Date.parse(c.at || '')).filter((t) => Number.isFinite(t)));
349
+ if (!Number.isFinite(latest) || (Number.isFinite(baseAt) && latest < baseAt)) {
350
+ ciReason = `PR #${n}'s latest CI run predates the head of ${base} (the PR is ${behind} commit(s) behind)`;
351
+ }
228
352
  }
229
353
  }
230
- return null;
354
+ if (ciReason === null) return null;
355
+ const local = judgeLocalGate({ number: n, headRefOid: pr.headRefOid }, `${m[1]}/${m[2]}`, api, base, behind, realFailures, dir);
356
+ if (local.ok) {
357
+ process.stdout.write(`${PREFIX} ${local.note}\n`);
358
+ return null;
359
+ }
360
+ return `${ciReason}; local gate: ${local.why}`;
231
361
  }
232
362
 
233
363
  let checked = 0;
@@ -0,0 +1,12 @@
1
+ {
2
+ "_comment": "Repos whose CI cannot run (private repos, GitHub Actions billing off). For these, `gh pr merge` of a CONTENT-ONLY PR is accepted when `record-local-gate` recorded a green local run at the PR head. A repo without an entry here gets no such path. gate = commands run in order in a fresh clone of the PR head; contentPaths = the only files such a PR may touch (glob: ** crosses directories, * stays inside one).",
3
+ "stevehuang0115/crewly-web": {
4
+ "gate": [
5
+ "npm ci --ignore-scripts --no-audit --no-fund",
6
+ "npx tsc --noEmit",
7
+ "npx vitest run --maxWorkers=2 --retry=1",
8
+ "npm run build"
9
+ ],
10
+ "contentPaths": ["content/blog/**/*.md"]
11
+ }
12
+ }
@@ -0,0 +1,46 @@
1
+ ---
2
+ name: Record Local Gate
3
+ description: Run a private repo's gate on a PR's exact head in a fresh clone and record the result, so a content-only PR can merge when GitHub Actions cannot run.
4
+ version: 1.0.0
5
+ category: development
6
+ skillType: claude-skill
7
+ assignableRoles:
8
+ - developer
9
+ - fullstack-dev
10
+ - backend-developer
11
+ - frontend-developer
12
+ - generalist
13
+ triggers:
14
+ - record local gate
15
+ - local green build
16
+ - ci cannot run
17
+ - merge content pr without ci
18
+ tags:
19
+ - merge
20
+ - ci
21
+ - guard
22
+ execution:
23
+ type: script
24
+ script:
25
+ file: execute.sh
26
+ interpreter: bash
27
+ timeoutMs: 1800000
28
+ ---
29
+
30
+ # Record Local Gate
31
+
32
+ Some private repos cannot run GitHub Actions (billing is off). `destructive-git-guard` blocks `gh pr merge` unless CI is green, so their PRs could never merge. For a repo listed in `config/hooks/destructive-git-guard/local-gates.json`, the guard accepts a recorded local run instead, for **content-only** PRs.
33
+
34
+ ## Use
35
+
36
+ ```bash
37
+ bash config/skills/agent/core/record-local-gate/execute.sh --repo stevehuang0115/crewly-web --pr 285
38
+ ```
39
+
40
+ It reads the PR head SHA with `gh`, checks that exact SHA out in a fresh temporary clone (never your working tree), runs the repo's gate commands with a scrubbed environment, and writes `$CREWLY_HOME/local-gates/<owner>__<repo>/pr-<N>-<sha>.json` (sha, baseRef, baseSha, command, exitCode, start and finish times, last 40 log lines, recordedBy). It prints what it ran and the exit code, and exits non-zero when the gate fails (the failing record is still written). There is no `--exit-code` flag: a record is never handed in.
41
+
42
+ Then `gh pr merge` goes through the guard. The guard accepts only when ALL hold: the repo has an entry; every changed file (and renamed-from path) matches the entry's `contentPaths`; a record exists for exactly the PR head with exit 0; the record's base head is still the base head (or the PR is not behind); no check actually ran and failed. Push a new commit and the record no longer matches: run this again.
43
+
44
+ ## Limits
45
+
46
+ The record is a file on disk. It is a speed bump, like the guard itself, not a boundary: someone who can write files can forge it. It does not replace review, and code PRs get no such path.
@@ -0,0 +1,87 @@
1
+ #!/bin/bash
2
+ # record-local-gate — run a repo's gate on a PR's exact head commit in a FRESH clone and
3
+ # record the result for destructive-git-guard (gh pr merge of a content-only PR whose CI
4
+ # cannot run). There is no way to pass an exit code in: the record is only ever the real
5
+ # result of the commands in local-gates.json. See config/hooks/destructive-git-guard/guard.mjs.
6
+ set -uo pipefail
7
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+
9
+ GH="${CREWLY_GUARD_GH:-gh}"
10
+ GATES="${CREWLY_GUARD_LOCAL_GATES:-${SCRIPT_DIR}/../../../../hooks/destructive-git-guard/local-gates.json}"
11
+ RECORDS_ROOT="${CREWLY_HOME:-$HOME/.crewly}/local-gates"
12
+ LOG_TAIL_LINES=40
13
+
14
+ usage() {
15
+ cat <<'EOF_USAGE'
16
+ Usage: bash execute.sh --repo owner/name --pr N
17
+ Runs the repo's gate (config/hooks/destructive-git-guard/local-gates.json) on the PR head in a
18
+ fresh temporary clone and writes $CREWLY_HOME/local-gates/<owner>__<repo>/pr-<N>-<sha>.json.
19
+ Exits non-zero when the gate fails (the record is still written, with the failing exit code).
20
+ EOF_USAGE
21
+ }
22
+
23
+ fail() { printf '{"error":%s}\n' "$(jq -Rn --arg e "$1" '$e')" >&2; exit "${2:-1}"; }
24
+
25
+ REPO=""; PR=""
26
+ while [ $# -gt 0 ]; do
27
+ case "$1" in
28
+ --repo) REPO="${2:-}"; shift 2 ;;
29
+ --pr) PR="${2:-}"; shift 2 ;;
30
+ -h|--help) usage; exit 0 ;;
31
+ *) usage >&2; fail "unknown argument: $1 (this skill takes only --repo and --pr; a record cannot be hand-made)" 2 ;;
32
+ esac
33
+ done
34
+ [[ "$REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || fail "--repo owner/name is required" 2
35
+ [[ "$PR" =~ ^[0-9]+$ ]] || fail "--pr <number> is required" 2
36
+ command -v jq >/dev/null || fail "jq is required"
37
+
38
+ ENTRY="$(jq -c --arg r "$REPO" '.[$r] // empty' "$GATES" 2>/dev/null)"
39
+ [ -n "$ENTRY" ] || fail "no local-gates entry for $REPO in $GATES; this repo has no local-gate path (its CI is the only way)"
40
+ N_CMDS="$(printf '%s' "$ENTRY" | jq '.gate | length')"
41
+ [ "${N_CMDS:-0}" -gt 0 ] || fail "the local-gates entry for $REPO has no gate commands"
42
+
43
+ VIEW="$("$GH" pr view "$PR" -R "$REPO" --json headRefOid,baseRefName 2>&1)" || fail "could not read PR #$PR with gh: $VIEW"
44
+ SHA="$(printf '%s' "$VIEW" | jq -r .headRefOid)"
45
+ BASE_REF="$(printf '%s' "$VIEW" | jq -r .baseRefName)"
46
+ [[ "$SHA" =~ ^[0-9a-f]{7,64}$ ]] || fail "gh returned no head commit for PR #$PR"
47
+ BASE_SHA="$("$GH" api "repos/$REPO/commits/$BASE_REF" --jq .sha 2>&1)" || fail "could not read the head of $BASE_REF: $BASE_SHA"
48
+
49
+ WORK="$(mktemp -d "${TMPDIR:-/tmp}/local-gate.XXXXXX")" || fail "could not make a temp dir"
50
+ trap 'rm -rf "$WORK"' EXIT
51
+ CLONE="$WORK/clone"; LOG="$WORK/gate.log"; : > "$LOG"
52
+ URL="${CREWLY_LOCAL_GATE_CLONE_URL:-https://github.com/$REPO.git}"
53
+
54
+ git init -q "$CLONE" || fail "git init failed"
55
+ (
56
+ cd "$CLONE" || exit 1
57
+ git remote add origin "$URL"
58
+ git -c credential.helper= -c "credential.helper=!$GH auth git-credential" fetch -q --depth 1 origin "pull/$PR/head" \
59
+ && [ "$(git rev-parse FETCH_HEAD)" = "$SHA" ] \
60
+ && git checkout -q --detach FETCH_HEAD
61
+ ) >"$WORK/fetch.log" 2>&1 || fail "could not check out PR #$PR at $SHA in a fresh clone (the PR may have moved): $(tail -3 "$WORK/fetch.log" | tr '\n' ' ')"
62
+
63
+ echo "record-local-gate: $REPO PR #$PR at $SHA (base $BASE_REF $BASE_SHA), $N_CMDS gate command(s), fresh clone $CLONE"
64
+ STARTED="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
65
+ EXIT_CODE=0; RAN=""
66
+ while IFS= read -r CMD; do
67
+ [ -n "$CMD" ] || continue
68
+ RAN="${RAN:+$RAN && }$CMD"
69
+ echo "record-local-gate: running: $CMD"
70
+ # A scrubbed environment: no tokens or session secrets reach the repo's scripts.
71
+ (cd "$CLONE" && env -i PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" LANG="${LANG:-en_US.UTF-8}" CI=true bash -c "set -o pipefail; $CMD") 2>&1 | tee -a "$LOG"
72
+ RC="${PIPESTATUS[0]}"
73
+ if [ "$RC" -ne 0 ]; then EXIT_CODE="$RC"; echo "record-local-gate: FAILED (exit $RC): $CMD"; break; fi
74
+ done < <(printf '%s' "$ENTRY" | jq -r '.gate[]')
75
+ FINISHED="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
76
+
77
+ DIR="$RECORDS_ROOT/${REPO/\//__}"
78
+ mkdir -p "$DIR" || fail "could not create $DIR"
79
+ OUT="$DIR/pr-$PR-$SHA.json"
80
+ tail -n "$LOG_TAIL_LINES" "$LOG" | jq -Rs --arg sha "$SHA" --arg baseRef "$BASE_REF" --arg baseSha "$BASE_SHA" --arg command "$RAN" \
81
+ --argjson exitCode "$EXIT_CODE" --arg startedAt "$STARTED" --arg finishedAt "$FINISHED" --arg by "${CREWLY_SESSION_NAME:-unknown}" \
82
+ '{sha:$sha, baseRef:$baseRef, baseSha:$baseSha, command:$command, exitCode:$exitCode, startedAt:$startedAt, finishedAt:$finishedAt, logTail:(split("\n")|map(select(length>0))), recordedBy:$by}' \
83
+ > "$OUT.tmp" && mv "$OUT.tmp" "$OUT" || fail "could not write the record $OUT"
84
+
85
+ echo "record-local-gate: ran $RAN"
86
+ echo "record-local-gate: exit code $EXIT_CODE; record: $OUT"
87
+ exit "$EXIT_CODE"
@@ -0,0 +1,64 @@
1
+ #!/bin/bash
2
+ # Tests for record-local-gate. Offline: `gh` is a stub, the "remote" is a local repo with
3
+ # refs/pull/7/head, and the gate commands are `true` / `false`.
4
+ set -uo pipefail
5
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ PASS=0; FAIL=0
7
+ check() {
8
+ if [ "$2" = "$3" ]; then PASS=$((PASS + 1)); echo " ✓ $1"
9
+ else FAIL=$((FAIL + 1)); echo " ✗ $1"; echo " expected: $2"; echo " actual: $3"; fi
10
+ }
11
+
12
+ T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
13
+ REMOTE="$T/remote"
14
+ git init -q -b main "$REMOTE"
15
+ git -C "$REMOTE" -c user.name=t -c user.email=t@t commit -q --allow-empty -m base
16
+ git -C "$REMOTE" -c user.name=t -c user.email=t@t commit -q --allow-empty -m pr
17
+ SHA="$(git -C "$REMOTE" rev-parse HEAD)"
18
+ git -C "$REMOTE" update-ref refs/pull/7/head "$SHA"
19
+
20
+ # gh stub: pr view -> head sha + base; api commits/main --jq .sha -> base sha
21
+ cat > "$T/gh" <<EOF
22
+ #!/bin/bash
23
+ if [ "\$1" = pr ] && [ "\$2" = view ]; then echo '{"headRefOid":"'"\${PR_SHA:-$SHA}"'","baseRefName":"main"}'; exit 0; fi
24
+ if [ "\$1" = api ]; then echo basesha0001; exit 0; fi
25
+ exit 3
26
+ EOF
27
+ chmod +x "$T/gh"
28
+
29
+ gates() { printf '{"o/r":{"gate":%s,"contentPaths":["content/**/*.md"]}}' "$1" > "$T/gates.json"; }
30
+ export CREWLY_GUARD_GH="$T/gh" CREWLY_GUARD_LOCAL_GATES="$T/gates.json" CREWLY_HOME="$T/home" \
31
+ CREWLY_LOCAL_GATE_CLONE_URL="$REMOTE" CREWLY_SESSION_NAME=tester TMPDIR="$T"
32
+ REC="$T/home/local-gates/o__r/pr-7-$SHA.json"
33
+ run() { bash "$SCRIPT_DIR/execute.sh" "$@" >"$T/out" 2>"$T/err"; echo $?; }
34
+
35
+ echo "record-local-gate"
36
+ gates '["true","echo second"]'
37
+ check "passing gate exits 0" 0 "$(run --repo o/r --pr 7)"
38
+ check "record exists for the head sha" yes "$([ -f "$REC" ] && echo yes || echo no)"
39
+ check "record sha is the PR head" "$SHA" "$(jq -r .sha "$REC")"
40
+ check "record exitCode 0" 0 "$(jq .exitCode "$REC")"
41
+ check "record keeps the base sha at run time" basesha0001 "$(jq -r .baseSha "$REC")"
42
+ check "record names who recorded it" tester "$(jq -r .recordedBy "$REC")"
43
+ check "record lists the commands run" "true && echo second" "$(jq -r .command "$REC")"
44
+ check "output says what ran and the exit code" 1 "$(grep -c 'exit code 0; record:' "$T/out")"
45
+
46
+ rm -f "$REC"
47
+ gates '["true","false","echo never"]'
48
+ check "failing gate exits non-zero" 1 "$(run --repo o/r --pr 7)"
49
+ check "failing gate still writes a record" yes "$([ -f "$REC" ] && echo yes || echo no)"
50
+ check "failing record has exitCode != 0" 1 "$(jq .exitCode "$REC")"
51
+ check "gate stops at the first failure" 0 "$(grep -c '^never' "$T/out")"
52
+
53
+ rm -f "$REC"
54
+ gates '["true"]'
55
+ check "--exit-code is refused" 2 "$(run --repo o/r --pr 7 --exit-code 0)"
56
+ check "no record after a refused flag" no "$([ -f "$REC" ] && echo yes || echo no)"
57
+ check "a repo with no entry is refused" 1 "$(run --repo x/y --pr 7)"
58
+ check "a missing --pr is refused" 2 "$(run --repo o/r)"
59
+
60
+ check "a PR that moved since gh looked is refused" 1 "$(PR_SHA=deadbeefdeadbeef run --repo o/r --pr 7)"
61
+ check "no record for the moved head" 0 "$(find "$T/home" -name 'pr-7-deadbeef*' 2>/dev/null | wc -l | tr -d ' ')"
62
+
63
+ echo "Passed: $PASS Failed: $FAIL"
64
+ [ "$FAIL" -eq 0 ] && [ "$PASS" -gt 0 ]
@@ -8379,6 +8379,10 @@ export declare const EXPERIMENT_CONSTANTS: {
8379
8379
  /** A check-in whose process read (or send) failed is retried after this, doubling up to the max */
8380
8380
  readonly CHECK_IN_RETRY_MIN_MS: number;
8381
8381
  readonly CHECK_IN_RETRY_MAX_MS: number;
8382
+ /** A supervisor session name (#1026): what an agent session name looks like */
8383
+ readonly SUPERVISOR_PATTERN: RegExp;
8384
+ /** Statuses in which a card's supervisor and creator may read its autopilot project (access ends when measured or cancelled) */
8385
+ readonly SUPERVISION_STATUSES: readonly string[];
8382
8386
  };
8383
8387
  /**
8384
8388
  * Release input-guard check (specs/2026-10-04-release-input-guard-check.md):