@nurix/nustack 0.23.12 → 0.23.14
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/dist/commands/agent.js +1 -53
- package/dist/commands/check.js +73 -0
- package/dist/commands/deploy.js +2 -1
- package/dist/commands/doctor.js +2 -1
- package/dist/commands/harness.js +127 -48
- package/dist/commands/init.js +103 -3
- package/dist/commands/login.js +9 -2
- package/dist/commands/mcp.js +7 -0
- package/dist/commands/project-sync.js +2 -1
- package/dist/commands/read.js +19 -0
- package/dist/commands/records.js +10 -0
- package/dist/commands/review.js +3 -2
- package/dist/commands/sessions.js +4 -1
- package/dist/commands/sync.js +2 -1
- package/dist/index.js +4 -0
- package/dist/lib/agent/claudeHooks.js +4 -1
- package/dist/lib/agent/desktopDoor.js +55 -0
- package/dist/lib/auth_grant.js +15 -3
- package/dist/lib/auth_recovery.js +10 -3
- package/dist/lib/etna/manager.js +24 -6
- package/dist/lib/etna/profile.js +6 -0
- package/dist/lib/etna/reconcile.js +6 -0
- package/dist/lib/gate/bulkReadRelay.js +354 -0
- package/dist/lib/gate/hook.js +30 -3
- package/dist/lib/gate/outline.js +12 -0
- package/dist/lib/gate/settings.js +24 -1
- package/dist/lib/gate/tierOne.js +22 -0
- package/dist/lib/harness/codes.js +24 -0
- package/dist/lib/harness/hookCheck.js +325 -0
- package/dist/lib/harness/hookEntry.js +27 -0
- package/dist/lib/harness/legacyMount.js +231 -0
- package/dist/lib/harness/mount.js +199 -131
- package/dist/lib/harness/storeWaivers.js +43 -0
- package/dist/lib/harness/vendor.js +29 -0
- package/dist/lib/harness/versionsClient.js +54 -0
- package/dist/lib/local_store/database.js +18 -0
- package/dist/lib/local_store/routing.js +26 -0
- package/dist/lib/nustack_client.js +35 -2
- package/dist/lib/onboarding/attach_existing_git.js +14 -2
- package/dist/lib/onboarding/binding.js +46 -2
- package/dist/lib/onboarding/commit_trailer_hook.js +66 -0
- package/dist/lib/onboarding/manifest_writer.js +19 -3
- package/dist/lib/onboarding/session_hook.js +76 -0
- package/dist/lib/origin_guard.js +43 -0
- package/dist/lib/project_contract.js +104 -0
- package/dist/lib/records/door.js +26 -19
- package/dist/lib/records/drain.js +10 -4
- package/dist/lib/records/refusalLog.js +30 -1
- package/dist/lib/session-engine/supervisor.js +6 -18
- package/dist/lib/sessions/client.js +0 -12
- package/dist/program.js +27 -4
- package/dist/vendor/harness/assemble/runtimes.js +32 -0
- package/dist/vendor/harness/assemble/settingsMerge.js +136 -0
- package/dist/vendor/harness/digest/index.js +38 -0
- package/dist/vendor/harness/lib/findings.js +32 -0
- package/dist/vendor/harness/lib/frontmatter.js +79 -0
- package/dist/vendor/harness/lib/paths.js +31 -0
- package/dist/vendor/harness/mount/cache.js +65 -0
- package/dist/vendor/harness/mount/exclude.js +77 -0
- package/dist/vendor/harness/mount/files.js +38 -0
- package/dist/vendor/harness/mount/index.js +352 -0
- package/dist/vendor/harness/rules/changeRules.js +162 -0
- package/dist/vendor/harness/rules/context.js +38 -0
- package/dist/vendor/harness/rules/docsRoot.js +34 -0
- package/dist/vendor/harness/rules/index.js +109 -0
- package/dist/vendor/harness/rules/node.js +144 -0
- package/dist/vendor/harness/rules/outputGate.js +28 -0
- package/dist/vendor/harness/rules/ownership.js +178 -0
- package/dist/vendor/harness/rules/packageTable.js +113 -0
- package/dist/vendor/harness/rules/plans.js +96 -0
- package/dist/vendor/harness/rules/sessionWalls.js +31 -0
- package/package.json +5 -5
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { performance } from "node:perf_hooks";
|
|
5
|
+
import { isPidAlive, probeProjects, readServerRecord } from "../agent/desktopDoor.js";
|
|
6
|
+
import { printJsonSuccess } from "../json_output.js";
|
|
7
|
+
import { DIRECT_SESSION_ID } from "../local_store/routing.js";
|
|
8
|
+
import { estimateReadTokens, readLeadingLines, READ_DEFAULT_LINE_LIMIT, regularFileSize, tokensOfLength } from "./estimate.js";
|
|
9
|
+
import { buildOutline, loadOutlineLibrary, OUTLINE_MAX_CHARS } from "./outline.js";
|
|
10
|
+
import { READ_BULK_BUDGET_BOUNDS_MS, resolveReadBulkBudget } from "./settings.js";
|
|
11
|
+
const RELAY_TIMEOUT_MARGIN_MS = 10_000;
|
|
12
|
+
const OUTLINE_SOURCE_MAX_BYTES = 8 * 1024 * 1024;
|
|
13
|
+
const OUTLINE_HEAD_SCAN_LINES = 20;
|
|
14
|
+
const REASON_FINDINGS_MAX = 3;
|
|
15
|
+
const EXIT_CODES = { pass: 0, fail: 1, budget: 1, latched: 1, unavailable: 2 };
|
|
16
|
+
const DESKTOP_CLOSED = "the NuStack desktop is not running — open it, then ask again";
|
|
17
|
+
const DESKTOP_SILENT = "the NuStack desktop did not answer — open it, then ask again";
|
|
18
|
+
const DESKTOP_OLD = "the desktop is older than the checked bulk read — update NuStack";
|
|
19
|
+
function realPathOf(file) {
|
|
20
|
+
try {
|
|
21
|
+
return realpathSync(file);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return file;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function isWithin(root, file) {
|
|
28
|
+
return file === root || file.startsWith(root.endsWith(path.sep) ? root : `${root}${path.sep}`);
|
|
29
|
+
}
|
|
30
|
+
export function pickProject(projects, requested) {
|
|
31
|
+
let best = null;
|
|
32
|
+
for (const project of projects) {
|
|
33
|
+
const projectRealPath = realPathOf(project.path);
|
|
34
|
+
if (!requested.every((entry) => isWithin(projectRealPath, entry.realPath)))
|
|
35
|
+
continue;
|
|
36
|
+
if (best === null || projectRealPath.length > best.projectRealPath.length)
|
|
37
|
+
best = { project, projectRealPath };
|
|
38
|
+
}
|
|
39
|
+
if (best !== null)
|
|
40
|
+
return best;
|
|
41
|
+
const firstHolder = projects.map((project) => realPathOf(project.path)).find((root) => isWithin(root, requested[0].realPath));
|
|
42
|
+
const unheld = firstHolder === undefined ? requested[0] : requested.find((entry) => !isWithin(firstHolder, entry.realPath));
|
|
43
|
+
return { unheldPath: unheld.given };
|
|
44
|
+
}
|
|
45
|
+
function findingText(finding) {
|
|
46
|
+
if (typeof finding === "string")
|
|
47
|
+
return finding;
|
|
48
|
+
if (finding !== null && typeof finding === "object") {
|
|
49
|
+
const fields = finding;
|
|
50
|
+
const at = typeof fields.at === "string" ? fields.at : null;
|
|
51
|
+
const detail = [fields.message, fields.text, fields.detail, fields.kind].find((value) => typeof value === "string");
|
|
52
|
+
if (at !== null && detail !== undefined)
|
|
53
|
+
return `${at}: ${detail}`;
|
|
54
|
+
if (detail !== undefined)
|
|
55
|
+
return detail;
|
|
56
|
+
}
|
|
57
|
+
return JSON.stringify(finding);
|
|
58
|
+
}
|
|
59
|
+
function isDigestCheck(value) {
|
|
60
|
+
if (value === null || typeof value !== "object")
|
|
61
|
+
return false;
|
|
62
|
+
const check = value;
|
|
63
|
+
return (check.result === "pass" || check.result === "fail" || check.result === "budget") && Array.isArray(check.findings);
|
|
64
|
+
}
|
|
65
|
+
function emptied(digest) {
|
|
66
|
+
return { ...digest, files: (digest.files ?? []).map((file) => ({ ...file, chunks: [] })) };
|
|
67
|
+
}
|
|
68
|
+
async function askDoor(port, token, body, timeoutMs) {
|
|
69
|
+
let response;
|
|
70
|
+
try {
|
|
71
|
+
response = await fetch(`http://127.0.0.1:${port}/api/read/bulk`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { Authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
const name = error?.name;
|
|
80
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
81
|
+
return { result: "budget", reason: `the desktop did not answer within ${timeoutMs} ms`, digest: null, check: null, miss: "budget" };
|
|
82
|
+
}
|
|
83
|
+
return { result: "unavailable", reason: DESKTOP_SILENT, digest: null, check: null, miss: "failure" };
|
|
84
|
+
}
|
|
85
|
+
let envelope = null;
|
|
86
|
+
try {
|
|
87
|
+
envelope = (await response.json());
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
envelope = null;
|
|
91
|
+
}
|
|
92
|
+
const message = typeof envelope?.message === "string" && envelope.message !== "" ? envelope.message : `HTTP ${response.status}`;
|
|
93
|
+
if (response.status >= 400 && response.status < 500) {
|
|
94
|
+
return { result: "fail", reason: `the desktop refused the bulk read: ${message}`, digest: null, check: null, miss: null };
|
|
95
|
+
}
|
|
96
|
+
if (response.status !== 200 || envelope === null || envelope.data === null || typeof envelope.data !== "object") {
|
|
97
|
+
const reason = response.status === 200 ? DESKTOP_SILENT : `the desktop could not run the bulk read: ${message}`;
|
|
98
|
+
return { result: "unavailable", reason, digest: null, check: null, miss: "failure" };
|
|
99
|
+
}
|
|
100
|
+
const data = envelope.data;
|
|
101
|
+
if (!isDigestCheck(data.check))
|
|
102
|
+
return { result: "unavailable", reason: DESKTOP_OLD, digest: null, check: null, miss: "failure" };
|
|
103
|
+
const check = data.check;
|
|
104
|
+
if (check.result === "pass")
|
|
105
|
+
return { result: "pass", reason: null, digest: data, check, miss: null };
|
|
106
|
+
if (check.result === "budget") {
|
|
107
|
+
const budget = typeof data.budgetMs === "number" ? ` of ${data.budgetMs} ms` : "";
|
|
108
|
+
return { result: "budget", reason: `the digest ran past its budget${budget}`, digest: emptied(data), check, miss: "budget" };
|
|
109
|
+
}
|
|
110
|
+
const shown = check.findings.slice(0, REASON_FINDINGS_MAX).map((finding) => `- ${findingText(finding)}`);
|
|
111
|
+
const more = check.findings.length > REASON_FINDINGS_MAX ? [`+${check.findings.length - REASON_FINDINGS_MAX} more findings`] : [];
|
|
112
|
+
return {
|
|
113
|
+
result: "fail",
|
|
114
|
+
reason: ["the digest failed its check:", ...shown, ...more].join("\n"),
|
|
115
|
+
digest: emptied(data),
|
|
116
|
+
check,
|
|
117
|
+
miss: "failure",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function lineCountOf(file) {
|
|
121
|
+
try {
|
|
122
|
+
return readLeadingLines(file, Number.POSITIVE_INFINITY).lines;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function renderPass(digest, question, requested, relativeOf) {
|
|
129
|
+
const perPath = new Map();
|
|
130
|
+
const sections = [];
|
|
131
|
+
for (const file of digest.files ?? []) {
|
|
132
|
+
const entry = requested.find((candidate) => relativeOf(candidate) === file.path);
|
|
133
|
+
const lines = [`${entry?.given ?? file.path} · ${question}`];
|
|
134
|
+
const chunks = file.chunks ?? [];
|
|
135
|
+
for (const chunk of chunks) {
|
|
136
|
+
lines.push(chunk.at);
|
|
137
|
+
for (const note of chunk.notes ?? []) {
|
|
138
|
+
const quotes = (note.quotes ?? []).length > 0 ? ` [${(note.quotes ?? []).join(", ")}]` : "";
|
|
139
|
+
lines.push(`- ${note.text ?? ""}${quotes}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (digest.truncated === true && entry !== undefined) {
|
|
143
|
+
const total = lineCountOf(entry.realPath);
|
|
144
|
+
const last = chunks.length > 0 ? Number(/-(\d+)$/.exec(chunks[chunks.length - 1].at)?.[1] ?? 0) : 0;
|
|
145
|
+
if (total !== null && last < total)
|
|
146
|
+
lines.push(`covers lines 1–${last} of ${total}`);
|
|
147
|
+
}
|
|
148
|
+
const section = lines.join("\n");
|
|
149
|
+
sections.push(section);
|
|
150
|
+
if (entry !== undefined)
|
|
151
|
+
perPath.set(entry.realPath, (perPath.get(entry.realPath) ?? 0) + section.length);
|
|
152
|
+
}
|
|
153
|
+
const footer = `model ${digest.model ?? "unknown"} · ${digest.elapsedMs ?? 0} ms · check pass`;
|
|
154
|
+
return { text: [...sections, footer].join("\n\n"), perPath };
|
|
155
|
+
}
|
|
156
|
+
function outlineSourceOf(file) {
|
|
157
|
+
const size = regularFileSize(file);
|
|
158
|
+
if (size === null)
|
|
159
|
+
return null;
|
|
160
|
+
try {
|
|
161
|
+
if (size <= OUTLINE_SOURCE_MAX_BYTES)
|
|
162
|
+
return { source: readFileSync(file, "utf8") };
|
|
163
|
+
const head = readLeadingLines(file, OUTLINE_HEAD_SCAN_LINES);
|
|
164
|
+
const source = readFileSync(file).subarray(0, head.bytes).toString("utf8");
|
|
165
|
+
return { source, partialLineCount: readLeadingLines(file, Number.POSITIVE_INFINITY).lines };
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function estimatedTokensOf(file) {
|
|
172
|
+
try {
|
|
173
|
+
return estimateReadTokens(readLeadingLines(file, READ_DEFAULT_LINE_LIMIT));
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function buildOutlines(requested, estimates) {
|
|
180
|
+
const library = await loadOutlineLibrary();
|
|
181
|
+
const outlines = [];
|
|
182
|
+
let total = 0;
|
|
183
|
+
let omitted = 0;
|
|
184
|
+
for (const entry of requested) {
|
|
185
|
+
const read = outlineSourceOf(entry.realPath);
|
|
186
|
+
if (read === null) {
|
|
187
|
+
omitted++;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const rendered = await buildOutline({
|
|
191
|
+
displayPath: entry.given,
|
|
192
|
+
source: read.source,
|
|
193
|
+
estimatedTokens: estimates.get(entry.realPath) ?? 0,
|
|
194
|
+
tool: "read",
|
|
195
|
+
partialLineCount: read.partialLineCount,
|
|
196
|
+
}, read.partialLineCount === undefined ? library : null);
|
|
197
|
+
if (total + rendered.text.length > OUTLINE_MAX_CHARS) {
|
|
198
|
+
omitted++;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
total += rendered.text.length;
|
|
202
|
+
outlines.push({ path: entry.given, realPath: entry.realPath, text: rendered.text });
|
|
203
|
+
}
|
|
204
|
+
return { outlines, omitted };
|
|
205
|
+
}
|
|
206
|
+
function renderNonPass(result, reason, outlines, omitted, requested) {
|
|
207
|
+
const perPath = new Map();
|
|
208
|
+
const blocks = [`nustack read bulk: ${result} — ${reason}`];
|
|
209
|
+
for (const outline of outlines) {
|
|
210
|
+
blocks.push(outline.text);
|
|
211
|
+
perPath.set(outline.realPath, (perPath.get(outline.realPath) ?? 0) + outline.text.length);
|
|
212
|
+
}
|
|
213
|
+
if (omitted > 0)
|
|
214
|
+
blocks.push(`+${omitted} outlines omitted`);
|
|
215
|
+
const promises = requested.map((entry) => {
|
|
216
|
+
const line = `A full Read of ${entry.given} now passes.`;
|
|
217
|
+
perPath.set(entry.realPath, (perPath.get(entry.realPath) ?? 0) + line.length);
|
|
218
|
+
return line;
|
|
219
|
+
});
|
|
220
|
+
blocks.push(promises.join("\n"));
|
|
221
|
+
return { text: blocks.join("\n\n"), perPath };
|
|
222
|
+
}
|
|
223
|
+
async function openStoreQuietly() {
|
|
224
|
+
try {
|
|
225
|
+
const { openLocalStore } = await import("../local_store/database.js");
|
|
226
|
+
return { store: await openLocalStore(), error: null };
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
return { store: null, error: error instanceof Error ? error.message : String(error) };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
export async function runReadBulk(options, env = process.env) {
|
|
233
|
+
const startedAt = performance.now();
|
|
234
|
+
const sessionId = options.session !== undefined && options.session !== "" ? options.session : DIRECT_SESSION_ID;
|
|
235
|
+
const isDirect = sessionId === DIRECT_SESSION_ID;
|
|
236
|
+
const agentType = options.agentType !== undefined && options.agentType !== "" ? options.agentType : "main";
|
|
237
|
+
const cwd = process.cwd();
|
|
238
|
+
const requested = options.paths.map((given) => {
|
|
239
|
+
const resolved = path.resolve(cwd, given);
|
|
240
|
+
return { given, resolved, realPath: realPathOf(resolved) };
|
|
241
|
+
});
|
|
242
|
+
const opened = await openStoreQuietly();
|
|
243
|
+
const store = opened.store;
|
|
244
|
+
try {
|
|
245
|
+
const outcome = await reachDoor(options, requested, sessionId, isDirect, store, env);
|
|
246
|
+
const estimates = new Map(requested.map((entry) => [entry.realPath, estimatedTokensOf(entry.realPath)]));
|
|
247
|
+
let text;
|
|
248
|
+
let perPath;
|
|
249
|
+
let outlines = [];
|
|
250
|
+
if (outcome.result === "pass" && outcome.digest !== null) {
|
|
251
|
+
({ text, perPath } = renderPass(outcome.digest, options.question, requested, outcome.relativeOf));
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
const built = await buildOutlines(requested, estimates);
|
|
255
|
+
outlines = built.outlines.map(({ path: given, text: outlineText }) => ({ path: given, text: outlineText }));
|
|
256
|
+
({ text, perPath } = renderNonPass(outcome.result, outcome.reason ?? outcome.result, built.outlines, built.omitted, requested));
|
|
257
|
+
}
|
|
258
|
+
const answer = {
|
|
259
|
+
result: outcome.result,
|
|
260
|
+
sessionId,
|
|
261
|
+
digest: outcome.digest,
|
|
262
|
+
check: outcome.check,
|
|
263
|
+
outlines,
|
|
264
|
+
reason: outcome.result === "pass" ? null : (outcome.reason ?? outcome.result),
|
|
265
|
+
};
|
|
266
|
+
if (options.json === true)
|
|
267
|
+
printJsonSuccess("read bulk", answer);
|
|
268
|
+
else
|
|
269
|
+
console.log(text);
|
|
270
|
+
process.exitCode = EXIT_CODES[outcome.result];
|
|
271
|
+
const paths = requested.map((entry) => ({
|
|
272
|
+
realPath: entry.realPath,
|
|
273
|
+
estimatedTokens: estimates.get(entry.realPath) ?? 0,
|
|
274
|
+
returnedTokens: tokensOfLength(perPath.get(entry.realPath) ?? 0),
|
|
275
|
+
}));
|
|
276
|
+
await writeOutcome(store, opened.error, {
|
|
277
|
+
sessionId,
|
|
278
|
+
agentType,
|
|
279
|
+
result: outcome.result,
|
|
280
|
+
miss: outcome.miss,
|
|
281
|
+
latencyMs: Math.max(0, Math.round(performance.now() - startedAt)),
|
|
282
|
+
paths,
|
|
283
|
+
});
|
|
284
|
+
return answer;
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
store?.close();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async function reachDoor(options, requested, sessionId, isDirect, store, env) {
|
|
291
|
+
const noRelative = (entry) => entry.given;
|
|
292
|
+
if (!isDirect && store !== null) {
|
|
293
|
+
const { isLatched } = await import("../local_store/routing.js");
|
|
294
|
+
if (isLatched(store, sessionId, "read.bulk")) {
|
|
295
|
+
return {
|
|
296
|
+
result: "latched",
|
|
297
|
+
reason: "the local model missed twice in this session, so tier 1 is off for it; no digest was asked for",
|
|
298
|
+
digest: null,
|
|
299
|
+
check: null,
|
|
300
|
+
miss: null,
|
|
301
|
+
relativeOf: noRelative,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const record = await readServerRecord();
|
|
306
|
+
if (record === null || !isPidAlive(record.pid)) {
|
|
307
|
+
return { result: "unavailable", reason: DESKTOP_CLOSED, digest: null, check: null, miss: "failure", relativeOf: noRelative };
|
|
308
|
+
}
|
|
309
|
+
const projects = await probeProjects(record);
|
|
310
|
+
if (projects === null) {
|
|
311
|
+
return { result: "unavailable", reason: DESKTOP_SILENT, digest: null, check: null, miss: "failure", relativeOf: noRelative };
|
|
312
|
+
}
|
|
313
|
+
const picked = pickProject(projects, requested);
|
|
314
|
+
if ("unheldPath" in picked) {
|
|
315
|
+
return {
|
|
316
|
+
result: "unavailable",
|
|
317
|
+
reason: `no NuStack project holds ${picked.unheldPath} — add its folder in the desktop`,
|
|
318
|
+
digest: null,
|
|
319
|
+
check: null,
|
|
320
|
+
miss: null,
|
|
321
|
+
relativeOf: noRelative,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
const relativeOf = (entry) => path.relative(picked.projectRealPath, entry.realPath).split(path.sep).join("/");
|
|
325
|
+
const { budgetMs } = resolveReadBulkBudget(options.budgetMs, env);
|
|
326
|
+
const body = {
|
|
327
|
+
project: picked.project.path,
|
|
328
|
+
paths: requested.map(relativeOf),
|
|
329
|
+
question: options.question,
|
|
330
|
+
};
|
|
331
|
+
if (options.lines !== undefined)
|
|
332
|
+
body.lines = options.lines;
|
|
333
|
+
if (budgetMs !== null)
|
|
334
|
+
body.budgetMs = budgetMs;
|
|
335
|
+
const timeoutMs = (budgetMs ?? READ_BULK_BUDGET_BOUNDS_MS.max) + RELAY_TIMEOUT_MARGIN_MS;
|
|
336
|
+
return { ...(await askDoor(record.port, record.token, body, timeoutMs)), relativeOf };
|
|
337
|
+
}
|
|
338
|
+
async function writeOutcome(store, openError, outcome) {
|
|
339
|
+
if (store === null) {
|
|
340
|
+
process.stderr.write(`nustack read bulk: the local store did not open, so this call left no label row (${openError ?? "unknown"})\n`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
const [{ withTransaction }, { recordBulkReadOutcome }] = await Promise.all([
|
|
345
|
+
import("../local_store/database.js"),
|
|
346
|
+
import("../local_store/routing.js"),
|
|
347
|
+
]);
|
|
348
|
+
recordBulkReadOutcome(store, (fn) => withTransaction(store, () => fn()), outcome, () => `lr_${randomUUID()}`);
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
352
|
+
process.stderr.write(`nustack read bulk: the local store is busy, so this call left no label row (${message.replace(/\s+/g, " ").trim()})\n`);
|
|
353
|
+
}
|
|
354
|
+
}
|
package/dist/lib/gate/hook.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { performance } from "node:perf_hooks";
|
|
3
|
+
import { classifyBashRead } from "./bashRead.js";
|
|
2
4
|
import { GateInputError, gateBashCall, gateReadCall } from "./readGate.js";
|
|
3
5
|
const TTY_NOTICE = "nustack gate: hook entry point; reads Claude Code's PreToolUse JSON on stdin\n";
|
|
4
6
|
async function readStdin(stdin) {
|
|
@@ -45,15 +47,40 @@ function messageOf(error) {
|
|
|
45
47
|
const text = error instanceof Error ? error.message : String(error);
|
|
46
48
|
return text.replace(/\s+/g, " ").trim();
|
|
47
49
|
}
|
|
48
|
-
function denialOf(
|
|
50
|
+
function denialOf(reason) {
|
|
49
51
|
return `${JSON.stringify({
|
|
50
52
|
hookSpecificOutput: {
|
|
51
53
|
hookEventName: "PreToolUse",
|
|
52
54
|
permissionDecision: "deny",
|
|
53
|
-
permissionDecisionReason:
|
|
55
|
+
permissionDecisionReason: reason,
|
|
54
56
|
},
|
|
55
57
|
})}\n`;
|
|
56
58
|
}
|
|
59
|
+
function deniedPathOf(subcommand, call) {
|
|
60
|
+
const written = subcommand === "read"
|
|
61
|
+
? call.toolInput.file_path
|
|
62
|
+
: typeof call.toolInput.command === "string"
|
|
63
|
+
? classifyBashRead(call.toolInput.command)?.path
|
|
64
|
+
: undefined;
|
|
65
|
+
if (typeof written !== "string" || written === "")
|
|
66
|
+
return null;
|
|
67
|
+
return path.isAbsolute(written) ? written : path.resolve(call.cwd, written);
|
|
68
|
+
}
|
|
69
|
+
async function reasonWithTierOne(subcommand, call, reason, io) {
|
|
70
|
+
const deniedPath = deniedPathOf(subcommand, call);
|
|
71
|
+
if (deniedPath === null)
|
|
72
|
+
return reason;
|
|
73
|
+
try {
|
|
74
|
+
const [{ isTierOneOffered, tierOneWayForward }, { withWayForward }] = await Promise.all([import("./tierOne.js"), import("./outline.js")]);
|
|
75
|
+
if (!(await isTierOneOffered(call.sessionId)))
|
|
76
|
+
return reason;
|
|
77
|
+
return withWayForward(reason, tierOneWayForward(call.sessionId, call.agentType, deniedPath));
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
io.stderr.write(`nustack gate: ${codeOf(error)}: ${messageOf(error)}\n`);
|
|
81
|
+
return reason;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
57
84
|
export async function runGateHook(subcommand, io = { stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, env: process.env }) {
|
|
58
85
|
const startedAt = performance.now();
|
|
59
86
|
process.exitCode = 0;
|
|
@@ -71,7 +98,7 @@ export async function runGateHook(subcommand, io = { stdin: process.stdin, stdou
|
|
|
71
98
|
else
|
|
72
99
|
throw new GateInputError(`unknown hook "${subcommand}"`);
|
|
73
100
|
if (answer.decision === "deny")
|
|
74
|
-
io.stdout.write(denialOf(answer));
|
|
101
|
+
io.stdout.write(denialOf(await reasonWithTierOne(subcommand, call, answer.reason, io)));
|
|
75
102
|
}
|
|
76
103
|
catch (error) {
|
|
77
104
|
io.stderr.write(`nustack gate: ${codeOf(error)}: ${messageOf(error)}\n`);
|
package/dist/lib/gate/outline.js
CHANGED
|
@@ -107,6 +107,18 @@ export function renderOutline(input, outline) {
|
|
|
107
107
|
}
|
|
108
108
|
return clip(text);
|
|
109
109
|
}
|
|
110
|
+
export function withWayForward(denial, line) {
|
|
111
|
+
const marker = "\n\nWays forward:";
|
|
112
|
+
const at = denial.lastIndexOf(marker);
|
|
113
|
+
if (at === -1)
|
|
114
|
+
return clip(`${denial}\n${line}`);
|
|
115
|
+
const body = denial.slice(0, at);
|
|
116
|
+
const tail = `${denial.slice(at)}\n${line}`;
|
|
117
|
+
const room = OUTLINE_MAX_CHARS - tail.length;
|
|
118
|
+
if (room <= 0)
|
|
119
|
+
return clip(tail.slice(2));
|
|
120
|
+
return `${body.length <= room ? body : `${body.slice(0, room - 1)}…`}${tail}`;
|
|
121
|
+
}
|
|
110
122
|
function clip(text) {
|
|
111
123
|
return text.length <= OUTLINE_MAX_CHARS ? text : `${text.slice(0, OUTLINE_MAX_CHARS - 1)}…`;
|
|
112
124
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GATE_THRESHOLD_TOKENS_MAX, GATE_THRESHOLD_TOKENS_MIN, readDesktopSettings } from "../desktop_settings.js";
|
|
1
|
+
import { DIGEST_BUDGET_MS_MAX, DIGEST_BUDGET_MS_MIN, GATE_THRESHOLD_TOKENS_MAX, GATE_THRESHOLD_TOKENS_MIN, readDesktopSettings, } from "../desktop_settings.js";
|
|
2
2
|
export const NUSTACK_GATE_ENV = "NUSTACK_GATE";
|
|
3
3
|
export const NUSTACK_GATE_THRESHOLD_ENV = "NUSTACK_GATE_THRESHOLD_TOKENS";
|
|
4
4
|
export const GATE_DEFAULT_THRESHOLD_TOKENS = 4_000;
|
|
@@ -22,3 +22,26 @@ export function resolveThreshold(env = process.env, readSettings = readDesktopSe
|
|
|
22
22
|
return { thresholdTokens: fromSettings, thresholdSource: "settings" };
|
|
23
23
|
return { thresholdTokens: GATE_DEFAULT_THRESHOLD_TOKENS, thresholdSource: "default" };
|
|
24
24
|
}
|
|
25
|
+
export const READ_BULK_BUDGET_ENV = "NUSTACK_READ_BULK_BUDGET_MS";
|
|
26
|
+
export const READ_BULK_BUDGET_BOUNDS_MS = { min: DIGEST_BUDGET_MS_MIN, max: DIGEST_BUDGET_MS_MAX };
|
|
27
|
+
export function isReadBulkBudgetInRange(value) {
|
|
28
|
+
return Number.isInteger(value) && value >= READ_BULK_BUDGET_BOUNDS_MS.min && value <= READ_BULK_BUDGET_BOUNDS_MS.max;
|
|
29
|
+
}
|
|
30
|
+
function envBudget(env) {
|
|
31
|
+
const text = env[READ_BULK_BUDGET_ENV]?.trim();
|
|
32
|
+
if (text === undefined || !/^\d+$/.test(text))
|
|
33
|
+
return null;
|
|
34
|
+
const value = Number.parseInt(text, 10);
|
|
35
|
+
return isReadBulkBudgetInRange(value) ? value : null;
|
|
36
|
+
}
|
|
37
|
+
export function resolveReadBulkBudget(flagBudgetMs, env = process.env, readSettings = readDesktopSettings) {
|
|
38
|
+
if (flagBudgetMs !== undefined && isReadBulkBudgetInRange(flagBudgetMs))
|
|
39
|
+
return { budgetMs: flagBudgetMs, budgetSource: "flag" };
|
|
40
|
+
const fromEnv = envBudget(env);
|
|
41
|
+
if (fromEnv !== null)
|
|
42
|
+
return { budgetMs: fromEnv, budgetSource: "env" };
|
|
43
|
+
const fromSettings = readSettings().routing.digestBudgetMs;
|
|
44
|
+
if (fromSettings !== null && isReadBulkBudgetInRange(fromSettings))
|
|
45
|
+
return { budgetMs: fromSettings, budgetSource: "settings" };
|
|
46
|
+
return { budgetMs: null, budgetSource: "none" };
|
|
47
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { isPidAlive, readServerRecord } from "../agent/desktopDoor.js";
|
|
2
|
+
export async function isTierOneOffered(sessionId) {
|
|
3
|
+
const record = await readServerRecord();
|
|
4
|
+
if (record === null || !isPidAlive(record.pid))
|
|
5
|
+
return false;
|
|
6
|
+
const [{ openLocalStore }, { isLatched }] = await Promise.all([import("../local_store/database.js"), import("../local_store/routing.js")]);
|
|
7
|
+
const store = await openLocalStore();
|
|
8
|
+
try {
|
|
9
|
+
return !isLatched(store, sessionId, "read.bulk");
|
|
10
|
+
}
|
|
11
|
+
finally {
|
|
12
|
+
store.close();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function shellWord(word) {
|
|
16
|
+
return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
|
|
17
|
+
}
|
|
18
|
+
export function tierOneWayForward(sessionId, agentType, filePath) {
|
|
19
|
+
const agent = agentType !== "" && agentType !== "main" ? ` --agent-type ${shellWord(agentType)}` : "";
|
|
20
|
+
const command = `nustack read bulk --session ${shellWord(sessionId)}${agent} --paths ${shellWord(filePath)} --question "<your question>"`;
|
|
21
|
+
return `3. Ask the local model: \`${command}\` — a digest whose every range is the engine's, checked before you see it.`;
|
|
22
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const HARNESS_CODES = {
|
|
2
|
+
HARNESS_UNAVAILABLE: "HARNESS_UNAVAILABLE",
|
|
3
|
+
HARNESS_MOUNT_FAILED: "HARNESS_MOUNT_FAILED",
|
|
4
|
+
HARNESS_DIGEST_MISMATCH: "HARNESS_DIGEST_MISMATCH",
|
|
5
|
+
HARNESS_OFFLINE_NO_CACHE: "HARNESS_OFFLINE_NO_CACHE",
|
|
6
|
+
ORIGIN_MISMATCH: "ORIGIN_MISMATCH",
|
|
7
|
+
ETNA_MOUNT_PRESENT: "ETNA_MOUNT_PRESENT",
|
|
8
|
+
INIT_NEEDS_PROJECT: "INIT_NEEDS_PROJECT",
|
|
9
|
+
INIT_NEEDS_DEVICE: "INIT_NEEDS_DEVICE",
|
|
10
|
+
};
|
|
11
|
+
export const TRAILER_HOOK_VALUES = ["installed", "skipped_hooks_path", "skipped_foreign_hook", "skipped_not_git"];
|
|
12
|
+
export const SESSION_HOOK_VALUES = ["installed", "present", "skipped_unparsable", "skipped_etna_mount"];
|
|
13
|
+
export const MOUNT_STATUS_VALUES = ["mounted", "current", "failed", "skipped_etna_mount"];
|
|
14
|
+
export const MOUNT_SOURCE_VALUES = ["network", "cache", "legacy"];
|
|
15
|
+
export const STATUS_SOURCE_VALUES = ["version", "legacy"];
|
|
16
|
+
export const STATUS_EXIT = { IN_STEP: 0, DIFFERING: 1, UNREACHABLE: 2 };
|
|
17
|
+
export class HarnessError extends Error {
|
|
18
|
+
code;
|
|
19
|
+
constructor(code, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "HarnessError";
|
|
22
|
+
this.code = code;
|
|
23
|
+
}
|
|
24
|
+
}
|