@event4u/agent-config 9.27.0 → 9.29.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/AGENTS.md +13 -12
- package/CHANGELOG.md +165 -0
- package/README.md +1 -1
- package/agents/templates/.ai-council.yml.example +55 -24
- package/dist/agent-src/commands/analyze/conformance.md +24 -0
- package/dist/agent-src/commands/cost/report.md +2 -2
- package/dist/agent-src/commands/team/adversarial.md +8 -7
- package/dist/agent-src/commands/team/delegate.md +13 -12
- package/dist/agent-src/commands/team/review.md +9 -8
- package/dist/agent-src/commands/team/status.md +9 -8
- package/dist/agent-src/commands/team.md +29 -19
- package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +88 -2
- package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +29 -21
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +26 -15
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +28 -3
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +54 -0
- package/dist/agent-src/contexts/execution/subagent-routing.md +13 -11
- package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +25 -0
- package/dist/agent-src/contexts/execution/subagent-steering.md +6 -3
- package/dist/agent-src/rules/artifact-engagement-recording.md +4 -1
- package/dist/agent-src/rules/autonomous-execution.md +1 -1
- package/dist/agent-src/rules/brand-source-of-truth.md +33 -6
- package/dist/agent-src/rules/context-hygiene.md +3 -1
- package/dist/agent-src/rules/council-availability.md +93 -0
- package/dist/agent-src/rules/cross-source-consistency.md +3 -0
- package/dist/agent-src/rules/delegation-policy.md +38 -10
- package/dist/agent-src/rules/design-fidelity.md +3 -0
- package/dist/agent-src/rules/evaluator-independence.md +2 -1
- package/dist/agent-src/rules/external-code-graph-interop.md +2 -1
- package/dist/agent-src/rules/finance-safety-floor.md +1 -1
- package/dist/agent-src/rules/git-history-discipline.md +1 -1
- package/dist/agent-src/rules/history-discipline.md +3 -1
- package/dist/agent-src/rules/legal-safety-floor.md +1 -1
- package/dist/agent-src/rules/no-pr-progress-comments.md +3 -1
- package/dist/agent-src/rules/onboarding-gate.md +3 -1
- package/dist/agent-src/rules/roadmap-ci-steps-policy.md +4 -1
- package/dist/agent-src/rules/role-mode-adherence.md +1 -1
- package/dist/agent-src/rules/scale-discipline.md +3 -1
- package/dist/agent-src/rules/self-repair-loop.md +103 -0
- package/dist/agent-src/rules/session-canary.md +2 -1
- package/dist/agent-src/rules/settings-ask-protocol.md +8 -7
- package/dist/agent-src/rules/strategy-safety-floor.md +1 -1
- package/dist/agent-src/rules/token-budget-discipline.md +4 -2
- package/dist/agent-src/rules/tool-safety.md +5 -1
- package/dist/agent-src/skills/adversarial-review/SKILL.md +1 -1
- package/dist/agent-src/skills/analysis-autonomous-mode/SKILL.md +1 -1
- package/dist/agent-src/skills/forensics-report/SKILL.md +114 -0
- package/dist/agent-src/skills/forensics-report/evals/triggers.json +17 -0
- package/dist/agent-src/skills/performance-analysis/SKILL.md +1 -1
- package/dist/agent-src/skills/persona-improvement/SKILL.md +1 -1
- package/dist/agent-src/skills/premortem/SKILL.md +30 -1
- package/dist/agent-src/skills/project-analyzer/SKILL.md +1 -1
- package/dist/agent-src/skills/reasoning-orchestrator/SKILL.md +2 -1
- package/dist/agent-src/skills/security-audit/SKILL.md +1 -1
- package/dist/agent-src/skills/sequential-thinking/SKILL.md +1 -1
- package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +1 -1
- package/dist/agent-src/skills/subagent-orchestration/SKILL.md +7 -5
- package/dist/agent-src/skills/subagent-orchestration/schemas/subagent-status.json +61 -3
- package/dist/agent-src/skills/test-case-discovery/SKILL.md +1 -1
- package/dist/agent-src/skills/universal-project-analysis/SKILL.md +1 -1
- package/dist/agent-src/templates/AGENTS.md +8 -12
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +23 -0
- package/dist/cli/registry.js +7 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-66OVWGB5.js +270 -0
- package/dist/cli-delegate/{chunk-HJ6DAJHR.js → chunk-LCFJIQ4J.js} +89 -7
- package/dist/cli-delegate/{chunk-VSYT7OHO.js → chunk-LVRYBKTI.js} +31 -2
- package/dist/cli-delegate/{chunk-DVRS2KVR.js → chunk-MMXA5GY7.js} +10 -17
- package/dist/cli-delegate/{chunk-WR6GTRHJ.js → chunk-MP3JT3IU.js} +3 -3
- package/dist/cli-delegate/{chunk-3KJIMFTB.js → chunk-NZ5N2XQL.js} +467 -305
- package/dist/cli-delegate/{chunk-AIIUU73N.js → chunk-OJM3MBJM.js} +1 -1
- package/dist/cli-delegate/{chunk-BCPIOAGV.js → chunk-RCIA67XN.js} +117 -96
- package/dist/cli-delegate/chunk-REKCMYOU.js +2991 -0
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +134 -0
- package/dist/cli-delegate/cmd_conformance.js +10 -47
- package/dist/cli-delegate/cmd_converge.js +3 -3
- package/dist/cli-delegate/cmd_doctor.js +10 -6
- package/dist/cli-delegate/cmd_explain.js +80 -330
- package/dist/cli-delegate/cmd_export.js +6 -6
- package/dist/cli-delegate/cmd_fleet.js +3 -3
- package/dist/cli-delegate/cmd_handoff.js +3 -3
- package/dist/cli-delegate/cmd_mcp_available.js +433 -0
- package/dist/cli-delegate/cmd_migrate.js +3 -3
- package/dist/cli-delegate/cmd_packs_active.js +153 -0
- package/dist/cli-delegate/cmd_preflight.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +6 -6
- package/dist/cli-delegate/cmd_route_audit.js +8 -8
- package/dist/cli-delegate/cmd_route_explain.js +4 -4
- package/dist/cli-delegate/cmd_settings_get.js +266 -0
- package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
- package/dist/cli-delegate/cmd_settings_set.js +8 -2968
- package/dist/cli-delegate/cmd_sync.js +6 -6
- package/dist/cli-delegate/cmd_uninstall.js +6 -6
- package/dist/cli-delegate/cmd_update.js +4 -4
- package/dist/cli-delegate/cmd_upgrade.js +4 -4
- package/dist/cli-delegate/cmd_validate.js +6 -6
- package/dist/cli-delegate/cmd_versions.js +4 -4
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +164 -55
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -3
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +44 -5
- package/dist/discovery/trust-report.md +11 -11
- package/dist/discovery/workspaces.json +16 -6
- package/dist/hooks/dispatch.js +9341 -6131
- package/dist/install/install.mjs +739 -524
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +777 -281
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_settings.js +134 -15
- package/dist/scripts/_lib/agent_settings.js.map +1 -1
- package/dist/scripts/condense.js +147 -2
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/schemas/settings.js +6 -7
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/shared/settingsCarveOut.js +0 -9
- package/dist/shared/settingsCarveOut.js.map +1 -1
- package/dist/shared/settingsClasses.js +11 -9
- package/dist/shared/settingsClasses.js.map +1 -1
- package/dist/ui/assets/{index-DmR0mbk4.js → index-JDAF00bC.js} +2 -2
- package/dist/ui/assets/{index-DmR0mbk4.js.map → index-JDAF00bC.js.map} +1 -1
- package/dist/ui/index.html +1 -1
- package/docs/contracts/settings-classes.md +68 -17
- package/docs/guidelines/agent-infra/memory-access.md +14 -3
- package/package.json +1 -1
- package/src/agent-src/templates/AGENTS.md +8 -12
- package/src/config/agent-settings.template.yml +52 -66
- package/src/config/budgets.yml +29 -0
- package/src/config/discovery/packs.yml +11 -0
- package/src/config/discovery/workspaces.yml +1 -1
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +26 -0
- package/src/config/gate-violation-baselines.json +3 -3
- package/src/scripts/_cli/cmd_brand_status.ts +174 -0
- package/src/scripts/_cli/cmd_conformance.ts +14 -42
- package/src/scripts/_cli/cmd_doctor.ts +65 -48
- package/src/scripts/_cli/cmd_mcp_available.ts +266 -0
- package/src/scripts/_cli/cmd_packs_active.ts +225 -0
- package/src/scripts/_cli/cmd_settings_get.ts +324 -0
- package/src/scripts/_dispatch.bash +57 -0
- package/src/scripts/_lib/agent_settings.ts +140 -16
- package/src/scripts/_lib/auto_dispatch.corpus.test.ts +1 -1
- package/src/scripts/_lib/auto_dispatch.ts +23 -18
- package/src/scripts/_lib/capsule_trigger.ts +124 -0
- package/src/scripts/_lib/carrier_divergence.ts +135 -0
- package/src/scripts/_lib/host_capability.ts +144 -4
- package/src/scripts/_lib/judgment_ladder.ts +382 -0
- package/src/scripts/_lib/orchestration_gate.ts +8 -0
- package/src/scripts/_lib/orchestration_record.ts +37 -0
- package/src/scripts/_lib/orchestration_savings.ts +1 -1
- package/src/scripts/_lib/prompt_shape.ts +88 -0
- package/src/scripts/_lib/review_skipped_record.ts +102 -0
- package/src/scripts/_lib/rule_layer_overlap.ts +254 -0
- package/src/scripts/_lib/self_repair.ts +489 -0
- package/src/scripts/_lib/self_repair_store.ts +128 -0
- package/src/scripts/_lib/subagent_capsule.ts +182 -0
- package/src/scripts/_lib/subagent_response.ts +19 -0
- package/src/scripts/_lib/subagent_spawn.ts +13 -0
- package/src/scripts/_lib/subagent_steering.ts +9 -4
- package/src/scripts/_lib/tier_budget_routing.ts +6 -1
- package/src/scripts/_lib/transcript_entry.ts +80 -0
- package/src/scripts/_lib/worker_budget.ts +37 -2
- package/src/scripts/ai_council/clients.ts +76 -54
- package/src/scripts/ai_council/config.ts +94 -6
- package/src/scripts/ai_council/handoff.ts +113 -0
- package/src/scripts/ai_council/modes.ts +19 -4
- package/src/scripts/ai_council/orchestrator.ts +105 -0
- package/src/scripts/ai_council/quorum.ts +84 -0
- package/src/scripts/ai_council/session.ts +44 -0
- package/src/scripts/ai_council/transport_resolver.ts +127 -1
- package/src/scripts/ai_team/availability.ts +110 -0
- package/src/scripts/ai_team/config.ts +34 -14
- package/src/scripts/ai_team/team_dispatch.ts +71 -29
- package/src/scripts/bench_critic_protocol.ts +396 -0
- package/src/scripts/check_branch_freshness.ts +255 -0
- package/src/scripts/check_council_config_location.ts +125 -1
- package/src/scripts/check_detector_corpus.ts +202 -0
- package/src/scripts/check_hook_bundle_freshness.ts +4 -1
- package/src/scripts/check_standing_rule_delivery.ts +314 -0
- package/src/scripts/condense.ts +162 -2
- package/src/scripts/config/profiles.ts +13 -3
- package/src/scripts/conformance_scan.ts +272 -24
- package/src/scripts/council_availability_hook.ts +188 -0
- package/src/scripts/council_cli.ts +527 -32
- package/src/scripts/forensics_report.ts +333 -0
- package/src/scripts/hook_manifest.yaml +107 -26
- package/src/scripts/hooks/block_no_verify.ts +39 -2
- package/src/scripts/hooks/block_unauthorized_git.ts +156 -7
- package/src/scripts/hooks/concern_registry.ts +10 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +484 -0
- package/src/scripts/hooks/end_review_nudge_hook.ts +726 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +342 -0
- package/src/scripts/install.ts +151 -0
- package/src/scripts/language_mirror_hook.ts +30 -37
- package/src/scripts/lint_discovery_vocabulary.ts +2 -0
- package/src/scripts/lint_never_silent.ts +276 -0
- package/src/scripts/lint_no_activation_gates.ts +271 -0
- package/src/scripts/measure_scope_dedup.ts +19 -13
- package/src/scripts/onboarding_gate_hook.ts +40 -1
- package/src/scripts/orchestration_record.ts +12 -1
- package/src/scripts/preamble_byte_census.ts +9 -20
- package/src/scripts/rebuild_hook_bundle.ts +177 -0
- package/src/scripts/release.ts +74 -2
- package/src/scripts/report_carrier_divergence.ts +364 -0
- package/src/scripts/report_skill_activation.ts +257 -0
- package/src/scripts/report_skill_obligation_violations.ts +579 -0
- package/src/scripts/routing_doctor.ts +95 -39
- package/src/scripts/score_skill_selection.ts +19 -5
- package/src/scripts/self_repair_cli.ts +470 -0
- package/src/scripts/self_repair_hook.ts +241 -0
- package/src/scripts/team_events_spike.ts +271 -0
- package/src/scripts/team_review_gate_hook.ts +31 -9
- package/src/server/schemas/settings.ts +10 -17
- package/src/shared/settingsCarveOut.ts +0 -10
- package/src/shared/settingsClasses.ts +11 -9
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Critic-protocol A/B runner (road-to-judgment-and-forensic-evidence Phase 2).
|
|
3
|
+
*
|
|
4
|
+
* Measures the `load_bearing` critic protocol against the shipped `legacy`
|
|
5
|
+
* adversarial-skeptic posture on the frozen corpus
|
|
6
|
+
* (internal/bench/adversarial-council/corpus.json) — the corpus on which the
|
|
7
|
+
* legacy posture scored a measured 100% false-positive rate on the 3
|
|
8
|
+
* controversial-but-correct clean controls (run 2026-07-21).
|
|
9
|
+
*
|
|
10
|
+
* Protocol (pre-registered in docs/CLAIMS.md `critic-protocol-load-bearing-ab`
|
|
11
|
+
* BEFORE any paid run; thresholds, prompts, and scorer semantics frozen there):
|
|
12
|
+
*
|
|
13
|
+
* Both arms run as ONE independent single-shot review per vendor
|
|
14
|
+
* (anthropic + openai, direct client calls — NEVER council_cli transport)
|
|
15
|
+
* over all 15 corpus items (12 defect fixtures + 3 clean controls).
|
|
16
|
+
*
|
|
17
|
+
* PER VENDOR, the load_bearing arm promotes iff BOTH:
|
|
18
|
+
* (1) FP rate on the 3 clean controls < 50%, AND
|
|
19
|
+
* (2) TP count on the 12 defect fixtures >= 80% of the legacy arm's
|
|
20
|
+
* TP count in the same run.
|
|
21
|
+
*
|
|
22
|
+
* Scoring is the existing deterministic scorer applied IDENTICALLY to both
|
|
23
|
+
* arms on findings only (caughtDefect / isFalsePositive). The protocol's
|
|
24
|
+
* `verdict` field is a secondary published signal; "flawed" with empty
|
|
25
|
+
* findings counts as `incoherent` (2026-08-09 council design pass) — never
|
|
26
|
+
* as a catch, never as an FP.
|
|
27
|
+
*
|
|
28
|
+
* `--mock` validates the pipeline in BOTH directions with zero spend (a
|
|
29
|
+
* passing arm must read PASS, a failing arm must read FAIL) — the roadmap's
|
|
30
|
+
* pre-spend tripwire. `--run` fires the paid cross-vendor calls
|
|
31
|
+
* (maintainer spend-gated). `--out <file>` writes the report JSON.
|
|
32
|
+
*
|
|
33
|
+
* The `legacy` default and the `critic_protocol` config key live in
|
|
34
|
+
* scripts/ai_council/config.ts; this runner takes the arms from the corpus
|
|
35
|
+
* loop directly (an A/B needs both regardless of the configured value).
|
|
36
|
+
*/
|
|
37
|
+
// ledger-exempt: bench runner, not a CI gate — spend-gated on-demand measurement
|
|
38
|
+
// with a fixed 15-item corpus; the report itself carries the full denominator.
|
|
39
|
+
import * as fs from 'node:fs';
|
|
40
|
+
import * as path from 'node:path';
|
|
41
|
+
|
|
42
|
+
import {
|
|
43
|
+
AnthropicClient,
|
|
44
|
+
OpenAIClient,
|
|
45
|
+
load_anthropic_key,
|
|
46
|
+
load_openai_key,
|
|
47
|
+
type ExternalAIClient,
|
|
48
|
+
} from './ai_council/clients.js';
|
|
49
|
+
import {
|
|
50
|
+
caughtDefect,
|
|
51
|
+
isFalsePositive,
|
|
52
|
+
categoryFamily,
|
|
53
|
+
type Finding,
|
|
54
|
+
type GroundTruth,
|
|
55
|
+
} from './_lib/adversarial_bench_score.js';
|
|
56
|
+
|
|
57
|
+
const REPO_ROOT = process.cwd();
|
|
58
|
+
const CORPUS_PATH = path.join(REPO_ROOT, 'internal/bench/adversarial-council/corpus.json');
|
|
59
|
+
|
|
60
|
+
/** Pre-registered thresholds — frozen with the CLAIMS entry, never adjusted. */
|
|
61
|
+
const FP_CEILING = 0.5; // strict: FP rate must be < 0.5 on the 3 controls
|
|
62
|
+
const TP_RETENTION_FLOOR = 0.8; // load_bearing TP >= 0.8 × legacy TP, per vendor
|
|
63
|
+
|
|
64
|
+
interface CorpusItem {
|
|
65
|
+
id: string;
|
|
66
|
+
class: string;
|
|
67
|
+
defect_category: string;
|
|
68
|
+
is_clean: boolean;
|
|
69
|
+
files: { path: string; content: string }[];
|
|
70
|
+
ground_truth: { defect_files: string[]; defect_summary: string };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function loadCorpus(): CorpusItem[] {
|
|
74
|
+
return JSON.parse(fs.readFileSync(CORPUS_PATH, 'utf8')).items as CorpusItem[];
|
|
75
|
+
}
|
|
76
|
+
function groundTruth(item: CorpusItem): GroundTruth {
|
|
77
|
+
return {
|
|
78
|
+
id: item.id,
|
|
79
|
+
is_clean: item.is_clean,
|
|
80
|
+
defect_files: item.ground_truth.defect_files,
|
|
81
|
+
defect_category: item.is_clean ? 'none' : categoryFamily(item.defect_category),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function codeBlock(item: CorpusItem): string {
|
|
85
|
+
return item.files.map((f) => `FILE: ${f.path}\n\`\`\`ts\n${f.content}\`\`\``).join('\n\n');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const FINDINGS_SHAPE =
|
|
89
|
+
'{"findings":[{"file":"<file path>","category":"<defect category>","confidence":"high|medium|low","summary":"<one line>"}]}';
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* LEGACY arm — byte-identical to the prompt that produced the measured 100%
|
|
93
|
+
* FP on the clean controls (bench_adversarial_council.ts skepticPrompt).
|
|
94
|
+
*/
|
|
95
|
+
function legacyPrompt(item: CorpusItem): string {
|
|
96
|
+
return [
|
|
97
|
+
'You are an adversarial security/correctness skeptic. Assume this code may hide a subtle, non-obvious defect that a normal review missed — a masked access-control/SSRF/traversal flaw, a boundary/logic inversion, a concurrency or resource-leak edge, a cross-file contract mismatch. Hunt for it. Report only defects you can concretely justify; if after hard scrutiny it is genuinely correct, report an empty array (do not fabricate).',
|
|
98
|
+
`Respond with STRICT JSON only, exactly: ${FINDINGS_SHAPE}`,
|
|
99
|
+
'',
|
|
100
|
+
codeBlock(item),
|
|
101
|
+
].join('\n');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const PROTOCOL_SHAPE =
|
|
105
|
+
'{"invariant":"<one sentence>","load_bearing_assumption":"<one sentence, names file+function>","failure_scenario_if_broken":"<one sentence>","cost_of_avoidance":"<one sentence>","succeeded_would_do":"<one sentence or \'nothing\'>","verdict":"holds|flawed","findings":[{"file":"<file path>","category":"<defect category>","confidence":"high|medium|low","summary":"<one line>"}]}';
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* LOAD_BEARING arm — the fixed protocol. Design integrated the 2026-08-09
|
|
109
|
+
* council convergence: the assumption must be tied to a named file+function
|
|
110
|
+
* (no platitudes), a concrete failure scenario forces the assumption to be
|
|
111
|
+
* load-bearing, and "holds" is an explicitly permitted, completed review.
|
|
112
|
+
*/
|
|
113
|
+
function loadBearingPrompt(item: CorpusItem): string {
|
|
114
|
+
return [
|
|
115
|
+
'You are a rigorous correctness critic running a fixed protocol. Do not free-associate defects. Work strictly in this order:',
|
|
116
|
+
'1. State the invariant this code must maintain to be correct, then name the SINGLE load-bearing assumption it rests on — the one whose failure would break that invariant. Tie it to a named file and function; a platitude ("inputs are valid") is not an assumption.',
|
|
117
|
+
'2. State the concrete failure scenario if that assumption broke: which input or state leads to which wrong behaviour. If you cannot state one, the assumption is not load-bearing — pick again.',
|
|
118
|
+
'3. State the cost of what this code is avoiding: what failure mode or complexity does its current shape avert?',
|
|
119
|
+
"4. State what someone who has already built a comparable thing successfully would do differently here, if anything ('nothing' is acceptable).",
|
|
120
|
+
'5. Now inspect the assumption against the code. If it survives inspection and the failure scenario is unreachable, your verdict is "holds" and findings is []. A defensible "holds" with the assumption and scenario stated is a completed review, not a refusal. If the assumption breaks, or the failure scenario is reachable, or you find another concrete defect you can justify against specific lines, your verdict is "flawed" and findings lists each one.',
|
|
121
|
+
`Respond with STRICT JSON only, exactly: ${PROTOCOL_SHAPE}`,
|
|
122
|
+
'',
|
|
123
|
+
codeBlock(item),
|
|
124
|
+
].join('\n');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const SYSTEM_JSON =
|
|
128
|
+
'You are a code reviewer. Output ONLY one JSON object — no prose, no markdown, no code fences. ' +
|
|
129
|
+
'If the code is correct, the "findings" array is empty. Do not include any text before or after the JSON object.';
|
|
130
|
+
|
|
131
|
+
interface ArmResponse {
|
|
132
|
+
findings: Finding[];
|
|
133
|
+
verdict: 'holds' | 'flawed' | null; // null for the legacy arm (no verdict field)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function extractJsonObject(text: string): Record<string, unknown> | null {
|
|
137
|
+
if (!text) return null;
|
|
138
|
+
const start = text.indexOf('{');
|
|
139
|
+
if (start < 0) return null;
|
|
140
|
+
let depth = 0;
|
|
141
|
+
let end = -1;
|
|
142
|
+
for (let i = start; i < text.length; i++) {
|
|
143
|
+
if (text[i] === '{') depth++;
|
|
144
|
+
else if (text[i] === '}' && --depth === 0) {
|
|
145
|
+
end = i;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (end < 0) return null;
|
|
150
|
+
try {
|
|
151
|
+
return JSON.parse(text.slice(start, end + 1)) as Record<string, unknown>;
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseArmResponse(text: string): ArmResponse {
|
|
158
|
+
const obj = extractJsonObject(text);
|
|
159
|
+
if (!obj) return { findings: [], verdict: null };
|
|
160
|
+
const arr = Array.isArray(obj.findings) ? obj.findings : [];
|
|
161
|
+
const findings = arr
|
|
162
|
+
.filter((f): f is Record<string, unknown> => typeof f === 'object' && f !== null)
|
|
163
|
+
.map((f) => ({
|
|
164
|
+
file: String(f.file ?? ''),
|
|
165
|
+
category: String(f.category ?? ''),
|
|
166
|
+
confidence: (['high', 'medium', 'low'].includes(String(f.confidence))
|
|
167
|
+
? String(f.confidence)
|
|
168
|
+
: 'high') as 'high' | 'medium' | 'low',
|
|
169
|
+
}))
|
|
170
|
+
.filter((f) => f.file.length > 0);
|
|
171
|
+
const verdict = obj.verdict === 'holds' || obj.verdict === 'flawed' ? obj.verdict : null;
|
|
172
|
+
return { findings, verdict };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
type Arm = 'legacy' | 'load_bearing';
|
|
176
|
+
type Vendor = 'anthropic' | 'openai';
|
|
177
|
+
const ARMS: readonly Arm[] = ['legacy', 'load_bearing'];
|
|
178
|
+
const VENDORS: readonly Vendor[] = ['anthropic', 'openai'];
|
|
179
|
+
|
|
180
|
+
/** fixtureId → arm → vendor → response */
|
|
181
|
+
type Reviews = Record<string, Record<Arm, Record<Vendor, ArmResponse>>>;
|
|
182
|
+
|
|
183
|
+
let _clients: Record<Vendor, ExternalAIClient> | null = null;
|
|
184
|
+
function getClients(): Record<Vendor, ExternalAIClient> {
|
|
185
|
+
if (!_clients) {
|
|
186
|
+
_clients = {
|
|
187
|
+
anthropic: new AnthropicClient({ model: 'claude-sonnet-4-5', api_key: load_anthropic_key() }),
|
|
188
|
+
openai: new OpenAIClient({ model: 'gpt-4o', api_key: load_openai_key() }),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
return _clients;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function realReviews(items: CorpusItem[]): Promise<Reviews> {
|
|
195
|
+
const cs = getClients();
|
|
196
|
+
const out: Reviews = {};
|
|
197
|
+
for (const item of items) {
|
|
198
|
+
const perArm = { legacy: {}, load_bearing: {} } as Reviews[string];
|
|
199
|
+
out[item.id] = perArm;
|
|
200
|
+
for (const arm of ARMS) {
|
|
201
|
+
const prompt = arm === 'legacy' ? legacyPrompt(item) : loadBearingPrompt(item);
|
|
202
|
+
for (const vendor of VENDORS) {
|
|
203
|
+
let text = '';
|
|
204
|
+
try {
|
|
205
|
+
const resp = await cs[vendor].ask(SYSTEM_JSON, prompt, 1600);
|
|
206
|
+
text = resp.text ?? '';
|
|
207
|
+
} catch (e) {
|
|
208
|
+
process.stderr.write(` ${vendor} error on ${item.id}/${arm}: ${(e as Error).message}\n`);
|
|
209
|
+
}
|
|
210
|
+
const r = parseArmResponse(text);
|
|
211
|
+
perArm[arm][vendor] = r;
|
|
212
|
+
process.stderr.write(
|
|
213
|
+
` ${arm} ${item.id} ${vendor}: findings=${String(r.findings.length)} verdict=${r.verdict ?? '-'}\n`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Mock reviews for pipeline validation in a chosen direction.
|
|
223
|
+
* `direction: 'pass'` — load_bearing keeps TP parity with legacy and stays
|
|
224
|
+
* silent on the controls. `direction: 'fail'` — load_bearing rubber-stamps
|
|
225
|
+
* (misses defects below the retention floor) so the verdict must read FAIL.
|
|
226
|
+
*/
|
|
227
|
+
function mockReviews(items: CorpusItem[], direction: 'pass' | 'fail'): Reviews {
|
|
228
|
+
const hit = (i: CorpusItem): Finding[] => [
|
|
229
|
+
{
|
|
230
|
+
file: i.ground_truth.defect_files[0] ?? i.files[0]?.path ?? '',
|
|
231
|
+
category: i.defect_category,
|
|
232
|
+
confidence: 'high' as const,
|
|
233
|
+
},
|
|
234
|
+
];
|
|
235
|
+
const fpHit = (i: CorpusItem): Finding[] => [
|
|
236
|
+
{ file: i.files[0]?.path ?? '', category: 'correctness', confidence: 'high' as const },
|
|
237
|
+
];
|
|
238
|
+
const out: Reviews = {};
|
|
239
|
+
const defects = items.filter((i) => !i.is_clean);
|
|
240
|
+
const keepIds = new Set(
|
|
241
|
+
// In 'fail' direction the load_bearing arm catches only half the
|
|
242
|
+
// defects legacy catches — below the 0.8 retention floor.
|
|
243
|
+
defects.filter((_, idx) => idx % 2 === 0).map((d) => d.id),
|
|
244
|
+
);
|
|
245
|
+
for (const item of items) {
|
|
246
|
+
const legacyResp: ArmResponse = item.is_clean
|
|
247
|
+
? { findings: fpHit(item), verdict: null } // legacy FPs every control (the measured defect)
|
|
248
|
+
: { findings: hit(item), verdict: null };
|
|
249
|
+
const lbCatches = item.is_clean ? false : direction === 'pass' || keepIds.has(item.id);
|
|
250
|
+
const lbResp: ArmResponse = item.is_clean
|
|
251
|
+
? { findings: [], verdict: 'holds' }
|
|
252
|
+
: lbCatches
|
|
253
|
+
? { findings: hit(item), verdict: 'flawed' }
|
|
254
|
+
: { findings: [], verdict: 'holds' };
|
|
255
|
+
out[item.id] = {
|
|
256
|
+
legacy: { anthropic: legacyResp, openai: legacyResp },
|
|
257
|
+
load_bearing: { anthropic: lbResp, openai: lbResp },
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
interface VendorArmScore {
|
|
264
|
+
tp: number;
|
|
265
|
+
fp: number;
|
|
266
|
+
fp_rate: number;
|
|
267
|
+
incoherent: number;
|
|
268
|
+
holds_on_clean: number;
|
|
269
|
+
caught_ids: string[];
|
|
270
|
+
fp_ids: string[];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
interface VendorVerdict {
|
|
274
|
+
legacy: VendorArmScore;
|
|
275
|
+
load_bearing: VendorArmScore;
|
|
276
|
+
tp_retention: number | null;
|
|
277
|
+
fp_pass: boolean;
|
|
278
|
+
tp_pass: boolean;
|
|
279
|
+
promotes: boolean;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function scoreVendor(items: CorpusItem[], reviews: Reviews, vendor: Vendor): VendorVerdict {
|
|
283
|
+
const defects = items.filter((i) => !i.is_clean);
|
|
284
|
+
const controls = items.filter((i) => i.is_clean);
|
|
285
|
+
const armScore = (arm: Arm): VendorArmScore => {
|
|
286
|
+
const caught: string[] = [];
|
|
287
|
+
let incoherent = 0;
|
|
288
|
+
let holdsOnClean = 0;
|
|
289
|
+
for (const d of defects) {
|
|
290
|
+
const r = reviews[d.id]?.[arm]?.[vendor] ?? { findings: [], verdict: null };
|
|
291
|
+
if (caughtDefect(groundTruth(d), r.findings)) caught.push(d.id);
|
|
292
|
+
if (r.verdict === 'flawed' && r.findings.length === 0) incoherent++;
|
|
293
|
+
}
|
|
294
|
+
const fpIds: string[] = [];
|
|
295
|
+
for (const c of controls) {
|
|
296
|
+
const r = reviews[c.id]?.[arm]?.[vendor] ?? { findings: [], verdict: null };
|
|
297
|
+
if (isFalsePositive(groundTruth(c), r.findings)) fpIds.push(c.id);
|
|
298
|
+
if (r.verdict === 'flawed' && r.findings.length === 0) incoherent++;
|
|
299
|
+
if (r.verdict === 'holds') holdsOnClean++;
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
tp: caught.length,
|
|
303
|
+
fp: fpIds.length,
|
|
304
|
+
fp_rate: controls.length ? fpIds.length / controls.length : 0,
|
|
305
|
+
incoherent,
|
|
306
|
+
holds_on_clean: holdsOnClean,
|
|
307
|
+
caught_ids: caught,
|
|
308
|
+
fp_ids: fpIds,
|
|
309
|
+
};
|
|
310
|
+
};
|
|
311
|
+
const legacy = armScore('legacy');
|
|
312
|
+
const load_bearing = armScore('load_bearing');
|
|
313
|
+
const tp_retention = legacy.tp > 0 ? load_bearing.tp / legacy.tp : null;
|
|
314
|
+
const fp_pass = load_bearing.fp_rate < FP_CEILING;
|
|
315
|
+
// A legacy TP of 0 leaves retention undefined. NOT part of the frozen
|
|
316
|
+
// registration (which is silent on a zero baseline) — a conservative
|
|
317
|
+
// in-code rule, unexercised in the recorded run (legacy TP was 10 and 5):
|
|
318
|
+
// retention unshowable against a zero baseline → tp_pass false.
|
|
319
|
+
const tp_pass = tp_retention !== null && tp_retention >= TP_RETENTION_FLOOR;
|
|
320
|
+
return { legacy, load_bearing, tp_retention, fp_pass, tp_pass, promotes: fp_pass && tp_pass };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function buildReport(items: CorpusItem[], reviews: Reviews, mode: string): Record<string, unknown> {
|
|
324
|
+
const perVendor: Record<string, VendorVerdict> = {};
|
|
325
|
+
for (const vendor of VENDORS) perVendor[vendor] = scoreVendor(items, reviews, vendor);
|
|
326
|
+
const promotes = VENDORS.every((v) => perVendor[v]?.promotes === true);
|
|
327
|
+
const detail: Record<string, unknown> = {};
|
|
328
|
+
for (const item of items) {
|
|
329
|
+
const perItem: Record<string, unknown> = { is_clean: item.is_clean };
|
|
330
|
+
for (const arm of ARMS) {
|
|
331
|
+
for (const vendor of VENDORS) {
|
|
332
|
+
const r = reviews[item.id]?.[arm]?.[vendor];
|
|
333
|
+
perItem[`${arm}_${vendor}`] = r
|
|
334
|
+
? { findings: r.findings.length, verdict: r.verdict }
|
|
335
|
+
: null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
detail[item.id] = perItem;
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
mode,
|
|
342
|
+
corpus: 'internal/bench/adversarial-council/corpus.json',
|
|
343
|
+
claim: 'critic-protocol-load-bearing-ab (docs/CLAIMS.md, pre-registered 2026-08-09)',
|
|
344
|
+
protocol:
|
|
345
|
+
'both arms single-shot per vendor over all 15 items; FP on findings only, identical scorer both arms; verdict secondary; flawed+empty findings = incoherent',
|
|
346
|
+
thresholds: { fp_ceiling: FP_CEILING, tp_retention_floor: TP_RETENTION_FLOOR },
|
|
347
|
+
per_vendor: perVendor,
|
|
348
|
+
verdict: {
|
|
349
|
+
promotes,
|
|
350
|
+
direction: promotes ? 'both thresholds met per vendor' : 'at least one threshold missed',
|
|
351
|
+
default_unchanged: 'legacy stays the default regardless (config critic_protocol)',
|
|
352
|
+
},
|
|
353
|
+
detail,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function main(argv: string[]): Promise<number> {
|
|
358
|
+
const mock = argv.includes('--mock');
|
|
359
|
+
const run = argv.includes('--run');
|
|
360
|
+
const outIdx = argv.indexOf('--out');
|
|
361
|
+
const outFile = outIdx >= 0 ? argv[outIdx + 1] : null;
|
|
362
|
+
if (!mock && !run) {
|
|
363
|
+
process.stderr.write('usage: bench_critic_protocol (--mock | --run) [--out <file>]\n');
|
|
364
|
+
return 2;
|
|
365
|
+
}
|
|
366
|
+
const items = loadCorpus();
|
|
367
|
+
if (mock) {
|
|
368
|
+
// Tripwire (roadmap pre-mortem): the pipeline must discriminate in BOTH
|
|
369
|
+
// directions before any paid call.
|
|
370
|
+
const passReport = buildReport(items, mockReviews(items, 'pass'), 'mock-pass');
|
|
371
|
+
const failReport = buildReport(items, mockReviews(items, 'fail'), 'mock-fail');
|
|
372
|
+
const passOk = (passReport.verdict as { promotes: boolean }).promotes === true;
|
|
373
|
+
const failOk = (failReport.verdict as { promotes: boolean }).promotes === false;
|
|
374
|
+
process.stdout.write(JSON.stringify({ mock_pass: passReport, mock_fail: failReport }, null, 2) + '\n');
|
|
375
|
+
if (!passOk || !failOk) {
|
|
376
|
+
process.stderr.write(
|
|
377
|
+
`mock discrimination FAILED: pass-direction promotes=${String(passOk)}, fail-direction rejects=${String(failOk)} — do NOT run paid calls.\n`,
|
|
378
|
+
);
|
|
379
|
+
return 1;
|
|
380
|
+
}
|
|
381
|
+
process.stderr.write('mock discrimination OK in both directions.\n');
|
|
382
|
+
return 0;
|
|
383
|
+
}
|
|
384
|
+
const reviews = await realReviews(items);
|
|
385
|
+
const report = buildReport(items, reviews, 'run');
|
|
386
|
+
const json = JSON.stringify(report, null, 2) + '\n';
|
|
387
|
+
if (outFile) fs.writeFileSync(outFile, json);
|
|
388
|
+
process.stdout.write(json);
|
|
389
|
+
return 0;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
393
|
+
void main(process.argv.slice(2)).then((code) => process.exit(code));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export { parseArmResponse, scoreVendor, buildReport, mockReviews, legacyPrompt, loadBearingPrompt };
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Fail when this branch is behind the base it will be merged into — asked of
|
|
4
|
+
* the REMOTE, not of the local tracking ref.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS — measured, not hypothetical.
|
|
7
|
+
*
|
|
8
|
+
* `feedback-update-branch-before-push` says: behind target → merge the base in
|
|
9
|
+
* and re-run the gates, before the push. On 2026-08-08 that obligation was
|
|
10
|
+
* satisfied in form and missed in substance. The run opened with
|
|
11
|
+
* `git fetch origin --prune`, branched from `origin/main` at `c0a3630eb`,
|
|
12
|
+
* worked for roughly ninety minutes, and pushed without fetching again. Two PRs
|
|
13
|
+
* merged inside that window, one of them shipping the same roadmap phase the
|
|
14
|
+
* branch was implementing. The PR opened `CONFLICTING`, and a concurrent branch
|
|
15
|
+
* had already published a different number for the same census.
|
|
16
|
+
*
|
|
17
|
+
* The failure is not a forgotten rule. It is a live-state fact read once and
|
|
18
|
+
* then carried: `direct-answers` Iron Law 2 forbids exactly that for branch and
|
|
19
|
+
* sync state, and a ninety-minute-old reading is memory, not a check. Nothing
|
|
20
|
+
* caught it, because nothing looked — `/roadmap:next` screens the remote before
|
|
21
|
+
* SELECTION, `task preflight` reads the tree, and the session register is
|
|
22
|
+
* advisory. The obligation was model-carried at the one point where its input
|
|
23
|
+
* decays fastest.
|
|
24
|
+
*
|
|
25
|
+
* THIS GATE ASKS THE REMOTE. A version that compared HEAD against the local
|
|
26
|
+
* `origin/main` ref would reproduce the very defect it exists to catch: that
|
|
27
|
+
* ref is exactly the stale reading that caused the incident. So it runs
|
|
28
|
+
* `git ls-remote`, one network round trip, and compares against the SHA the
|
|
29
|
+
* server reports right now.
|
|
30
|
+
*
|
|
31
|
+
* It never fetches, merges, or rebases for you. Choosing how to catch up is a
|
|
32
|
+
* git-history decision (`git-history-discipline`), and a gate that rewrites
|
|
33
|
+
* history to make itself pass is a worse failure than the one it reports.
|
|
34
|
+
*/
|
|
35
|
+
import { execFileSync } from "node:child_process";
|
|
36
|
+
import fs from "node:fs";
|
|
37
|
+
import os from "node:os";
|
|
38
|
+
import path from "node:path";
|
|
39
|
+
import { fileURLToPath } from "node:url";
|
|
40
|
+
|
|
41
|
+
import { runGateCli, runSelfTest, type SelfTestCase } from "./_lib/gate_self_test.js";
|
|
42
|
+
import { reportScanned } from "./_lib/scan_scope.js";
|
|
43
|
+
|
|
44
|
+
const DEFAULT_BASE = "main";
|
|
45
|
+
const SELF_TEST_MIN_CASES = 3;
|
|
46
|
+
const SELF_TEST_MIN_REJECT = 1;
|
|
47
|
+
const SCRIPT_REL = "src/scripts/check_branch_freshness.ts";
|
|
48
|
+
|
|
49
|
+
function git(args: string[]): string | null {
|
|
50
|
+
try {
|
|
51
|
+
return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The SHA the server reports for `<base>` right now — never a tracking ref. */
|
|
58
|
+
export function remoteHead(base: string, remote = "origin"): string | null {
|
|
59
|
+
const out = git(["ls-remote", "--heads", remote, base]);
|
|
60
|
+
if (out === null || out === "") {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const sha = out.split(/\s+/)[0];
|
|
64
|
+
return sha !== undefined && /^[0-9a-f]{40}$/.test(sha) ? sha : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Is `sha` already contained in HEAD?
|
|
69
|
+
*
|
|
70
|
+
* `merge-base --is-ancestor` needs the object locally. A SHA the server just
|
|
71
|
+
* reported may not be here yet, which is itself the answer — the branch cannot
|
|
72
|
+
* contain a commit it has never seen.
|
|
73
|
+
*/
|
|
74
|
+
export function containsCommit(sha: string): boolean | null {
|
|
75
|
+
if (git(["cat-file", "-e", `${sha}^{commit}`]) === null) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
execFileSync("git", ["merge-base", "--is-ancestor", sha, "HEAD"], { stdio: "ignore" });
|
|
80
|
+
return true;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build a throwaway origin + working clone, and optionally let a SECOND clone
|
|
88
|
+
* push so the working clone's tracking ref goes stale without it noticing.
|
|
89
|
+
*
|
|
90
|
+
* The stale-tracking-ref case is the whole point. A fixture that only advances
|
|
91
|
+
* the remote via the same clone would leave `origin/main` correct locally, and a
|
|
92
|
+
* gate reading the tracking ref would pass it — which is exactly the false green
|
|
93
|
+
* this gate exists to remove.
|
|
94
|
+
*/
|
|
95
|
+
function buildFixture(root: string, opts: { advanceRemote: boolean }): string {
|
|
96
|
+
const origin = path.join(root, "origin.git");
|
|
97
|
+
const work = path.join(root, "work");
|
|
98
|
+
fs.mkdirSync(origin, { recursive: true });
|
|
99
|
+
const g = (args: string[], at: string): void => {
|
|
100
|
+
execFileSync("git", args, { cwd: at, stdio: "ignore" });
|
|
101
|
+
};
|
|
102
|
+
const write = (at: string, name: string, body: string): void => {
|
|
103
|
+
fs.writeFileSync(path.join(at, name), body);
|
|
104
|
+
g(["add", "-A"], at);
|
|
105
|
+
g(["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", name], at);
|
|
106
|
+
};
|
|
107
|
+
g(["init", "--bare", "-b", DEFAULT_BASE], origin);
|
|
108
|
+
g(["init", "-b", DEFAULT_BASE, work], root);
|
|
109
|
+
g(["remote", "add", "origin", origin], work);
|
|
110
|
+
write(work, "a.txt", "one");
|
|
111
|
+
g(["push", "-u", "origin", DEFAULT_BASE], work);
|
|
112
|
+
g(["checkout", "-b", "feat/x"], work);
|
|
113
|
+
write(work, "b.txt", "two");
|
|
114
|
+
if (opts.advanceRemote) {
|
|
115
|
+
const other = path.join(root, "other");
|
|
116
|
+
g(["clone", origin, other], root);
|
|
117
|
+
write(other, "c.txt", "three");
|
|
118
|
+
g(["push"], other);
|
|
119
|
+
}
|
|
120
|
+
return work;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function selfTest(repoRoot: string): number {
|
|
124
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cbf-selftest-"));
|
|
125
|
+
const run = (advanceRemote: boolean, onBase: boolean): number => {
|
|
126
|
+
const dir = fs.mkdtempSync(path.join(root, "case-"));
|
|
127
|
+
const work = buildFixture(dir, { advanceRemote });
|
|
128
|
+
if (onBase) {
|
|
129
|
+
execFileSync("git", ["checkout", DEFAULT_BASE], { cwd: work, stdio: "ignore" });
|
|
130
|
+
}
|
|
131
|
+
// The child must not inherit a CI flag, or every case would no-op green.
|
|
132
|
+
delete process.env["CI"];
|
|
133
|
+
delete process.env["GITHUB_ACTIONS"];
|
|
134
|
+
return runGateCli(repoRoot, SCRIPT_REL, ["--quiet"], work);
|
|
135
|
+
};
|
|
136
|
+
const cases: SelfTestCase[] = [
|
|
137
|
+
{
|
|
138
|
+
name: "a branch whose tracking ref is stale while the remote has moved is REFUSED",
|
|
139
|
+
expect: "reject",
|
|
140
|
+
run: () => run(true, false),
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: "a branch containing the remote base passes",
|
|
144
|
+
expect: "accept",
|
|
145
|
+
run: () => run(false, false),
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "standing on the base branch itself is a no-op, not a refusal",
|
|
149
|
+
expect: "accept",
|
|
150
|
+
run: () => run(false, true),
|
|
151
|
+
},
|
|
152
|
+
];
|
|
153
|
+
try {
|
|
154
|
+
return runSelfTest({
|
|
155
|
+
gate: "check_branch_freshness",
|
|
156
|
+
cases,
|
|
157
|
+
minCases: SELF_TEST_MIN_CASES,
|
|
158
|
+
minRejectCases: SELF_TEST_MIN_REJECT,
|
|
159
|
+
});
|
|
160
|
+
} finally {
|
|
161
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Publish the scan scope: how many remote base refs this run actually resolved.
|
|
167
|
+
*
|
|
168
|
+
* One when the server answered, zero on every path that legitimately has
|
|
169
|
+
* nothing to ask — each with its reason, because a gate reporting success over
|
|
170
|
+
* an empty scope is the false green this repo keeps finding, and "there was
|
|
171
|
+
* nothing to check" and "I checked and it was fine" must not print the same.
|
|
172
|
+
*/
|
|
173
|
+
function scanReport(scanned: number, allowEmpty?: string): void {
|
|
174
|
+
reportScanned({
|
|
175
|
+
gate: "check_branch_freshness",
|
|
176
|
+
scanned,
|
|
177
|
+
units: "remote base ref(s)",
|
|
178
|
+
roots: ["origin"],
|
|
179
|
+
...(allowEmpty === undefined ? {} : { allowEmpty }),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function main(argv: string[] = process.argv.slice(2)): number {
|
|
184
|
+
if (argv.includes("--self-test")) {
|
|
185
|
+
return selfTest(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."));
|
|
186
|
+
}
|
|
187
|
+
const quiet = argv.includes("--quiet");
|
|
188
|
+
const baseArg = argv.indexOf("--base");
|
|
189
|
+
const base = baseArg >= 0 ? (argv[baseArg + 1] ?? DEFAULT_BASE) : DEFAULT_BASE;
|
|
190
|
+
|
|
191
|
+
// CI merges or refuses on the server; a second opinion here would be noise.
|
|
192
|
+
if (process.env["CI"] === "true" || process.env["GITHUB_ACTIONS"] === "true") {
|
|
193
|
+
scanReport(0, "no-op in CI — the forge owns mergeability, and a second opinion here is noise");
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
198
|
+
if (branch === null || branch === "HEAD") {
|
|
199
|
+
scanReport(0, "detached HEAD — there is no branch whose freshness could be asked");
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
if (branch === base) {
|
|
203
|
+
scanReport(0, `standing on ${base} itself — a branch cannot be behind itself`);
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const sha = remoteHead(base);
|
|
208
|
+
if (sha === null) {
|
|
209
|
+
// Loud, not silent. An offline run has not verified the invariant, and a
|
|
210
|
+
// gate that passes on an invariant it never evaluated is the false green
|
|
211
|
+
// this repo keeps finding.
|
|
212
|
+
scanReport(0, `origin/${base} unreachable — reported NOT VERIFIED rather than green`);
|
|
213
|
+
process.stdout.write(
|
|
214
|
+
`check_branch_freshness: could not reach origin/${base} — NOT VERIFIED (offline?). ` +
|
|
215
|
+
"Re-run before pushing.\n",
|
|
216
|
+
);
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
scanReport(1);
|
|
221
|
+
if (containsCommit(sha) === true) {
|
|
222
|
+
if (!quiet) {
|
|
223
|
+
process.stdout.write(`✅ branch is current with origin/${base} (${sha.slice(0, 9)})` + "\n");
|
|
224
|
+
}
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
console.error(`❌ branch is BEHIND origin/${base} — the remote is at ${sha.slice(0, 9)}.`);
|
|
229
|
+
console.error("");
|
|
230
|
+
console.error(" Pushing now opens a PR that may conflict, and worse: another branch");
|
|
231
|
+
console.error(" may already have shipped what this one is implementing.");
|
|
232
|
+
console.error("");
|
|
233
|
+
console.error(` git fetch origin && git merge origin/${base}`);
|
|
234
|
+
console.error(" then re-run the gates before pushing.");
|
|
235
|
+
console.error("");
|
|
236
|
+
console.error(
|
|
237
|
+
" This gate asks the REMOTE, not your tracking ref — a fetch from earlier in",
|
|
238
|
+
);
|
|
239
|
+
console.error(" the session is a memory, not a check (direct-answers Iron Law 2).");
|
|
240
|
+
return 1;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const invokedDirectly = ((): boolean => {
|
|
244
|
+
const entry = process.argv[1];
|
|
245
|
+
if (entry === undefined) return false;
|
|
246
|
+
try {
|
|
247
|
+
return fs.realpathSync(path.resolve(entry)) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
248
|
+
} catch {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
})();
|
|
252
|
+
|
|
253
|
+
if (invokedDirectly) {
|
|
254
|
+
process.exit(main());
|
|
255
|
+
}
|