agentwrangler 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/LICENSE +191 -0
  2. package/README.md +116 -0
  3. package/dist/apply/jobs.js +429 -0
  4. package/dist/apply/open-terminal-child.mjs +98 -0
  5. package/dist/apply/open-terminal.js +221 -0
  6. package/dist/apply/settings-gen.js +35 -0
  7. package/dist/cli/agentwrangler.js +18 -0
  8. package/dist/daemon/config.js +51 -0
  9. package/dist/daemon/http.js +258 -0
  10. package/dist/daemon/index.js +372 -0
  11. package/dist/daemon/outcomes-pass.js +82 -0
  12. package/dist/daemon/readiness.js +15 -0
  13. package/dist/daemon/router.js +756 -0
  14. package/dist/daemon/static.js +146 -0
  15. package/dist/db/migrate.js +72 -0
  16. package/dist/db/migrations/001_observe.sql +196 -0
  17. package/dist/db/migrations/002_indexes.sql +6 -0
  18. package/dist/db/migrations/003_context_inventory_history.sql +20 -0
  19. package/dist/db/migrations/004_apply_jobs.sql +17 -0
  20. package/dist/db/migrations/005_tool_event_metadata.sql +17 -0
  21. package/dist/db/migrations/006_d7_query_indexes.sql +9 -0
  22. package/dist/db/migrations/007_work_item_branch_keys.sql +11 -0
  23. package/dist/db/migrations/008_thinking_tokens.sql +1 -0
  24. package/dist/db/migrations/009_user_turn_count.sql +1 -0
  25. package/dist/db/migrations/010_workspace_cwd.sql +1 -0
  26. package/dist/db/migrations/011_reports.sql +1 -0
  27. package/dist/db/migrations/012_reconcile_indexes.sql +2 -0
  28. package/dist/db/migrations/013_friction_fields.sql +5 -0
  29. package/dist/db/migrations/014_session_churn.sql +11 -0
  30. package/dist/db/migrations/015_gap_aggregates.sql +6 -0
  31. package/dist/db/open.js +30 -0
  32. package/dist/detector/benchmark-anchors.js +36 -0
  33. package/dist/detector/calibration.js +302 -0
  34. package/dist/detector/context-history-retention.js +312 -0
  35. package/dist/detector/context-probe.js +574 -0
  36. package/dist/detector/d1-source-identity.js +25 -0
  37. package/dist/detector/detectors/d10_catalog_footprint.js +146 -0
  38. package/dist/detector/detectors/d1_ctx_always_loaded.js +203 -0
  39. package/dist/detector/detectors/d2_session_long_full_context.js +119 -0
  40. package/dist/detector/detectors/d4_model_mismatch.js +258 -0
  41. package/dist/detector/detectors/d5_limit_burn_forecast.js +138 -0
  42. package/dist/detector/detectors/d6_tool_result_bloat.js +301 -0
  43. package/dist/detector/detectors/d7_loop_retry_waste.js +345 -0
  44. package/dist/detector/detectors/d8_cache_write_churn.js +201 -0
  45. package/dist/detector/detectors/d9_idle_background_session.js +101 -0
  46. package/dist/detector/engine.js +88 -0
  47. package/dist/detector/index.js +17 -0
  48. package/dist/detector/measurement.js +426 -0
  49. package/dist/detector/practice-registry.js +259 -0
  50. package/dist/detector/registry.js +32 -0
  51. package/dist/detector/savings.js +249 -0
  52. package/dist/detector/types.js +14 -0
  53. package/dist/evidence/common/approved-input.js +632 -0
  54. package/dist/evidence/common/boundary.js +84 -0
  55. package/dist/evidence/common/canonical.js +55 -0
  56. package/dist/evidence/common/redaction.js +321 -0
  57. package/dist/evidence/common/sqlite.js +25 -0
  58. package/dist/evidence/common/state.js +29 -0
  59. package/dist/evidence/cond1/cli.js +289 -0
  60. package/dist/evidence/cond1/packet.js +407 -0
  61. package/dist/evidence/cond1/prepare.js +295 -0
  62. package/dist/evidence/cond1/score.js +349 -0
  63. package/dist/evidence/cond1/types.js +1 -0
  64. package/dist/evidence/create-approval.js +365 -0
  65. package/dist/evidence/create-scratch.js +542 -0
  66. package/dist/evidence/d7/cli.js +113 -0
  67. package/dist/evidence/d7/measure.js +193 -0
  68. package/dist/evidence/d7/types.js +1 -0
  69. package/dist/evidence/discover-approval.js +492 -0
  70. package/dist/evidence/g2/adjudicate.js +20 -0
  71. package/dist/evidence/g2/cli.js +207 -0
  72. package/dist/evidence/g2/kappa.js +39 -0
  73. package/dist/evidence/g2/pipeline.js +92 -0
  74. package/dist/evidence/g2/store.js +14 -0
  75. package/dist/evidence/github/client.js +1 -0
  76. package/dist/evidence/github/gh-cli-client.js +301 -0
  77. package/dist/evidence/r3/cli.js +209 -0
  78. package/dist/evidence/r3/evaluate.js +417 -0
  79. package/dist/evidence/r3/packet.js +162 -0
  80. package/dist/evidence/r3/prepare.js +405 -0
  81. package/dist/evidence/r3/score.js +341 -0
  82. package/dist/evidence/r3/transcript.js +155 -0
  83. package/dist/evidence/r3/types.js +4 -0
  84. package/dist/hook/context-budget-hook.mjs +138 -0
  85. package/dist/hook/danger-guard-denylist.json +27 -0
  86. package/dist/hook/danger-guard-hook.mjs +167 -0
  87. package/dist/hook/install.js +0 -0
  88. package/dist/hook/limit-burn-hook.mjs +127 -0
  89. package/dist/hook/loop-guard-hook.mjs +104 -0
  90. package/dist/hook/precompact-checkpoint-hook.mjs +123 -0
  91. package/dist/ingest/churn-collector.js +122 -0
  92. package/dist/ingest/detector-hook.js +52 -0
  93. package/dist/ingest/discovery.js +207 -0
  94. package/dist/ingest/health.js +43 -0
  95. package/dist/ingest/index.js +28 -0
  96. package/dist/ingest/ingestor.js +509 -0
  97. package/dist/ingest/parser.js +344 -0
  98. package/dist/ingest/pricing.js +153 -0
  99. package/dist/ingest/reconcile.js +52 -0
  100. package/dist/ingest/tail.js +152 -0
  101. package/dist/ingest/types.js +24 -0
  102. package/dist/ingest/workspace-mapping.js +114 -0
  103. package/dist/oauth/anthropic-api-key.js +88 -0
  104. package/dist/oauth/count-tokens.js +86 -0
  105. package/dist/oauth/credentials.js +171 -0
  106. package/dist/oauth/judge-g2-client.js +154 -0
  107. package/dist/oauth/usage.js +167 -0
  108. package/dist/outcomes/branch-key.js +49 -0
  109. package/dist/outcomes/conclusions.js +45 -0
  110. package/dist/outcomes/derive.js +94 -0
  111. package/dist/outcomes/finding-extractors.js +131 -0
  112. package/dist/outcomes/findings.js +237 -0
  113. package/dist/outcomes/github/client.js +367 -0
  114. package/dist/outcomes/github/credential.js +195 -0
  115. package/dist/outcomes/github/gh-cli-client.js +340 -0
  116. package/dist/outcomes/linker.js +486 -0
  117. package/dist/outcomes/pool.js +24 -0
  118. package/dist/outcomes/sync.js +276 -0
  119. package/dist/query/api/agents-liveness.js +182 -0
  120. package/dist/query/api/burn-status.js +50 -0
  121. package/dist/query/api/context-budget.js +114 -0
  122. package/dist/query/api/context-composition.js +67 -0
  123. package/dist/query/api/cost-per-success.js +104 -0
  124. package/dist/query/api/delivery.js +92 -0
  125. package/dist/query/api/effectiveness.js +254 -0
  126. package/dist/query/api/efficiency-headroom.js +74 -0
  127. package/dist/query/api/headroom-trend.js +105 -0
  128. package/dist/query/api/hook-config.js +75 -0
  129. package/dist/query/api/hook-install.js +8 -0
  130. package/dist/query/api/hot-sessions.js +17 -0
  131. package/dist/query/api/idle-sessions.js +52 -0
  132. package/dist/query/api/index.js +40 -0
  133. package/dist/query/api/loop-guard.js +90 -0
  134. package/dist/query/api/offload-share.js +41 -0
  135. package/dist/query/api/outcomes.js +218 -0
  136. package/dist/query/api/overview.js +535 -0
  137. package/dist/query/api/rec-prompt.js +138 -0
  138. package/dist/query/api/recommendations-ledger.js +111 -0
  139. package/dist/query/api/recommendations.js +514 -0
  140. package/dist/query/api/reports.js +78 -0
  141. package/dist/query/api/self-churn.js +77 -0
  142. package/dist/query/api/self-percentiles.js +109 -0
  143. package/dist/query/api/session-drivers.js +153 -0
  144. package/dist/query/api/settings.js +85 -0
  145. package/dist/query/api/spend-flavor.js +234 -0
  146. package/dist/query/api/trends.js +155 -0
  147. package/dist/query/cap-weighted.js +119 -0
  148. package/dist/query/db-context.js +42 -0
  149. package/dist/query/envelope.js +71 -0
  150. package/dist/query/forecast.js +191 -0
  151. package/dist/query/settings-store.js +441 -0
  152. package/dist/query/spend.js +171 -0
  153. package/dist/query/trends.js +194 -0
  154. package/dist/ui/assets/index-DnRKgc21.css +1 -0
  155. package/dist/ui/assets/index-h1Q1wWq5.js +168 -0
  156. package/dist/ui/index.html +39 -0
  157. package/package.json +59 -0
@@ -0,0 +1,154 @@
1
+ /**
2
+ * src/oauth/judge-g2-client.ts — Claude client for blinded G2 adjudication.
3
+ *
4
+ * Calls Anthropic Messages using the local Claude Code OAuth credential. The
5
+ * displayed evidence is sent only for the adjudication request and is never
6
+ * logged (SEC-101).
7
+ */
8
+ import { fileCredentialSource } from "./credentials.js";
9
+ const MESSAGES_URL = "https://api.anthropic.com/v1/messages";
10
+ // Pilot-review artifact: a human reviews this fixed rubric before any live run.
11
+ export const G2_JUDGE_RUBRIC = `You adjudicate one blinded G2 DEFERRAL finding. Given only the displayed evidence object and its evidenceKind, decide whether that displayed evidence supports that the item is a genuine deferral.
12
+
13
+ Return CONFIRMED only when the displayed evidence supports the finding. Return REJECTED when it does not support the finding, is insufficient, or is unrelated. Do not infer facts that are not displayed. Use a confidence number from 0 through 1 inclusive. Choose a short, lowercase snake_case rationale_tag that identifies the main reason.
14
+
15
+ Respond with strict JSON only, with exactly these fields and no markdown or additional text:
16
+ {"verdict":"CONFIRMED"|"REJECTED","confidence":<number 0..1>,"rationale_tag":"<short snake_case tag>"}`;
17
+ function parseJudgeResult(raw) {
18
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
19
+ return { ok: false, reason: "Judge returned an invalid response shape." };
20
+ }
21
+ // Require the three rubric fields to be present and valid; ignore any extra
22
+ // keys the model volunteers (e.g. a spontaneous "reasoning") rather than
23
+ // discarding the whole verdict.
24
+ const candidate = raw;
25
+ if ((candidate.verdict !== "CONFIRMED" && candidate.verdict !== "REJECTED") ||
26
+ typeof candidate.confidence !== "number" ||
27
+ !Number.isFinite(candidate.confidence) ||
28
+ candidate.confidence < 0 ||
29
+ candidate.confidence > 1 ||
30
+ typeof candidate.rationale_tag !== "string" ||
31
+ candidate.rationale_tag.length === 0) {
32
+ return { ok: false, reason: "Judge response did not match the G2 rubric." };
33
+ }
34
+ return {
35
+ ok: true,
36
+ verdict: candidate.verdict,
37
+ confidence: candidate.confidence,
38
+ rationaleTag: candidate.rationale_tag,
39
+ };
40
+ }
41
+ /**
42
+ * Anthropic marks a 429/overload as retryable via `x-should-retry: true` and,
43
+ * for hard quota walls, an `anthropic-ratelimit-*`/`retry-after` header. Retry
44
+ * transient throttles with bounded exponential backoff + full jitter so a
45
+ * single blip among the sequential judge calls does not abort the whole run.
46
+ */
47
+ function isRetryableStatus(status, headers) {
48
+ if (headers?.get("x-should-retry") === "true")
49
+ return true;
50
+ return status === 429 || status === 529 || status >= 500;
51
+ }
52
+ /** Delay before the next attempt: honor `retry-after` (seconds) else exp backoff. */
53
+ function backoffDelayMs(attempt, baseDelayMs, headers) {
54
+ const retryAfter = headers?.get("retry-after");
55
+ if (retryAfter !== undefined && retryAfter !== null) {
56
+ const secs = Number(retryAfter);
57
+ if (Number.isFinite(secs) && secs >= 0)
58
+ return Math.min(secs * 1000, 30_000);
59
+ }
60
+ const ceiling = Math.min(baseDelayMs * 2 ** attempt, 30_000);
61
+ return Math.round(Math.random() * ceiling);
62
+ }
63
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
64
+ export function claudeJudgeClient(opts = {}) {
65
+ const credSource = opts.credSource ?? fileCredentialSource;
66
+ const fetchFn = opts.fetchFn ?? fetch;
67
+ const model = opts.model ?? "claude-opus-4-8";
68
+ const apiKey = opts.apiKey;
69
+ const maxRetries = opts.maxRetries ?? 5;
70
+ const baseDelayMs = opts.baseDelayMs ?? 1000;
71
+ const sleepFn = opts.sleepFn ?? defaultSleep;
72
+ return async ({ evidenceKind, evidence }) => {
73
+ // API-key auth uses `x-api-key`; OAuth (subscription) auth uses a Bearer
74
+ // token + the oauth beta header. Resolve one set of auth headers up front.
75
+ let authHeaders;
76
+ if (apiKey !== undefined) {
77
+ authHeaders = {
78
+ "x-api-key": apiKey,
79
+ "anthropic-version": "2023-06-01",
80
+ "content-type": "application/json",
81
+ };
82
+ }
83
+ else {
84
+ const credResult = credSource.read();
85
+ if (!credResult.ok) {
86
+ return { ok: false, reason: credResult.reason };
87
+ }
88
+ authHeaders = {
89
+ Authorization: `Bearer ${credResult.credential.accessToken}`,
90
+ "anthropic-beta": "oauth-2025-04-20",
91
+ "anthropic-version": "2023-06-01",
92
+ "content-type": "application/json",
93
+ };
94
+ }
95
+ for (let attempt = 0;; attempt++) {
96
+ let response;
97
+ try {
98
+ response = await fetchFn(MESSAGES_URL, {
99
+ method: "POST",
100
+ headers: authHeaders,
101
+ body: JSON.stringify({
102
+ model,
103
+ max_tokens: 512,
104
+ system: G2_JUDGE_RUBRIC,
105
+ messages: [{ role: "user", content: JSON.stringify({ evidenceKind, evidence }) }],
106
+ }),
107
+ });
108
+ }
109
+ catch (error) {
110
+ // Network-level failure — retry with backoff, then give up.
111
+ if (attempt < maxRetries) {
112
+ await sleepFn(backoffDelayMs(attempt, baseDelayMs));
113
+ continue;
114
+ }
115
+ return {
116
+ ok: false,
117
+ reason: `Could not reach ${MESSAGES_URL}: ${error instanceof Error ? error.message : String(error)}`,
118
+ };
119
+ }
120
+ if (!response.ok) {
121
+ if (attempt < maxRetries && isRetryableStatus(response.status, response.headers)) {
122
+ await sleepFn(backoffDelayMs(attempt, baseDelayMs, response.headers));
123
+ continue;
124
+ }
125
+ return {
126
+ ok: false,
127
+ reason: `HTTP ${response.status} from Messages API`,
128
+ status: response.status,
129
+ };
130
+ }
131
+ const raw = await response.json();
132
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
133
+ return { ok: false, reason: "Messages API returned an invalid response shape." };
134
+ }
135
+ const content = raw.content;
136
+ if (!Array.isArray(content)) {
137
+ return { ok: false, reason: "Messages API response is missing content." };
138
+ }
139
+ const textBlock = content.find((block) => typeof block === "object" &&
140
+ block !== null &&
141
+ block.type === "text" &&
142
+ typeof block.text === "string");
143
+ if (textBlock === undefined) {
144
+ return { ok: false, reason: "Messages API response is missing a text block." };
145
+ }
146
+ try {
147
+ return parseJudgeResult(JSON.parse(textBlock.text));
148
+ }
149
+ catch {
150
+ return { ok: false, reason: "Judge response was not valid JSON." };
151
+ }
152
+ }
153
+ };
154
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * src/oauth/usage.ts — reader for the Anthropic oauth/usage endpoint.
3
+ *
4
+ * Reads the current user's weekly utilization from their local OAuth session.
5
+ * No data leaves the machine — this queries the same session Claude Code uses.
6
+ *
7
+ * Requires a valid credential (read from ~/.claude/.credentials.json by default).
8
+ * Returns ok:false without calling fetch if credentials are missing or expired.
9
+ *
10
+ * Degrades gracefully on ANY non-200. Never throws uncaught; always returns a
11
+ * typed result.
12
+ */
13
+ import { fileCredentialSource } from "./credentials.js";
14
+ const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
15
+ /**
16
+ * Validate one raw period from the API (utilization as PERCENT 0–100) and
17
+ * return it normalized to the fraction-0–1 contract, or null if malformed.
18
+ */
19
+ function parseUsagePeriod(value) {
20
+ if (typeof value !== "object" || value === null)
21
+ return null;
22
+ const candidate = value;
23
+ const pct = candidate.utilization;
24
+ if (typeof pct !== "number" ||
25
+ !Number.isFinite(pct) ||
26
+ pct < 0 ||
27
+ pct > 100 ||
28
+ typeof candidate.resets_at !== "string" ||
29
+ candidate.resets_at.length === 0 ||
30
+ !Number.isFinite(new Date(candidate.resets_at).getTime())) {
31
+ return null;
32
+ }
33
+ return { utilization: pct / 100, resets_at: candidate.resets_at };
34
+ }
35
+ /**
36
+ * Parse the unverified per-model utilization response shape. Invalid entries
37
+ * are ignored so optional model detail can never invalidate the core periods.
38
+ */
39
+ function parsePerModel(value) {
40
+ if (typeof value !== "object" || value === null)
41
+ return undefined;
42
+ try {
43
+ const parseEntry = (modelValue, entryValue) => {
44
+ if (typeof modelValue !== "string" || modelValue.trim().length === 0)
45
+ return null;
46
+ if (typeof entryValue !== "object" || entryValue === null)
47
+ return null;
48
+ const pct = entryValue.utilization;
49
+ if (typeof pct !== "number" || !Number.isFinite(pct) || pct < 0 || pct > 100) {
50
+ return null;
51
+ }
52
+ return { model: modelValue.trim(), utilization: pct / 100 };
53
+ };
54
+ const entries = [];
55
+ if (Array.isArray(value)) {
56
+ for (const entry of value) {
57
+ if (typeof entry !== "object" || entry === null)
58
+ continue;
59
+ const candidate = entry;
60
+ const model = [candidate.model, candidate.model_key, candidate.tier].find((candidateModel) => typeof candidateModel === "string" && candidateModel.trim().length > 0);
61
+ const parsed = parseEntry(model, entry);
62
+ if (parsed !== null)
63
+ entries.push(parsed);
64
+ }
65
+ }
66
+ else {
67
+ for (const [model, entry] of Object.entries(value)) {
68
+ const parsed = parseEntry(model, entry);
69
+ if (parsed !== null)
70
+ entries.push(parsed);
71
+ }
72
+ }
73
+ return entries.length > 0 ? entries : undefined;
74
+ }
75
+ catch {
76
+ return undefined;
77
+ }
78
+ }
79
+ /**
80
+ * Validate value contains valid five_hour and seven_day periods and return the
81
+ * normalized (fraction 0–1) usage data, or null if malformed.
82
+ * Extra unknown keys (per-model breakdowns, extra_usage, limits, spend, …) are
83
+ * tolerated — we only require the two bounded fields.
84
+ */
85
+ function parseUsageData(value) {
86
+ if (typeof value !== "object" || value === null)
87
+ return null;
88
+ const candidate = value;
89
+ const fiveHour = parseUsagePeriod(candidate.five_hour);
90
+ const sevenDay = parseUsagePeriod(candidate.seven_day);
91
+ if (fiveHour === null || sevenDay === null)
92
+ return null;
93
+ let perModel;
94
+ try {
95
+ perModel = parsePerModel(candidate.per_model === undefined ? candidate.per_model_utilization : candidate.per_model);
96
+ }
97
+ catch {
98
+ perModel = undefined;
99
+ }
100
+ if (perModel !== undefined) {
101
+ return { five_hour: fiveHour, seven_day: sevenDay, per_model: perModel };
102
+ }
103
+ return { five_hour: fiveHour, seven_day: sevenDay };
104
+ }
105
+ /**
106
+ * Fetch the oauth/usage endpoint, authenticated with the local OAuth credential.
107
+ *
108
+ * @param credSource - Credential source to read the token from (default: file source).
109
+ * Injectable for tests via a stub that returns a known credential or ok:false.
110
+ *
111
+ * Returns ok:false (no fetch) when credentials are missing or expired.
112
+ * Distinguishes 401 (auth failure — re-login) from 429 (rate limited).
113
+ */
114
+ export async function fetchOAuthUsage(credSource = fileCredentialSource) {
115
+ // 1. Read credential; fail closed if unavailable.
116
+ const credResult = credSource.read();
117
+ if (!credResult.ok) {
118
+ return { ok: false, reason: credResult.reason };
119
+ }
120
+ const { accessToken } = credResult.credential;
121
+ // 2. Fetch with auth headers.
122
+ try {
123
+ const res = await fetch(USAGE_URL, {
124
+ headers: {
125
+ Authorization: `Bearer ${accessToken}`,
126
+ "anthropic-beta": "oauth-2025-04-20",
127
+ // Honest, self-identifying UA. Verified 2026-09-01 against the real
128
+ // endpoint: this UA returns 200 — the endpoint does not require Claude
129
+ // Code's own UA, so we do not impersonate it.
130
+ "User-Agent": "claude-code/AgentWrangler-oauth-usage-reader",
131
+ },
132
+ });
133
+ if (!res.ok) {
134
+ if (res.status === 401) {
135
+ return {
136
+ ok: false,
137
+ reason: "OAuth token rejected (401) — re-login to Claude Code.",
138
+ status: 401,
139
+ };
140
+ }
141
+ if (res.status === 429) {
142
+ return {
143
+ ok: false,
144
+ reason: "Rate limited (429) — try again later.",
145
+ status: 429,
146
+ };
147
+ }
148
+ return {
149
+ ok: false,
150
+ reason: `HTTP ${res.status} from oauth/usage`,
151
+ status: res.status,
152
+ };
153
+ }
154
+ const raw = await res.json();
155
+ const data = parseUsageData(raw);
156
+ if (data === null) {
157
+ return { ok: false, reason: "oauth/usage returned an invalid response shape." };
158
+ }
159
+ return { ok: true, data };
160
+ }
161
+ catch (e) {
162
+ return {
163
+ ok: false,
164
+ reason: `Could not reach ${USAGE_URL}: ${e instanceof Error ? e.message : String(e)}`,
165
+ };
166
+ }
167
+ }
@@ -0,0 +1,49 @@
1
+ import { createHash } from "node:crypto";
2
+ const NORMALIZATION_VERSION = "branch-v1";
3
+ const LOCAL_HEAD_PREFIX = "refs/heads/";
4
+ const FULL_COMMIT_SHA = /^[0-9a-f]{40}$/i;
5
+ const FORBIDDEN_CHARACTERS = /[\\~^:?*\[]/u;
6
+ const WHITESPACE_OR_CONTROL = /[\s\p{Cc}]/u;
7
+ function isDetachedHeadMarker(ref) {
8
+ const lower = ref.toLowerCase();
9
+ return (lower === "detached" ||
10
+ lower === "detached head" ||
11
+ lower === "(detached)" ||
12
+ lower === "(no branch)" ||
13
+ lower.startsWith("(head detached ") ||
14
+ lower.startsWith("head detached "));
15
+ }
16
+ /**
17
+ * Convert a valid Git branch ref directly to its privacy-safe equality key.
18
+ *
19
+ * The normalized ref never leaves this function. Invalid or absent values
20
+ * abstain with null rather than producing a key.
21
+ */
22
+ export function fingerprintBranchRef(value) {
23
+ if (typeof value !== "string")
24
+ return null;
25
+ const byteLength = Buffer.byteLength(value, "utf8");
26
+ if (byteLength < 1 || byteLength > 255)
27
+ return null;
28
+ if (value.trim() !== value || WHITESPACE_OR_CONTROL.test(value))
29
+ return null;
30
+ const ref = value.startsWith(LOCAL_HEAD_PREFIX) ? value.slice(LOCAL_HEAD_PREFIX.length) : value;
31
+ if (ref.length === 0)
32
+ return null;
33
+ if (ref === "HEAD" || ref === "@" || FULL_COMMIT_SHA.test(ref) || isDetachedHeadMarker(ref)) {
34
+ return null;
35
+ }
36
+ if (ref.includes("..") || ref.includes("@{") || FORBIDDEN_CHARACTERS.test(ref))
37
+ return null;
38
+ const components = ref.split("/");
39
+ if (components.some((component) => component.length === 0 ||
40
+ component.startsWith(".") ||
41
+ component.endsWith(".") ||
42
+ component.endsWith(".lock"))) {
43
+ return null;
44
+ }
45
+ return createHash("sha256")
46
+ .update(`${NORMALIZATION_VERSION}\0`, "utf8")
47
+ .update(ref, "utf8")
48
+ .digest("hex");
49
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * src/outcomes/conclusions.ts — shared PURE post-processing for GitHub reads.
3
+ *
4
+ * M-03 dedup: both GitHub transports (fetch-based client.ts and gh-CLI
5
+ * gh-cli-client.ts) aggregated check-run conclusions and normalized PR bodies
6
+ * with copy-pasted logic. Only the transport differs (HTTP fetch vs `gh api`
7
+ * subprocess); this module owns the transport-independent post-processing so
8
+ * the two can never drift. NO wire calls, pagination, retry, or timeout logic
9
+ * lives here.
10
+ */
11
+ /** Conclusions that do NOT block a SUCCESS verdict. */
12
+ const BENIGN_CONCLUSIONS = new Set(["SUCCESS", "SKIPPED", "NEUTRAL"]);
13
+ /**
14
+ * Aggregate a fetched check-runs body into the single conclusion string
15
+ * consumed by work_items.checks_conclusion:
16
+ * truncated body → {ok:false, reason:"github-checks-truncated:n/m"} (honest
17
+ * refusal rather than a conclusion from partial data; warns on stderr),
18
+ * zero runs → "NONE",
19
+ * any FAILURE → "FAILURE",
20
+ * anything not SUCCESS/SKIPPED/NEUTRAL → "PENDING",
21
+ * otherwise → "SUCCESS".
22
+ */
23
+ export function aggregateCheckConclusion(body) {
24
+ const runs = body.check_runs;
25
+ if (body.total_count > runs.length) {
26
+ console.warn(`Outcomes: check-runs truncated (${runs.length}/${body.total_count}) — conclusion may be incomplete`);
27
+ return { ok: false, reason: `github-checks-truncated:${runs.length}/${body.total_count}` };
28
+ }
29
+ if (runs.length === 0)
30
+ return { ok: true, data: "NONE" };
31
+ const conclusions = runs.map((r) => r.conclusion?.toUpperCase() ?? "PENDING");
32
+ if (conclusions.includes("FAILURE"))
33
+ return { ok: true, data: "FAILURE" };
34
+ if (conclusions.some((c) => !BENIGN_CONCLUSIONS.has(c))) {
35
+ return { ok: true, data: "PENDING" };
36
+ }
37
+ return { ok: true, data: "SUCCESS" };
38
+ }
39
+ /**
40
+ * Normalize a fetched PR JSON object to the PR-body string: null/missing body
41
+ * → "" (both transports previously duplicated the `?? ""`).
42
+ */
43
+ export function normalizePRBody(data) {
44
+ return data.body ?? "";
45
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * src/outcomes/derive.ts — Outcome derivation.
3
+ *
4
+ * Pure versioned deriveOutcome() per plan §3.4 + Spec §3.
5
+ * 5 branches:
6
+ * 1. state=OPEN → IN_PROGRESS
7
+ * 2. state=CLOSED (abandoned) → OBSERVED_FAILURE (no merge)
8
+ * 3. state=MERGED, deferral findings → OBSERVED_SUCCESS_WITH_DEFERRALS
9
+ * 4. state=MERGED, checks=FAILURE → OBSERVED_FAILURE
10
+ * 5. state=MERGED, otherwise → OBSERVED_SUCCESS
11
+ * checks=NONE: MERGED + no CI → treat as OBSERVED_SUCCESS (checks_conclusion='NONE' annotation only)
12
+ *
13
+ * writeObservedOutcomes(): upserts derived outcomes for all linked terminal PRs.
14
+ */
15
+ export const METHODOLOGY_VERSION = "outcome-v1";
16
+ // COND-1: per-extractor precision map. All three extractors are EXPERIMENTAL
17
+ // and excluded from gated/deferral denominators.
18
+ const EXPERIMENTAL_SOURCES = new Set(["UNRESOLVED_THREAD", "DEFERRAL_SECTION", "DIFF_MARKER"]);
19
+ /**
20
+ * A finding counts toward deferral denominators only when:
21
+ * - source is deterministic (not LLM, not EXPERIMENTAL source)
22
+ * - OR (source=LLM AND human_state='CONFIRMED')
23
+ * Since all three current extractors are EXPERIMENTAL, this effectively means
24
+ * only LLM+CONFIRMED findings count in gated denominators (conservative COND-1).
25
+ */
26
+ function isGatedFinding(f) {
27
+ if (EXPERIMENTAL_SOURCES.has(f.source))
28
+ return false; // COND-1 exclusion
29
+ if (f.source === "LLM" && f.human_state !== "CONFIRMED")
30
+ return false;
31
+ return true;
32
+ }
33
+ // ---------------------------------------------------------------------------
34
+ // Core derivation (pure — no DB access)
35
+ // ---------------------------------------------------------------------------
36
+ /**
37
+ * Derive the outcome for a single work item.
38
+ * Findings passed in may be empty ([]) if no findings exist.
39
+ *
40
+ * The COND-1 gate applies only to the deferral branch:
41
+ * OBSERVED_SUCCESS_WITH_DEFERRALS requires ≥1 gated DEFERRED finding.
42
+ * Experimental findings are written and displayed but not counted toward
43
+ * this determination (conservative).
44
+ */
45
+ export function deriveOutcome(workItem, findings) {
46
+ // Branch 1: still open
47
+ if (workItem.state === "OPEN")
48
+ return "IN_PROGRESS";
49
+ // Branch 2: closed without merge (abandoned)
50
+ if (workItem.state === "CLOSED")
51
+ return "OBSERVED_FAILURE";
52
+ // state === "MERGED" beyond here
53
+ // Branch 4: CI failure
54
+ if (workItem.checks_conclusion === "FAILURE")
55
+ return "OBSERVED_FAILURE";
56
+ // Branch 3: gated deferred findings → success with deferrals
57
+ const hasDeferral = findings.some((f) => f.status === "DEFERRED" && isGatedFinding(f));
58
+ if (hasDeferral)
59
+ return "OBSERVED_SUCCESS_WITH_DEFERRALS";
60
+ // Branch 5 (incl. checks=NONE + checks=SUCCESS): clean success
61
+ return "OBSERVED_SUCCESS";
62
+ }
63
+ // ---------------------------------------------------------------------------
64
+ // DB write pass
65
+ // ---------------------------------------------------------------------------
66
+ /**
67
+ * Derive and write observed_outcomes for all linked terminal PRs.
68
+ * "Terminal" = state in (MERGED, CLOSED) with ≥1 session_work_links row.
69
+ * Idempotent — ON CONFLICT DO UPDATE.
70
+ */
71
+ export function writeObservedOutcomes(db, methodologyVersion = METHODOLOGY_VERSION) {
72
+ const workItems = db
73
+ .prepare(`SELECT wi.work_item_id, wi.state, wi.checks_conclusion
74
+ FROM work_items wi
75
+ WHERE wi.state IN ('MERGED', 'CLOSED')
76
+ AND EXISTS (SELECT 1 FROM session_work_links l WHERE l.work_item_id = wi.work_item_id)`)
77
+ .all();
78
+ const getFindingsStmt = db.prepare(`SELECT status, source, human_state, extractor_version
79
+ FROM review_findings WHERE work_item_id = ?`);
80
+ const upsert = db.prepare(`
81
+ INSERT INTO observed_outcomes (work_item_id, outcome, derived_at, methodology_version)
82
+ VALUES (?, ?, ?, ?)
83
+ ON CONFLICT(work_item_id) DO UPDATE SET
84
+ outcome = excluded.outcome,
85
+ derived_at = excluded.derived_at,
86
+ methodology_version = excluded.methodology_version
87
+ `);
88
+ const now = new Date().toISOString();
89
+ for (const wi of workItems) {
90
+ const findings = getFindingsStmt.all(wi.work_item_id);
91
+ const outcome = deriveOutcome(wi, findings);
92
+ upsert.run(wi.work_item_id, outcome, now, methodologyVersion);
93
+ }
94
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Pure, deterministic projections for the experimental E1/E2/E3 finding
3
+ * extractors. These functions never read or write the operator database.
4
+ * Callers must keep the returned evidence text in memory or approved ephemeral
5
+ * evidence state; production persists only the structural identifiers.
6
+ */
7
+ export const EXTRACTOR_VERSIONS = {
8
+ E1: "unresolved-thread-v1",
9
+ E2: "deferral-section-v1",
10
+ E3: "diff-marker-v1",
11
+ };
12
+ /**
13
+ * Project current GitHub review-thread state. Current `isResolved` is never
14
+ * represented as proof of state at merge.
15
+ */
16
+ export function projectReviewThreadFindings(workItemId, threads) {
17
+ return threads.map((thread) => ({
18
+ sourceFindingId: `e1:${workItemId}:${thread.id}`,
19
+ evidenceRef: thread.id,
20
+ status: thread.isResolved ? "ADDRESSED" : "DEFERRED",
21
+ evidence: {
22
+ stateAtRelevantTime: thread.isResolved ? "RESOLVED" : "UNRESOLVED",
23
+ temporalBasis: "CURRENT_STATE_ONLY",
24
+ },
25
+ }));
26
+ }
27
+ const DEFERRAL_HEADING_RE = /^#{1,4}\s*(deferred|follow[- ]?ups?|known issues|out of scope)\b/im;
28
+ function deferralKeywordClass(headingLine) {
29
+ const keyword = (/(deferred|follow[- ]?ups?|known issues|out of scope)/i.exec(headingLine)?.[1] ?? "").toLowerCase();
30
+ if (keyword.startsWith("defer"))
31
+ return "deferred";
32
+ if (keyword.startsWith("follow"))
33
+ return "follow-ups";
34
+ if (keyword.startsWith("known"))
35
+ return "known-issues";
36
+ if (keyword.startsWith("out"))
37
+ return "out-of-scope";
38
+ return "section";
39
+ }
40
+ export function extractDeferralFindings(body, workItemId) {
41
+ const results = [];
42
+ let inSection = false;
43
+ let sectionClass = "section";
44
+ let itemIndex = 0;
45
+ for (const line of body.split("\n")) {
46
+ if (DEFERRAL_HEADING_RE.test(line)) {
47
+ inSection = true;
48
+ sectionClass = deferralKeywordClass(line);
49
+ itemIndex = 0;
50
+ continue;
51
+ }
52
+ if (!inSection)
53
+ continue;
54
+ if (/^#{1,4}\s/.test(line)) {
55
+ inSection = false;
56
+ continue;
57
+ }
58
+ if (!/^\s*[-*]\s+\S/.test(line))
59
+ continue;
60
+ results.push({
61
+ sourceFindingId: `e2:${workItemId}:${results.length}`,
62
+ evidenceRef: `${workItemId}:e2:${sectionClass}:${itemIndex}`,
63
+ status: "DEFERRED",
64
+ evidenceText: line,
65
+ });
66
+ itemIndex++;
67
+ }
68
+ return results;
69
+ }
70
+ const EXCLUDED_GLOBS = [
71
+ /package-lock\.json$/,
72
+ /yarn\.lock$/,
73
+ /pnpm-lock\.yaml$/,
74
+ /composer\.lock$/,
75
+ /Gemfile\.lock$/,
76
+ /poetry\.lock$/,
77
+ /go\.sum$/,
78
+ /node_modules\//,
79
+ /vendor\//,
80
+ /dist\//,
81
+ /build\//,
82
+ /\.min\.js$/,
83
+ /\.min\.css$/,
84
+ ];
85
+ const TODO_FIXME_RE = /\b(TODO|FIXME)\b/;
86
+ const DIFF_FILE_RE = /^\+\+\+\s+b\/(.+)$/;
87
+ const DIFF_HUNK_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
88
+ const DIFF_ADD_RE = /^\+(?!\+\+)/;
89
+ function isExcludedFile(filePath) {
90
+ return EXCLUDED_GLOBS.some((pattern) => pattern.test(filePath));
91
+ }
92
+ /** Pure E3 detection seam. It deliberately has no commit or work-item identity. */
93
+ export function projectDiffMarkerCandidates(diff) {
94
+ const results = [];
95
+ let currentFile = "";
96
+ let lineNumber = 0;
97
+ for (const line of diff.split("\n")) {
98
+ const fileMatch = DIFF_FILE_RE.exec(line);
99
+ if (fileMatch !== null) {
100
+ currentFile = fileMatch[1] ?? "";
101
+ continue;
102
+ }
103
+ const hunkMatch = DIFF_HUNK_RE.exec(line);
104
+ if (hunkMatch !== null) {
105
+ lineNumber = Number.parseInt(hunkMatch[1] ?? "0", 10) - 1;
106
+ continue;
107
+ }
108
+ if (DIFF_ADD_RE.test(line)) {
109
+ lineNumber++;
110
+ if (!isExcludedFile(currentFile) && TODO_FIXME_RE.test(line)) {
111
+ results.push({
112
+ evidenceText: line.slice(1),
113
+ filePath: currentFile,
114
+ lineNumber,
115
+ });
116
+ }
117
+ }
118
+ else if (line.startsWith(" ")) {
119
+ lineNumber++;
120
+ }
121
+ }
122
+ return results;
123
+ }
124
+ export function extractDiffMarkerFindings(diff, commitSha, workItemId) {
125
+ return projectDiffMarkerCandidates(diff).map((candidate, index) => ({
126
+ sourceFindingId: `e3:${workItemId}:${index}`,
127
+ evidenceRef: `${candidate.filePath}:${candidate.lineNumber}@${commitSha.slice(0, 7)}`,
128
+ status: "UNKNOWN",
129
+ ...candidate,
130
+ }));
131
+ }