@yagni-app/code-staging 0.0.0 → 0.1.0-staging.997.1
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/LICENSE.md +55 -0
- package/README.md +166 -0
- package/dist/branding.d.ts +25 -0
- package/dist/branding.js +27 -0
- package/dist/claudeCompat.d.ts +78 -0
- package/dist/claudeCompat.js +198 -0
- package/dist/cli.d.ts +55 -0
- package/dist/cli.js +326 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +28 -0
- package/dist/credentials.d.ts +41 -0
- package/dist/credentials.js +75 -0
- package/dist/distribution.d.ts +15 -0
- package/dist/distribution.js +45 -0
- package/dist/doctor.d.ts +94 -0
- package/dist/doctor.js +343 -0
- package/dist/extension/advisor.d.ts +84 -0
- package/dist/extension/advisor.js +101 -0
- package/dist/extension/askAdvisorTool.d.ts +83 -0
- package/dist/extension/askAdvisorTool.js +188 -0
- package/dist/extension/askYagniTool.d.ts +39 -0
- package/dist/extension/askYagniTool.js +63 -0
- package/dist/extension/bless.d.ts +45 -0
- package/dist/extension/bless.js +74 -0
- package/dist/extension/branding.d.ts +48 -0
- package/dist/extension/branding.js +119 -0
- package/dist/extension/chipEditor.d.ts +117 -0
- package/dist/extension/chipEditor.js +369 -0
- package/dist/extension/config.d.ts +112 -0
- package/dist/extension/config.js +108 -0
- package/dist/extension/costHud.d.ts +73 -0
- package/dist/extension/costHud.js +123 -0
- package/dist/extension/decisionCapture.d.ts +52 -0
- package/dist/extension/decisionCapture.js +66 -0
- package/dist/extension/decisions.d.ts +83 -0
- package/dist/extension/decisions.js +200 -0
- package/dist/extension/diagnostics.d.ts +41 -0
- package/dist/extension/diagnostics.js +110 -0
- package/dist/extension/index.d.ts +116 -0
- package/dist/extension/index.js +388 -0
- package/dist/extension/initDone.d.ts +28 -0
- package/dist/extension/initDone.js +66 -0
- package/dist/extension/initPass.d.ts +152 -0
- package/dist/extension/initPass.js +394 -0
- package/dist/extension/nextWorkTool.d.ts +51 -0
- package/dist/extension/nextWorkTool.js +80 -0
- package/dist/extension/permission.d.ts +91 -0
- package/dist/extension/permission.js +236 -0
- package/dist/extension/pipeline/activity.d.ts +37 -0
- package/dist/extension/pipeline/activity.js +151 -0
- package/dist/extension/pipeline/activityFeed.d.ts +59 -0
- package/dist/extension/pipeline/activityFeed.js +175 -0
- package/dist/extension/pipeline/budget.d.ts +48 -0
- package/dist/extension/pipeline/budget.js +68 -0
- package/dist/extension/pipeline/checkpoint.d.ts +31 -0
- package/dist/extension/pipeline/checkpoint.js +176 -0
- package/dist/extension/pipeline/eval.d.ts +168 -0
- package/dist/extension/pipeline/eval.js +182 -0
- package/dist/extension/pipeline/events.d.ts +56 -0
- package/dist/extension/pipeline/events.js +147 -0
- package/dist/extension/pipeline/findings.d.ts +42 -0
- package/dist/extension/pipeline/findings.js +144 -0
- package/dist/extension/pipeline/finish.d.ts +128 -0
- package/dist/extension/pipeline/finish.js +307 -0
- package/dist/extension/pipeline/goCommand.d.ts +128 -0
- package/dist/extension/pipeline/goCommand.js +972 -0
- package/dist/extension/pipeline/goCompareCommand.d.ts +48 -0
- package/dist/extension/pipeline/goCompareCommand.js +184 -0
- package/dist/extension/pipeline/goFlags.d.ts +40 -0
- package/dist/extension/pipeline/goFlags.js +46 -0
- package/dist/extension/pipeline/goStatusCommands.d.ts +59 -0
- package/dist/extension/pipeline/goStatusCommands.js +188 -0
- package/dist/extension/pipeline/invocation.d.ts +45 -0
- package/dist/extension/pipeline/invocation.js +64 -0
- package/dist/extension/pipeline/orchestrator.d.ts +131 -0
- package/dist/extension/pipeline/orchestrator.js +636 -0
- package/dist/extension/pipeline/personas.d.ts +44 -0
- package/dist/extension/pipeline/personas.js +248 -0
- package/dist/extension/pipeline/resilience.d.ts +85 -0
- package/dist/extension/pipeline/resilience.js +166 -0
- package/dist/extension/pipeline/resume.d.ts +18 -0
- package/dist/extension/pipeline/resume.js +106 -0
- package/dist/extension/pipeline/runRegistry.d.ts +112 -0
- package/dist/extension/pipeline/runRegistry.js +202 -0
- package/dist/extension/pipeline/runSession.d.ts +152 -0
- package/dist/extension/pipeline/runSession.js +167 -0
- package/dist/extension/pipeline/runState.d.ts +158 -0
- package/dist/extension/pipeline/runState.js +264 -0
- package/dist/extension/pipeline/runner.d.ts +79 -0
- package/dist/extension/pipeline/runner.js +298 -0
- package/dist/extension/pipeline/scrubSecrets.d.ts +15 -0
- package/dist/extension/pipeline/scrubSecrets.js +32 -0
- package/dist/extension/pipeline/stages.d.ts +51 -0
- package/dist/extension/pipeline/stages.js +113 -0
- package/dist/extension/pipeline/ticketResolution.d.ts +34 -0
- package/dist/extension/pipeline/ticketResolution.js +75 -0
- package/dist/extension/pipeline/types.d.ts +436 -0
- package/dist/extension/pipeline/types.js +48 -0
- package/dist/extension/pipeline/verify.d.ts +277 -0
- package/dist/extension/pipeline/verify.js +758 -0
- package/dist/extension/pipeline/workspace.d.ts +32 -0
- package/dist/extension/pipeline/workspace.js +73 -0
- package/dist/extension/pipeline/worktree.d.ts +107 -0
- package/dist/extension/pipeline/worktree.js +200 -0
- package/dist/extension/provider.d.ts +11 -0
- package/dist/extension/provider.js +26 -0
- package/dist/extension/recall.d.ts +93 -0
- package/dist/extension/recall.js +187 -0
- package/dist/extension/recordContextTool.d.ts +38 -0
- package/dist/extension/recordContextTool.js +85 -0
- package/dist/extension/recordDecisionTool.d.ts +52 -0
- package/dist/extension/recordDecisionTool.js +102 -0
- package/dist/extension/repoDocs.d.ts +81 -0
- package/dist/extension/repoDocs.js +260 -0
- package/dist/extension/resilientFetch.d.ts +60 -0
- package/dist/extension/resilientFetch.js +133 -0
- package/dist/extension/reviewTool.d.ts +34 -0
- package/dist/extension/reviewTool.js +81 -0
- package/dist/extension/spool.d.ts +92 -0
- package/dist/extension/spool.js +266 -0
- package/dist/extension/stateHome.d.ts +2 -0
- package/dist/extension/stateHome.js +6 -0
- package/dist/extension/subagents.d.ts +135 -0
- package/dist/extension/subagents.js +281 -0
- package/dist/extension/surface.d.ts +10 -0
- package/dist/extension/surface.js +12 -0
- package/dist/extension/todos.d.ts +110 -0
- package/dist/extension/todos.js +217 -0
- package/dist/extension/tokenProvider.d.ts +93 -0
- package/dist/extension/tokenProvider.js +234 -0
- package/dist/launch.d.ts +76 -0
- package/dist/launch.js +111 -0
- package/dist/login.d.ts +45 -0
- package/dist/login.js +142 -0
- package/dist/logout.d.ts +14 -0
- package/dist/logout.js +34 -0
- package/dist/paths.d.ts +31 -0
- package/dist/paths.js +87 -0
- package/dist/piPackage.d.ts +33 -0
- package/dist/piPackage.js +71 -0
- package/dist/profiles.d.ts +80 -0
- package/dist/profiles.js +222 -0
- package/dist/refresh.d.ts +70 -0
- package/dist/refresh.js +117 -0
- package/dist/upgrade.d.ts +97 -0
- package/dist/upgrade.js +284 -0
- package/package.json +39 -3
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-doc grounding collector for ask_yagni (the retrieval coverage fix from
|
|
3
|
+
* the 2026-07 grounding-gate eval): coding agents ask codebase/architecture
|
|
4
|
+
* questions — ADRs, schema, conventions — that the workspace corpus (Vision,
|
|
5
|
+
* Library, decisions, corrections) structurally cannot answer, because that
|
|
6
|
+
* knowledge lives in the repo. This module gathers the few most relevant repo
|
|
7
|
+
* docs from the CALLER'S working tree so the backend can fold them into the
|
|
8
|
+
* numbered source catalog.
|
|
9
|
+
*
|
|
10
|
+
* Reading from the checkout (not a server-side index) is load-bearing: the docs
|
|
11
|
+
* are always exactly as old as the checkout being edited, so an agent working
|
|
12
|
+
* on an old base sees that era's conventions, never today's.
|
|
13
|
+
*
|
|
14
|
+
* BASELINE, not working tree (echo-chamber guard): docs are enumerated and read
|
|
15
|
+
* at git HEAD, never from the live working tree. A /go run edits the tree
|
|
16
|
+
* without committing until FINISH, so mid-run ask_yagni calls from the review
|
|
17
|
+
* and fix stages would otherwise retrieve the candidate's OWN in-flight edits
|
|
18
|
+
* to a doc and have the backend present that self-authored text as
|
|
19
|
+
* source-of-truth context. HEAD is the pre-run baseline in both the /go
|
|
20
|
+
* pipeline and the eval driver (which commits the exported tree before the
|
|
21
|
+
* agent starts). A non-git directory falls back to the working-tree walk —
|
|
22
|
+
* there is no baseline to prefer.
|
|
23
|
+
*
|
|
24
|
+
* Pure-ish and bounded: a small fixed set of root docs plus a capped doc list,
|
|
25
|
+
* lexical scoring against the question (no model call, no network), hard caps
|
|
26
|
+
* on file count, bytes read, and excerpt size. All fs/git access sits behind
|
|
27
|
+
* injectable seams so tests never touch a real repo.
|
|
28
|
+
*/
|
|
29
|
+
import { execFileSync } from "node:child_process";
|
|
30
|
+
import * as fs from "node:fs";
|
|
31
|
+
import * as path from "node:path";
|
|
32
|
+
/** Root-level docs worth checking by name in any repo. */
|
|
33
|
+
const ROOT_DOC_NAMES = [
|
|
34
|
+
"CONTEXT.md",
|
|
35
|
+
"AGENTS.md",
|
|
36
|
+
"README.md",
|
|
37
|
+
"ARCHITECTURE.md",
|
|
38
|
+
"GLOSSARY.md",
|
|
39
|
+
"CONTRIBUTING.md",
|
|
40
|
+
];
|
|
41
|
+
/** Directories under docs/ never worth descending into. */
|
|
42
|
+
const SKIP_DIR_RE = /^(node_modules|\.[^/]*|dist|build|out)$/;
|
|
43
|
+
/** Tokens too generic to signal relevance on their own. */
|
|
44
|
+
const STOPWORDS = new Set([
|
|
45
|
+
"the", "and", "for", "with", "this", "that", "what", "how", "does", "are",
|
|
46
|
+
"was", "were", "have", "has", "had", "not", "but", "can", "should", "would",
|
|
47
|
+
"our", "its", "any", "all", "one", "two", "use", "used", "using", "when",
|
|
48
|
+
"where", "which", "who", "why", "there", "here", "into", "from", "about",
|
|
49
|
+
"file", "files", "code", "codebase", "repo", "repository",
|
|
50
|
+
]);
|
|
51
|
+
/** Lowercased, deduped, stopword-free query tokens (length >= 3). */
|
|
52
|
+
export function tokenizeQuery(query) {
|
|
53
|
+
const tokens = new Set();
|
|
54
|
+
for (const raw of query.toLowerCase().split(/[^a-z0-9_-]+/)) {
|
|
55
|
+
const t = raw.replace(/^[-_]+|[-_]+$/g, "");
|
|
56
|
+
if (t.length >= 3 && !STOPWORDS.has(t))
|
|
57
|
+
tokens.add(t);
|
|
58
|
+
}
|
|
59
|
+
return [...tokens];
|
|
60
|
+
}
|
|
61
|
+
const defaultFs = {
|
|
62
|
+
readFileSync: (p) => fs.readFileSync(p, "utf8"),
|
|
63
|
+
readdirSync: (p) => fs.readdirSync(p, { withFileTypes: true }),
|
|
64
|
+
statSize: (p) => fs.statSync(p).size,
|
|
65
|
+
};
|
|
66
|
+
const GIT_OPTS = {
|
|
67
|
+
encoding: "utf8",
|
|
68
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
69
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
70
|
+
};
|
|
71
|
+
const defaultGit = {
|
|
72
|
+
listDocsAtHead: (cwd) => {
|
|
73
|
+
try {
|
|
74
|
+
const out = execFileSync("git", ["ls-tree", "-r", "--name-only", "HEAD"], { cwd, ...GIT_OPTS });
|
|
75
|
+
return out.split("\n").filter(Boolean);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
readAtHead: (cwd, rel) => {
|
|
82
|
+
try {
|
|
83
|
+
return execFileSync("git", ["show", `HEAD:${rel}`], { cwd, ...GIT_OPTS });
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
/** Is this HEAD-tracked path a doc we consider? (root doc names + docs/**.md) */
|
|
91
|
+
function isDocCandidate(rel) {
|
|
92
|
+
if (ROOT_DOC_NAMES.includes(rel))
|
|
93
|
+
return true;
|
|
94
|
+
return /^docs\//.test(rel) && /\.(md|markdown)$/i.test(rel);
|
|
95
|
+
}
|
|
96
|
+
/** Enumerate candidate doc paths: fixed root names + a bounded docs/ walk. */
|
|
97
|
+
function candidatePaths(cwd, maxFiles, fsImpl) {
|
|
98
|
+
const found = [];
|
|
99
|
+
for (const name of ROOT_DOC_NAMES) {
|
|
100
|
+
try {
|
|
101
|
+
if (fsImpl.statSize(path.join(cwd, name)) >= 0)
|
|
102
|
+
found.push(name);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
/* absent */
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Breadth-first bounded walk of docs/ so shallow (usually more general) docs
|
|
109
|
+
// win the scan cap over deeply nested ones.
|
|
110
|
+
const queue = ["docs"];
|
|
111
|
+
let scanned = 0;
|
|
112
|
+
while (queue.length > 0 && scanned < maxFiles) {
|
|
113
|
+
const rel = queue.shift();
|
|
114
|
+
let entries;
|
|
115
|
+
try {
|
|
116
|
+
entries = fsImpl.readdirSync(path.join(cwd, rel));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
if (scanned >= maxFiles)
|
|
123
|
+
break;
|
|
124
|
+
const relPath = path.join(rel, entry.name);
|
|
125
|
+
if (entry.isDirectory()) {
|
|
126
|
+
if (!SKIP_DIR_RE.test(entry.name))
|
|
127
|
+
queue.push(relPath);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (entry.isFile() && /\.(md|markdown)$/i.test(entry.name)) {
|
|
131
|
+
scanned += 1;
|
|
132
|
+
found.push(relPath);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return found;
|
|
137
|
+
}
|
|
138
|
+
/** Occurrence count of `needle` in `hay`, capped (both already lowercased). */
|
|
139
|
+
function cappedCount(hay, needle, cap) {
|
|
140
|
+
let count = 0;
|
|
141
|
+
let i = 0;
|
|
142
|
+
while (count < cap) {
|
|
143
|
+
i = hay.indexOf(needle, i);
|
|
144
|
+
if (i === -1)
|
|
145
|
+
break;
|
|
146
|
+
count += 1;
|
|
147
|
+
i += needle.length;
|
|
148
|
+
}
|
|
149
|
+
return count;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Build a bounded excerpt: the lines around each token match, merged into
|
|
153
|
+
* windows, joined with ellipses. A path-only hit (no body match) takes the head
|
|
154
|
+
* of the file instead — the title/intro is the most general summary.
|
|
155
|
+
*/
|
|
156
|
+
export function buildExcerpt(body, tokens, maxChars) {
|
|
157
|
+
const lines = body.split("\n");
|
|
158
|
+
const lower = lines.map((l) => l.toLowerCase());
|
|
159
|
+
const matched = [];
|
|
160
|
+
for (let i = 0; i < lower.length; i++) {
|
|
161
|
+
if (tokens.some((t) => lower[i].includes(t)))
|
|
162
|
+
matched.push(i);
|
|
163
|
+
}
|
|
164
|
+
if (matched.length === 0) {
|
|
165
|
+
return body.slice(0, maxChars);
|
|
166
|
+
}
|
|
167
|
+
// Merge +/-5-line windows around the matches, preserving order.
|
|
168
|
+
const CONTEXT = 5;
|
|
169
|
+
const ranges = [];
|
|
170
|
+
for (const m of matched) {
|
|
171
|
+
const start = Math.max(0, m - CONTEXT);
|
|
172
|
+
const end = Math.min(lines.length - 1, m + CONTEXT);
|
|
173
|
+
const last = ranges[ranges.length - 1];
|
|
174
|
+
if (last && start <= last[1] + 1)
|
|
175
|
+
last[1] = Math.max(last[1], end);
|
|
176
|
+
else
|
|
177
|
+
ranges.push([start, end]);
|
|
178
|
+
}
|
|
179
|
+
const parts = [];
|
|
180
|
+
let total = 0;
|
|
181
|
+
for (const [start, end] of ranges) {
|
|
182
|
+
const chunk = lines.slice(start, end + 1).join("\n").trim();
|
|
183
|
+
if (!chunk)
|
|
184
|
+
continue;
|
|
185
|
+
if (total + chunk.length > maxChars) {
|
|
186
|
+
const room = maxChars - total;
|
|
187
|
+
if (room > 80)
|
|
188
|
+
parts.push(chunk.slice(0, room));
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
parts.push(chunk);
|
|
192
|
+
total += chunk.length;
|
|
193
|
+
}
|
|
194
|
+
return parts.join("\n[...]\n");
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Collect the repo docs most relevant to `query` from the working tree at
|
|
198
|
+
* `cwd`. Returns [] on any repo without matching docs (or without docs at all)
|
|
199
|
+
* — the caller then sends nothing and the backend behaves exactly as before.
|
|
200
|
+
* Never throws: grounding must not be able to break the ask itself.
|
|
201
|
+
*/
|
|
202
|
+
export function collectRepoDocs(cwd, query, opts = {}) {
|
|
203
|
+
const maxDocs = opts.maxDocs ?? 3;
|
|
204
|
+
const maxExcerptChars = opts.maxExcerptChars ?? 2_000;
|
|
205
|
+
const maxFiles = opts.maxFiles ?? 400;
|
|
206
|
+
const maxFileBytes = opts.maxFileBytes ?? 512 * 1024;
|
|
207
|
+
const fsImpl = opts.fsImpl ?? defaultFs;
|
|
208
|
+
const gitImpl = opts.gitImpl === undefined ? defaultGit : opts.gitImpl;
|
|
209
|
+
try {
|
|
210
|
+
const tokens = tokenizeQuery(query);
|
|
211
|
+
if (tokens.length === 0)
|
|
212
|
+
return [];
|
|
213
|
+
// Prefer the git-HEAD baseline (echo-chamber guard, see module doc); fall
|
|
214
|
+
// back to the working-tree walk only when cwd is not a usable git tree.
|
|
215
|
+
const headDocs = gitImpl ? gitImpl.listDocsAtHead(cwd) : null;
|
|
216
|
+
const candidates = headDocs
|
|
217
|
+
? headDocs.filter(isDocCandidate).slice(0, maxFiles)
|
|
218
|
+
: candidatePaths(cwd, maxFiles, fsImpl);
|
|
219
|
+
const readBody = (rel) => {
|
|
220
|
+
if (headDocs)
|
|
221
|
+
return gitImpl.readAtHead(cwd, rel);
|
|
222
|
+
const abs = path.join(cwd, rel);
|
|
223
|
+
try {
|
|
224
|
+
if (fsImpl.statSize(abs) > maxFileBytes)
|
|
225
|
+
return null;
|
|
226
|
+
return fsImpl.readFileSync(abs);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
const scored = [];
|
|
233
|
+
for (const rel of candidates) {
|
|
234
|
+
const body = readBody(rel);
|
|
235
|
+
if (body === null || body.length > maxFileBytes)
|
|
236
|
+
continue;
|
|
237
|
+
const lowerBody = body.toLowerCase();
|
|
238
|
+
const lowerPath = rel.toLowerCase();
|
|
239
|
+
let score = 0;
|
|
240
|
+
for (const t of tokens) {
|
|
241
|
+
if (lowerPath.includes(t))
|
|
242
|
+
score += 10;
|
|
243
|
+
score += cappedCount(lowerBody, t, 5);
|
|
244
|
+
}
|
|
245
|
+
if (score >= 2)
|
|
246
|
+
scored.push({ rel, score, body });
|
|
247
|
+
}
|
|
248
|
+
scored.sort((a, b) => b.score - a.score || a.rel.length - b.rel.length || (a.rel < b.rel ? -1 : 1));
|
|
249
|
+
return scored.slice(0, maxDocs).map((s) => ({
|
|
250
|
+
// Repo-relative with forward slashes so the citation reads the same on
|
|
251
|
+
// every platform.
|
|
252
|
+
path: s.rel.split(path.sep).join("/"),
|
|
253
|
+
excerpt: buildExcerpt(s.body, tokenizeQuery(query), maxExcerptChars),
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
//# sourceMappingURL=repoDocs.js.map
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R4a - resilientFetch for the read-path grounding tools and boot fetches.
|
|
3
|
+
*
|
|
4
|
+
* All five grounding tools and both boot fetches single-shot `fetch` and throw raw
|
|
5
|
+
* on the first blip, so a transient 429/5xx/socket reset stops grounding answering
|
|
6
|
+
* mid-session. `resilientFetch` wraps any `fetch` impl with bounded,
|
|
7
|
+
* transient-only retry (honoring `Retry-After`), a per-attempt timeout composed
|
|
8
|
+
* with the caller's signal (reusing R1's `composeAbortSignal`), and a friendly
|
|
9
|
+
* error on network exhaustion. `friendlyFetchError` maps a non-ok response to an
|
|
10
|
+
* actionable, body-truncated message for the tools' own `!res.ok` throw.
|
|
11
|
+
*
|
|
12
|
+
* Metered/write POSTs use `METERED_POST_FETCH_POLICY` (one attempt): server-side
|
|
13
|
+
* idempotency is the right place for replay, and retrying billed or capture
|
|
14
|
+
* requests client-side can duplicate spend or recorded judgment.
|
|
15
|
+
*
|
|
16
|
+
* Pure-ish: backoff sleep + jitter are injectable so the unit tests never wait.
|
|
17
|
+
*/
|
|
18
|
+
/** Tuning surface for {@link resilientFetch}. */
|
|
19
|
+
export interface ResilientFetchPolicy {
|
|
20
|
+
/** Total attempts including the first (1 = no retry). */
|
|
21
|
+
maxAttempts: number;
|
|
22
|
+
backoffBaseMs: number;
|
|
23
|
+
backoffMaxMs: number;
|
|
24
|
+
/** Per-attempt wall-clock timeout (composed with the caller signal). */
|
|
25
|
+
timeoutMs: number;
|
|
26
|
+
/** Jitter as a fraction of the computed delay (+/-). */
|
|
27
|
+
jitterRatio: number;
|
|
28
|
+
}
|
|
29
|
+
/** Generous defaults: a few quick retries, a 30s per-attempt cap. */
|
|
30
|
+
export declare const DEFAULT_FETCH_POLICY: ResilientFetchPolicy;
|
|
31
|
+
/**
|
|
32
|
+
* POSTs that trigger LLM work or write YAGNI records must not be replayed by the
|
|
33
|
+
* client. Server-side idempotency is the right fix for retries; until then, make
|
|
34
|
+
* one attempt and surface the response.
|
|
35
|
+
*/
|
|
36
|
+
export declare const METERED_POST_FETCH_POLICY: ResilientFetchPolicy;
|
|
37
|
+
export interface ResilientFetchOpts {
|
|
38
|
+
policy?: ResilientFetchPolicy;
|
|
39
|
+
fetchImpl?: typeof fetch;
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
/** Backoff sleep (default: real timer); injected in tests. */
|
|
42
|
+
sleep?: (ms: number) => Promise<void>;
|
|
43
|
+
/** Jitter source in [0, 1) (default: Math.random). */
|
|
44
|
+
random?: () => number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Fetch with bounded, transient-only retry. Returns the Response for any success
|
|
48
|
+
* or non-retryable status (the caller still inspects `res.ok`); returns the LAST
|
|
49
|
+
* retryable response when attempts are exhausted; throws a friendly Error only on
|
|
50
|
+
* network exhaustion. Never retries once the caller signal has aborted.
|
|
51
|
+
*/
|
|
52
|
+
export declare function resilientFetch(url: string, init: RequestInit, opts?: ResilientFetchOpts): Promise<Response>;
|
|
53
|
+
/**
|
|
54
|
+
* Map a non-ok response to a friendly, body-truncated message for a tool's throw.
|
|
55
|
+
* Auth statuses get an actionable re-login hint; everything else carries the
|
|
56
|
+
* status plus a capped slice of the body so a giant HTML error never floods the
|
|
57
|
+
* agent. No em-dashes (house copy rule).
|
|
58
|
+
*/
|
|
59
|
+
export declare function friendlyFetchError(label: string, res: Response): Promise<string>;
|
|
60
|
+
//# sourceMappingURL=resilientFetch.d.ts.map
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R4a - resilientFetch for the read-path grounding tools and boot fetches.
|
|
3
|
+
*
|
|
4
|
+
* All five grounding tools and both boot fetches single-shot `fetch` and throw raw
|
|
5
|
+
* on the first blip, so a transient 429/5xx/socket reset stops grounding answering
|
|
6
|
+
* mid-session. `resilientFetch` wraps any `fetch` impl with bounded,
|
|
7
|
+
* transient-only retry (honoring `Retry-After`), a per-attempt timeout composed
|
|
8
|
+
* with the caller's signal (reusing R1's `composeAbortSignal`), and a friendly
|
|
9
|
+
* error on network exhaustion. `friendlyFetchError` maps a non-ok response to an
|
|
10
|
+
* actionable, body-truncated message for the tools' own `!res.ok` throw.
|
|
11
|
+
*
|
|
12
|
+
* Metered/write POSTs use `METERED_POST_FETCH_POLICY` (one attempt): server-side
|
|
13
|
+
* idempotency is the right place for replay, and retrying billed or capture
|
|
14
|
+
* requests client-side can duplicate spend or recorded judgment.
|
|
15
|
+
*
|
|
16
|
+
* Pure-ish: backoff sleep + jitter are injectable so the unit tests never wait.
|
|
17
|
+
*/
|
|
18
|
+
import { composeAbortSignal } from "./pipeline/resilience.js";
|
|
19
|
+
/** Generous defaults: a few quick retries, a 30s per-attempt cap. */
|
|
20
|
+
export const DEFAULT_FETCH_POLICY = {
|
|
21
|
+
maxAttempts: 3,
|
|
22
|
+
backoffBaseMs: 300,
|
|
23
|
+
backoffMaxMs: 5_000,
|
|
24
|
+
timeoutMs: 30_000,
|
|
25
|
+
jitterRatio: 0.25,
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* POSTs that trigger LLM work or write YAGNI records must not be replayed by the
|
|
29
|
+
* client. Server-side idempotency is the right fix for retries; until then, make
|
|
30
|
+
* one attempt and surface the response.
|
|
31
|
+
*/
|
|
32
|
+
export const METERED_POST_FETCH_POLICY = {
|
|
33
|
+
...DEFAULT_FETCH_POLICY,
|
|
34
|
+
maxAttempts: 1,
|
|
35
|
+
};
|
|
36
|
+
/** Statuses worth retrying: request timeout, too-early, rate limit, and 5xx. */
|
|
37
|
+
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
38
|
+
const MAX_ERROR_BODY = 800;
|
|
39
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
40
|
+
/** Parse a `Retry-After` header (delta seconds; HTTP-date is ignored) into ms, or null. */
|
|
41
|
+
function retryAfterMs(res) {
|
|
42
|
+
const raw = res.headers.get("retry-after");
|
|
43
|
+
if (!raw)
|
|
44
|
+
return null;
|
|
45
|
+
const secs = Number(raw.trim());
|
|
46
|
+
if (Number.isFinite(secs) && secs >= 0)
|
|
47
|
+
return Math.round(secs * 1000);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
function backoff(policy, attempt, random) {
|
|
51
|
+
const raw = Math.min(policy.backoffMaxMs, policy.backoffBaseMs * 2 ** (attempt - 1));
|
|
52
|
+
const jitter = raw * policy.jitterRatio * (random() * 2 - 1);
|
|
53
|
+
return Math.max(0, Math.round(raw + jitter));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Fetch with bounded, transient-only retry. Returns the Response for any success
|
|
57
|
+
* or non-retryable status (the caller still inspects `res.ok`); returns the LAST
|
|
58
|
+
* retryable response when attempts are exhausted; throws a friendly Error only on
|
|
59
|
+
* network exhaustion. Never retries once the caller signal has aborted.
|
|
60
|
+
*/
|
|
61
|
+
export async function resilientFetch(url, init, opts = {}) {
|
|
62
|
+
const policy = opts.policy ?? DEFAULT_FETCH_POLICY;
|
|
63
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
64
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
65
|
+
const random = opts.random ?? Math.random;
|
|
66
|
+
const callerSignal = opts.signal;
|
|
67
|
+
let lastError;
|
|
68
|
+
let lastResponse;
|
|
69
|
+
for (let attempt = 1; attempt <= policy.maxAttempts; attempt++) {
|
|
70
|
+
if (callerSignal?.aborted)
|
|
71
|
+
break;
|
|
72
|
+
// Per-attempt timeout via a manual controller so the timer is always cleared
|
|
73
|
+
// on settle (AbortSignal.timeout would leak a ref'd timer past a fast success).
|
|
74
|
+
const timeoutController = new AbortController();
|
|
75
|
+
const timer = setTimeout(() => timeoutController.abort(), policy.timeoutMs);
|
|
76
|
+
timer.unref?.();
|
|
77
|
+
const signal = composeAbortSignal(callerSignal, timeoutController.signal);
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetchImpl(url, { ...init, signal });
|
|
80
|
+
if (res.ok || !RETRYABLE_STATUS.has(res.status))
|
|
81
|
+
return res;
|
|
82
|
+
lastResponse = res;
|
|
83
|
+
if (attempt >= policy.maxAttempts)
|
|
84
|
+
return res;
|
|
85
|
+
const wait = retryAfterMs(res) ?? backoff(policy, attempt, random);
|
|
86
|
+
await sleep(wait);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
lastError = err;
|
|
90
|
+
// A genuine caller cancel must not be retried; a timeout abort is retryable.
|
|
91
|
+
if (callerSignal?.aborted)
|
|
92
|
+
throw err;
|
|
93
|
+
if (attempt >= policy.maxAttempts)
|
|
94
|
+
break;
|
|
95
|
+
await sleep(backoff(policy, attempt, random));
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (lastResponse)
|
|
102
|
+
return lastResponse;
|
|
103
|
+
const reason = lastError instanceof Error ? lastError.message : String(lastError ?? "unknown error");
|
|
104
|
+
throw new Error(`could not reach the YAGNI backend after ${policy.maxAttempts} attempts (network): ${reason}`);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Map a non-ok response to a friendly, body-truncated message for a tool's throw.
|
|
108
|
+
* Auth statuses get an actionable re-login hint; everything else carries the
|
|
109
|
+
* status plus a capped slice of the body so a giant HTML error never floods the
|
|
110
|
+
* agent. No em-dashes (house copy rule).
|
|
111
|
+
*/
|
|
112
|
+
export async function friendlyFetchError(label, res) {
|
|
113
|
+
let body = "";
|
|
114
|
+
try {
|
|
115
|
+
body = (await res.text()).trim();
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
/* body unreadable */
|
|
119
|
+
}
|
|
120
|
+
if (body.length > MAX_ERROR_BODY)
|
|
121
|
+
body = `${body.slice(0, MAX_ERROR_BODY)} ...`;
|
|
122
|
+
if (res.status === 401 || res.status === 403) {
|
|
123
|
+
return `${label} is not authorized (HTTP ${res.status}). Run \`yagni login\` to re-authenticate.`;
|
|
124
|
+
}
|
|
125
|
+
if (res.status === 429) {
|
|
126
|
+
return `${label} was rate limited (HTTP 429). Try again in a moment.`;
|
|
127
|
+
}
|
|
128
|
+
if (res.status >= 500) {
|
|
129
|
+
return `${label} failed: the YAGNI backend returned HTTP ${res.status}.${body ? ` ${body}` : ""}`;
|
|
130
|
+
}
|
|
131
|
+
return `${label} failed: HTTP ${res.status}.${body ? ` ${body}` : ""}`;
|
|
132
|
+
}
|
|
133
|
+
//# sourceMappingURL=resilientFetch.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import type { Citation } from "./askYagniTool.js";
|
|
4
|
+
/** Options for {@link makeReviewBusinessMatchTool}. */
|
|
5
|
+
export interface MakeReviewToolOptions {
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
getToken: () => string | undefined;
|
|
8
|
+
fetchImpl?: typeof fetch;
|
|
9
|
+
/**
|
|
10
|
+
* How to obtain the diff when the agent doesn't pass one. Injectable so the
|
|
11
|
+
* unit test is deterministic; defaults to reading the repo's staged (then
|
|
12
|
+
* unstaged) changes via git. pi's ExtensionContext exposes no exec, so the
|
|
13
|
+
* tool shells out itself.
|
|
14
|
+
*/
|
|
15
|
+
getDiff?: (cwd: string | undefined, signal: AbortSignal | undefined) => Promise<string>;
|
|
16
|
+
}
|
|
17
|
+
declare const parameters: Type.TObject<{
|
|
18
|
+
summary: Type.TOptional<Type.TString>;
|
|
19
|
+
diff: Type.TOptional<Type.TString>;
|
|
20
|
+
ticket: Type.TOptional<Type.TString>;
|
|
21
|
+
}>;
|
|
22
|
+
/**
|
|
23
|
+
* Build the `review_business_match` tool.
|
|
24
|
+
*
|
|
25
|
+
* Reviews a code change for BUSINESS fit — whether it matches how THIS company
|
|
26
|
+
* works and its current priorities — grounded in the YAGNI app, with citations.
|
|
27
|
+
* Distinct from a correctness/bug review: it catches "right code, wrong thing"
|
|
28
|
+
* (contradicts a decision, solves a narrower thing than the customer needs).
|
|
29
|
+
*/
|
|
30
|
+
export declare function makeReviewBusinessMatchTool(opts: MakeReviewToolOptions): ToolDefinition<typeof parameters, {
|
|
31
|
+
citations: Citation[];
|
|
32
|
+
}>;
|
|
33
|
+
export {};
|
|
34
|
+
//# sourceMappingURL=reviewTool.d.ts.map
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { friendlyFetchError, METERED_POST_FETCH_POLICY, resilientFetch } from "./resilientFetch.js";
|
|
4
|
+
const parameters = Type.Object({
|
|
5
|
+
summary: Type.Optional(Type.String()),
|
|
6
|
+
diff: Type.Optional(Type.String()),
|
|
7
|
+
ticket: Type.Optional(Type.String()),
|
|
8
|
+
});
|
|
9
|
+
function runGit(args, cwd, signal) {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
execFile("git", args, { cwd, signal, maxBuffer: 32 * 1024 * 1024 }, (err, stdout) => resolve(err ? "" : stdout));
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
async function defaultGetDiff(cwd, signal) {
|
|
15
|
+
const staged = await runGit(["diff", "--staged"], cwd, signal);
|
|
16
|
+
if (staged.trim().length > 0)
|
|
17
|
+
return staged;
|
|
18
|
+
return runGit(["diff"], cwd, signal);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Build the `review_business_match` tool.
|
|
22
|
+
*
|
|
23
|
+
* Reviews a code change for BUSINESS fit — whether it matches how THIS company
|
|
24
|
+
* works and its current priorities — grounded in the YAGNI app, with citations.
|
|
25
|
+
* Distinct from a correctness/bug review: it catches "right code, wrong thing"
|
|
26
|
+
* (contradicts a decision, solves a narrower thing than the customer needs).
|
|
27
|
+
*/
|
|
28
|
+
export function makeReviewBusinessMatchTool(opts) {
|
|
29
|
+
return {
|
|
30
|
+
name: "review_business_match",
|
|
31
|
+
label: "Review (business fit)",
|
|
32
|
+
description: "Review a code change for BUSINESS fit (not bugs/style): does it match how this " +
|
|
33
|
+
"company works and its current priorities, and does it conflict with any decision " +
|
|
34
|
+
"or convention? Grounded in the YAGNI app, returns a verdict plus citations. Pass " +
|
|
35
|
+
"a `diff` (and a `summary` of intent); if omitted, the repo's staged/unstaged diff " +
|
|
36
|
+
"is used. Run this before opening a PR.",
|
|
37
|
+
promptSnippet: "review_business_match: check a diff against how the company works + its priorities (cited).",
|
|
38
|
+
promptGuidelines: [
|
|
39
|
+
"Run review_business_match before finishing a change, especially anything product- or policy-adjacent.",
|
|
40
|
+
"Pass a one-line `summary` of what the change is for and the `ticket` it serves, so the review can judge fit.",
|
|
41
|
+
"Treat flagged conflicts as real: reconcile with the cited decision/convention or ask the user.",
|
|
42
|
+
],
|
|
43
|
+
parameters,
|
|
44
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
45
|
+
onUpdate?.({
|
|
46
|
+
content: [{ type: "text", text: "Reviewing for business fit with YAGNI…" }],
|
|
47
|
+
details: { citations: [] },
|
|
48
|
+
});
|
|
49
|
+
const getDiff = opts.getDiff ?? defaultGetDiff;
|
|
50
|
+
let diff = params.diff?.trim() ?? "";
|
|
51
|
+
if (!diff) {
|
|
52
|
+
diff = (await getDiff(ctx?.cwd, signal)).trim();
|
|
53
|
+
}
|
|
54
|
+
if (!diff) {
|
|
55
|
+
throw new Error("review_business_match: no diff provided and no staged/unstaged git changes were found in the repo.");
|
|
56
|
+
}
|
|
57
|
+
const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/review`, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: {
|
|
60
|
+
"content-type": "application/json",
|
|
61
|
+
authorization: `Bearer ${opts.getToken() ?? ""}`,
|
|
62
|
+
},
|
|
63
|
+
body: JSON.stringify({
|
|
64
|
+
diff,
|
|
65
|
+
summary: params.summary,
|
|
66
|
+
ticket: params.ticket,
|
|
67
|
+
cwd: ctx?.cwd,
|
|
68
|
+
}),
|
|
69
|
+
}, { fetchImpl: opts.fetchImpl, signal, policy: METERED_POST_FETCH_POLICY });
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
throw new Error(await friendlyFetchError("review_business_match", res));
|
|
72
|
+
}
|
|
73
|
+
const data = (await res.json());
|
|
74
|
+
return {
|
|
75
|
+
content: [{ type: "text", text: data.review }],
|
|
76
|
+
details: { citations: data.citations ?? [] },
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=reviewTool.js.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable write-spool for the judgment-capture tools (spec §4, R4 write half).
|
|
3
|
+
*
|
|
4
|
+
* `record_decision` and `record_engineering_context` are metered writes that
|
|
5
|
+
* must never be blindly retried (invariant §0.8) — but losing a captured
|
|
6
|
+
* decision because the laptop lost Wi-Fi is worse. The backend now dedups on
|
|
7
|
+
* an `idempotencyKey` (§2), so the safe shape is:
|
|
8
|
+
*
|
|
9
|
+
* - every logical write carries a generated idempotencyKey,
|
|
10
|
+
* - a transport failure or 5xx appends `{endpoint, body, idempotencyKey, ts}`
|
|
11
|
+
* to `~/.yagni-code/spool/pending.ndjson` (dir 0700, file 0600,
|
|
12
|
+
* torn-line-tolerant — the same self-healing NDJSON pattern as
|
|
13
|
+
* pipeline/checkpoint.ts),
|
|
14
|
+
* - the spool is flushed at session_start and after any successful write,
|
|
15
|
+
* replaying with the SAME idempotencyKey (the server replays the stored
|
|
16
|
+
* response, so a duplicate flush is harmless),
|
|
17
|
+
* - entries older than 14 days are dropped with an honest notice (stale
|
|
18
|
+
* judgment silently landing weeks later would be worse than losing it).
|
|
19
|
+
*
|
|
20
|
+
* Everything here is FAIL-SOFT: a spool problem must never break a tool call
|
|
21
|
+
* or session start. Eval mode never reaches this module (the capture tools are
|
|
22
|
+
* not registered there).
|
|
23
|
+
*/
|
|
24
|
+
/** Spooled entries older than this are dropped at flush (with a notice). */
|
|
25
|
+
export declare const MAX_SPOOL_AGE_MS: number;
|
|
26
|
+
/** One durable pending write: replayed verbatim with the same idempotencyKey. */
|
|
27
|
+
export interface SpoolEntry {
|
|
28
|
+
/** Repo-relative endpoint path, e.g. `/api/yagni-code/decisions`. */
|
|
29
|
+
endpoint: string;
|
|
30
|
+
/** The JSON body as originally posted (already carrying the idempotencyKey). */
|
|
31
|
+
body: Record<string, unknown>;
|
|
32
|
+
idempotencyKey: string;
|
|
33
|
+
/** Epoch ms when the write first failed (drives the 14-day expiry). */
|
|
34
|
+
ts: number;
|
|
35
|
+
}
|
|
36
|
+
export declare function _setSpoolHomeForTest(dir: string | null): void;
|
|
37
|
+
/** The on-disk pending file (exported so tests can inspect it directly). */
|
|
38
|
+
export declare function spoolFile(): string;
|
|
39
|
+
/** Append one pending write. NEVER throws (fail-soft: memory loss beats a crash). */
|
|
40
|
+
export declare function appendToSpool(entry: SpoolEntry): void;
|
|
41
|
+
/** Load pending entries, dropping torn/garbage lines. Never throws. */
|
|
42
|
+
export declare function loadSpool(): SpoolEntry[];
|
|
43
|
+
/** The transport/auth surface a flush or a spooling write posts through. */
|
|
44
|
+
export interface SpoolClientOpts {
|
|
45
|
+
baseUrl: string;
|
|
46
|
+
getToken: () => string | undefined;
|
|
47
|
+
fetchImpl?: typeof fetch;
|
|
48
|
+
now?: () => number;
|
|
49
|
+
}
|
|
50
|
+
/** What one flush did — `notices` are honest, user-showable one-liners. */
|
|
51
|
+
export interface FlushOutcome {
|
|
52
|
+
replayed: number;
|
|
53
|
+
droppedExpired: number;
|
|
54
|
+
droppedRejected: number;
|
|
55
|
+
remaining: number;
|
|
56
|
+
notices: string[];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Replay every pending write with its ORIGINAL idempotencyKey (server-side
|
|
60
|
+
* dedup makes a double replay a no-op). Serialized: concurrent flushes (a
|
|
61
|
+
* session_start racing a post-write flush) coalesce onto one pass.
|
|
62
|
+
*
|
|
63
|
+
* Per entry: expired (>14d) → dropped with a notice; 2xx → replayed; a
|
|
64
|
+
* permanent 4xx (not auth/rate) → dropped with a notice (the backend will
|
|
65
|
+
* never accept it); transport error, 5xx, 401/403/408/429 → kept, and the
|
|
66
|
+
* rest of the flush stops (the backend is unreachable or the token is bad —
|
|
67
|
+
* hammering the remaining entries would not help). Never throws.
|
|
68
|
+
*/
|
|
69
|
+
export declare function flushSpool(opts: SpoolClientOpts): Promise<FlushOutcome>;
|
|
70
|
+
/** The outcome of one spool-backed judgment write. */
|
|
71
|
+
export type JudgmentWriteOutcome = {
|
|
72
|
+
kind: "ok";
|
|
73
|
+
json: unknown;
|
|
74
|
+
}
|
|
75
|
+
/** Transport failure or 5xx: the write is durably spooled for replay. */
|
|
76
|
+
| {
|
|
77
|
+
kind: "spooled";
|
|
78
|
+
reason: string;
|
|
79
|
+
}
|
|
80
|
+
/** A non-retryable client error: the caller should throw this message. */
|
|
81
|
+
| {
|
|
82
|
+
kind: "rejected";
|
|
83
|
+
message: string;
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* POST one judgment write with its idempotencyKey; on transport failure or a
|
|
87
|
+
* 5xx, durably spool it for replay instead of losing it. A successful write
|
|
88
|
+
* also kicks a background flush (fail-soft, fire-and-forget) so earlier
|
|
89
|
+
* stranded entries drain as soon as the backend is healthy again.
|
|
90
|
+
*/
|
|
91
|
+
export declare function sendOrSpool(opts: SpoolClientOpts, label: string, endpoint: string, body: Record<string, unknown>, idempotencyKey: string, signal?: AbortSignal): Promise<JudgmentWriteOutcome>;
|
|
92
|
+
//# sourceMappingURL=spool.d.ts.map
|