@nurix/nustack 0.23.21 → 0.23.23

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.
@@ -0,0 +1,104 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ export const CONTEXT_MAX_OBJECTS = 3;
4
+ const SOURCE_EXTENSION = /\.(ts|tsx|js|jsx|mjs|cjs|json|md|mdx|sql|py|rs|go|yaml|yml|toml|css|html|sh)$/i;
5
+ export function isIdentifierShaped(token) {
6
+ if (!/^[A-Za-z_][A-Za-z0-9_-]*[A-Za-z0-9]$/.test(token))
7
+ return false;
8
+ if (/[a-z][A-Z]/.test(token))
9
+ return true;
10
+ if (/[A-Za-z0-9]_[A-Za-z0-9]/.test(token))
11
+ return true;
12
+ if (/[a-z0-9]-[a-z0-9]/i.test(token))
13
+ return true;
14
+ return /^[A-Z][A-Z0-9_]+$/.test(token) && token.length >= 2;
15
+ }
16
+ function isPathShaped(token) {
17
+ return (token.includes("/") && /[A-Za-z]/.test(token)) || SOURCE_EXTENSION.test(token);
18
+ }
19
+ export function extractTerms(text) {
20
+ const paths = [];
21
+ const identifiers = [];
22
+ const add = (list, term) => {
23
+ if (term !== "" && !list.includes(term))
24
+ list.push(term);
25
+ };
26
+ const classify = (raw, isBackticked) => {
27
+ const token = raw.replace(/^[("'[{<]+|[)"'\]}>,;:!?.]+$/g, "").replace(/'s$/, "");
28
+ if (token === "")
29
+ return;
30
+ if (isPathShaped(token))
31
+ add(paths, token.replace(/^\.\//, ""));
32
+ else if (isIdentifierShaped(token))
33
+ add(identifiers, token);
34
+ else if (isBackticked && /^[A-Za-z_][A-Za-z0-9_]*$/.test(token))
35
+ add(identifiers, token);
36
+ };
37
+ for (const match of text.matchAll(/`([^`]+)`/g)) {
38
+ const span = (match[1] ?? "").trim();
39
+ if (/\s/.test(span))
40
+ span.split(/\s+/).forEach((part) => classify(part, true));
41
+ else
42
+ classify(span, true);
43
+ }
44
+ for (const token of text.replace(/`[^`]*`/g, " ").split(/\s+/))
45
+ classify(token, false);
46
+ return { paths, identifiers };
47
+ }
48
+ function escapeRegExp(text) {
49
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
50
+ }
51
+ export function mentions(text, phrase) {
52
+ const body = escapeRegExp(phrase.trim()).replace(/\s+/g, "\\s+");
53
+ if (body === "")
54
+ return false;
55
+ return new RegExp(`(?:^|[^A-Za-z0-9_-])${body}(?:e?s)?(?=$|[^A-Za-z0-9_-])`, "i").test(text);
56
+ }
57
+ export function resolvePaths(root, cwd, candidates) {
58
+ const resolved = [];
59
+ const missing = [];
60
+ for (const candidate of candidates) {
61
+ const fromRoot = path.resolve(root, candidate);
62
+ const absolute = existsSync(fromRoot) ? fromRoot : path.resolve(cwd, candidate);
63
+ const relative = path.relative(root, absolute).split(path.sep).join("/");
64
+ if (!existsSync(absolute) || relative.startsWith("..") || path.isAbsolute(relative)) {
65
+ missing.push(candidate);
66
+ continue;
67
+ }
68
+ if (relative === "" || resolved.some((entry) => entry.path === relative))
69
+ continue;
70
+ resolved.push({ path: relative, isFile: statSync(absolute).isFile() });
71
+ }
72
+ return { resolved, missing };
73
+ }
74
+ export function resolveObjects(objects, text, identifiers, pathSites) {
75
+ const lowered = new Set(identifiers.map((term) => term.toLowerCase()));
76
+ const ranked = [];
77
+ objects.forEach((object, order) => {
78
+ const via = [];
79
+ for (const site of pathSites) {
80
+ if (object.tables.includes(site.table)) {
81
+ const entry = `path ${site.path} (writes ${site.table} at line ${site.line})`;
82
+ if (!via.some((existing) => existing.startsWith(`path ${site.path} `)))
83
+ via.push(entry);
84
+ }
85
+ }
86
+ for (const table of object.tables) {
87
+ if (lowered.has(table.toLowerCase()) || mentions(text, table))
88
+ via.push(`table ${table}`);
89
+ }
90
+ if (mentions(text, object.name))
91
+ via.push(`name "${object.name}"`);
92
+ for (const alias of object.aliases) {
93
+ if (alias.toLowerCase() !== object.name.toLowerCase() && mentions(text, alias))
94
+ via.push(`alias "${alias}"`);
95
+ }
96
+ if (via.length > 0)
97
+ ranked.push({ object, via, order });
98
+ });
99
+ ranked.sort((left, right) => right.via.length - left.via.length || left.order - right.order);
100
+ return ranked.slice(0, CONTEXT_MAX_OBJECTS).map(({ object, via }) => ({ object, via }));
101
+ }
102
+ export function isRegisterTerm(objects, term) {
103
+ return objects.some((object) => [object.name, ...object.aliases, ...object.tables].some((name) => mentions(term, name) && mentions(name, term)));
104
+ }
@@ -0,0 +1,58 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ export const WRITE_SITE_CANDIDATE_FILES = 20;
4
+ export function camelCaseOf(table) {
5
+ return table.replace(/[_-]+([a-z0-9])/g, (_match, letter) => letter.toUpperCase());
6
+ }
7
+ function escapeRegExp(text) {
8
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9
+ }
10
+ export function tableSymbols(table, graph) {
11
+ const camel = camelCaseOf(table);
12
+ const candidates = camel === table ? [table] : [camel, table];
13
+ if (graph === null)
14
+ return [camel];
15
+ const confirmed = candidates.find((symbol) => graph.isDefined(symbol) || graph.importers(symbol, 1).length > 0);
16
+ return confirmed === undefined ? [] : [confirmed];
17
+ }
18
+ export function writePatterns(table, symbols) {
19
+ const patterns = symbols.map((symbol) => new RegExp(`\\.(insert|update|delete)\\(\\s*(?:\\w+\\.)?${escapeRegExp(symbol)}\\b`));
20
+ patterns.push(new RegExp(`(INSERT\\s+INTO|UPDATE|DELETE\\s+FROM)\\s+["'\`]?${escapeRegExp(table)}\\b`));
21
+ return patterns;
22
+ }
23
+ function verbOf(match) {
24
+ const lowered = match.toLowerCase();
25
+ if (lowered.includes("insert"))
26
+ return "insert";
27
+ if (lowered.includes("delete"))
28
+ return "delete";
29
+ return "update";
30
+ }
31
+ export function scanWriteSites(root, files, table, symbols) {
32
+ const patterns = writePatterns(table, symbols);
33
+ const sites = [];
34
+ for (const file of [...new Set(files)]) {
35
+ let text;
36
+ try {
37
+ text = readFileSync(path.join(root, file), "utf8");
38
+ }
39
+ catch {
40
+ continue;
41
+ }
42
+ text.split("\n").forEach((line, index) => {
43
+ for (const pattern of patterns) {
44
+ const match = pattern.exec(line);
45
+ if (match !== null) {
46
+ sites.push({ path: file, line: index + 1, verb: verbOf(match[1] ?? ""), table });
47
+ break;
48
+ }
49
+ }
50
+ });
51
+ }
52
+ return sites;
53
+ }
54
+ export function findWriteSites(root, table, graph, scrutinised = []) {
55
+ const symbols = tableSymbols(table, graph);
56
+ const candidates = graph === null || symbols.length === 0 ? [] : graph.importers(symbols[0] ?? table, WRITE_SITE_CANDIDATE_FILES);
57
+ return scanWriteSites(root, [...candidates, ...scrutinised], table, graph === null ? [camelCaseOf(table)] : symbols);
58
+ }
@@ -199,3 +199,16 @@ export function assessBashRead(file, range, thresholdTokens, minimumThresholdTok
199
199
  return { decision: "pass", reason: "under-threshold", source: "read", estimatedTokens };
200
200
  return { decision: "gate", source: "read", estimatedTokens, thresholdTokens: threshold };
201
201
  }
202
+ export function assessBashReadByCoin(file, range, isSelected, env = process.env) {
203
+ const size = regularFileSize(file);
204
+ if (size === null)
205
+ return { decision: "pass", reason: "not-a-file", source: "stat", estimatedTokens: null };
206
+ if (isSkippedExtension(file))
207
+ return { decision: "pass", reason: "skipped-extension", source: "stat", estimatedTokens: null };
208
+ if (hasLeadingNul(file))
209
+ return { decision: "pass", reason: "binary", source: "read", estimatedTokens: null };
210
+ const estimatedTokens = Math.min(tokensOfLength(printedBytes(file, size, range)), tokensOfLength(bashMaxOutputLength(env)));
211
+ if (!isSelected())
212
+ return { decision: "pass", reason: "coin-not-selected", source: "read", estimatedTokens };
213
+ return { decision: "gate", source: "read", estimatedTokens, thresholdTokens: null };
214
+ }
@@ -88,3 +88,17 @@ export function assessFullRead(file, thresholdTokens, minimumThresholdTokens) {
88
88
  return { decision: "pass", reason: "under-threshold", source: "read", estimatedTokens };
89
89
  return { decision: "gate", source: "read", estimatedTokens, thresholdTokens: threshold };
90
90
  }
91
+ export function assessFullReadByCoin(file, isSelected) {
92
+ const size = regularFileSize(file);
93
+ if (size === null)
94
+ return { decision: "pass", reason: "not-a-file", source: "stat", estimatedTokens: null };
95
+ if (isSkippedExtension(file))
96
+ return { decision: "pass", reason: "skipped-extension", source: "stat", estimatedTokens: null };
97
+ const scan = readLeadingLines(file, READ_DEFAULT_LINE_LIMIT);
98
+ if (scan.isBinary)
99
+ return { decision: "pass", reason: "binary", source: "read", estimatedTokens: null };
100
+ const estimatedTokens = estimateReadTokens(scan);
101
+ if (!isSelected())
102
+ return { decision: "pass", reason: "coin-not-selected", source: "read", estimatedTokens };
103
+ return { decision: "gate", source: "read", estimatedTokens, thresholdTokens: null };
104
+ }
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import { performance } from "node:perf_hooks";
3
3
  import { classifyBashRead } from "./bashRead.js";
4
4
  import { GateInputError, gateBashCall, gateReadCall } from "./readGate.js";
5
+ import { GATE_COIN_RATE_ENV, resolveCoin } from "./settings.js";
5
6
  const TTY_NOTICE = "nustack gate: hook entry point; reads Claude Code's PreToolUse JSON on stdin\n";
6
7
  async function readStdin(stdin) {
7
8
  const chunks = [];
@@ -113,11 +114,14 @@ export async function runGateHook(subcommand, io = { stdin: process.stdin, stdou
113
114
  return;
114
115
  }
115
116
  const call = parseCall(await readStdin(io.stdin), startedAt);
117
+ const { coin, rejected } = resolveCoin(io.env);
118
+ if (rejected !== null)
119
+ io.stderr.write(`nustack gate: GATE_COIN_RATE_INVALID: ${GATE_COIN_RATE_ENV}=${JSON.stringify(rejected)} is not a number in [0, 1]; the size test applies\n`);
116
120
  let answer;
117
121
  if (subcommand === "read")
118
- answer = await gateReadCall(call, io.env);
122
+ answer = await gateReadCall(call, io.env, {}, coin);
119
123
  else if (subcommand === "bash")
120
- answer = await gateBashCall(call, io.env);
124
+ answer = await gateBashCall(call, io.env, coin);
121
125
  else
122
126
  throw new GateInputError(`unknown hook "${subcommand}"`);
123
127
  if (answer.decision === "deny")
@@ -2,9 +2,10 @@ import { randomUUID } from "node:crypto";
2
2
  import { closeSync, openSync, readFileSync, readSync, realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { performance } from "node:perf_hooks";
5
- import { assessBashRead, classifyBashRead } from "./bashRead.js";
6
- import { assessFullRead, readLeadingLines, regularFileSize, tokensOfLength } from "./estimate.js";
7
- import { GATE_MIN_THRESHOLD_TOKENS, isGateOff, resolveThreshold } from "./settings.js";
5
+ import { assessBashRead, assessBashReadByCoin, classifyBashRead } from "./bashRead.js";
6
+ import { assessFullRead, assessFullReadByCoin, readLeadingLines, regularFileSize, tokensOfLength } from "./estimate.js";
7
+ import { assessRedirect, redirectHandler } from "./redirect.js";
8
+ import { GATE_MIN_THRESHOLD_TOKENS, isCoinSelected, isGateOff, resolveCoin, resolveThreshold } from "./settings.js";
8
9
  export class GateInputError extends Error {
9
10
  code = "HOOK_INPUT_INVALID";
10
11
  constructor(message) {
@@ -26,7 +27,10 @@ function realPathOf(file) {
26
27
  return file;
27
28
  }
28
29
  }
29
- export async function gateReadCall(call, env = process.env) {
30
+ function coinSelects(coin, call, resolved) {
31
+ return () => isCoinSelected(coin, call.sessionId, realPathOf(resolved));
32
+ }
33
+ export async function gateReadCall(call, env = process.env, deps = {}, coin = resolveCoin(env).coin) {
30
34
  if (isGateOff(env))
31
35
  return PASS;
32
36
  const filePath = call.toolInput.file_path;
@@ -35,10 +39,16 @@ export async function gateReadCall(call, env = process.env) {
35
39
  if (call.toolInput.offset != null || call.toolInput.limit != null)
36
40
  return PASS;
37
41
  const resolved = resolvePath(call.cwd, filePath);
38
- const assessment = assessFullRead(resolved, () => resolveThreshold(env).thresholdTokens, GATE_MIN_THRESHOLD_TOKENS);
39
- return answerAssessment(call, resolved, assessment, "read");
42
+ const redirect = await assessRedirect(resolved, call.cwd, env, deps);
43
+ const assessment = coin === null
44
+ ? assessFullRead(resolved, () => resolveThreshold(env).thresholdTokens, GATE_MIN_THRESHOLD_TOKENS)
45
+ : assessFullReadByCoin(resolved, coinSelects(coin, call, resolved));
46
+ if (assessment.decision === "gate" || !redirect.isRedirect)
47
+ return answerAssessment(call, resolved, assessment, "read");
48
+ const { LABEL_ROW_HANDLERS } = await import("../local_store/routing.js");
49
+ return answerLargeRead(call, resolved, redirect.estimatedTokens, "read", redirectHandler(LABEL_ROW_HANDLERS));
40
50
  }
41
- export async function gateBashCall(call, env = process.env) {
51
+ export async function gateBashCall(call, env = process.env, coin = resolveCoin(env).coin) {
42
52
  if (isGateOff(env))
43
53
  return PASS;
44
54
  const command = call.toolInput.command;
@@ -48,7 +58,9 @@ export async function gateBashCall(call, env = process.env) {
48
58
  if (read === null)
49
59
  return PASS;
50
60
  const resolved = resolvePath(call.cwd, read.path);
51
- const assessment = assessBashRead(resolved, read.range, () => resolveThreshold(env).thresholdTokens, GATE_MIN_THRESHOLD_TOKENS, env);
61
+ const assessment = coin === null
62
+ ? assessBashRead(resolved, read.range, () => resolveThreshold(env).thresholdTokens, GATE_MIN_THRESHOLD_TOKENS, env)
63
+ : assessBashReadByCoin(resolved, read.range, coinSelects(coin, call, resolved), env);
52
64
  return answerAssessment(call, resolved, assessment, "bash");
53
65
  }
54
66
  async function answerAssessment(call, resolved, assessment, tool) {
@@ -56,7 +68,7 @@ async function answerAssessment(call, resolved, assessment, tool) {
56
68
  return PASS;
57
69
  return answerLargeRead(call, resolved, assessment.estimatedTokens, tool);
58
70
  }
59
- async function answerLargeRead(call, resolved, estimatedTokens, tool) {
71
+ async function answerLargeRead(call, resolved, estimatedTokens, tool, deniedHandler = "outline") {
60
72
  const [{ openLocalStore, withTransaction }, routing, outline] = await Promise.all([
61
73
  import("../local_store/database.js"),
62
74
  import("../local_store/routing.js"),
@@ -84,7 +96,7 @@ async function answerLargeRead(call, resolved, estimatedTokens, tool) {
84
96
  path: realPath,
85
97
  estimated_tokens: estimatedTokens,
86
98
  returned_tokens: returnedTokens,
87
- handler: isInserted ? "outline" : "bypass",
99
+ handler: isInserted ? deniedHandler : "bypass",
88
100
  check_result: null,
89
101
  bypass_used: isInserted ? 0 : 1,
90
102
  latency_ms: Math.max(0, Math.round(performance.now() - call.startedAt)),
@@ -0,0 +1,61 @@
1
+ import { statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { relativeToRoot, resolveProjectRoot } from "../graph/projectRoot.js";
4
+ import { estimateReadTokens, isSkippedExtension, readLeadingLines, READ_DEFAULT_LINE_LIMIT, regularFileSize } from "./estimate.js";
5
+ export const REDIRECT_MIN_LINES = 350;
6
+ export const REDIRECT_RULE_PATH = path.join(".claude", "rules", "large-read-redirect.md");
7
+ export const NUSTACK_READ_REDIRECT_ENV = "NUSTACK_READ_REDIRECT";
8
+ export function isRedirectOn(root, env = process.env) {
9
+ const override = env[NUSTACK_READ_REDIRECT_ENV];
10
+ if (override === "on" || override === "off")
11
+ return { isOn: override === "on", source: "env" };
12
+ if (root === null)
13
+ return { isOn: false, source: "absent" };
14
+ try {
15
+ return statSync(path.join(root, REDIRECT_RULE_PATH)).isFile() ? { isOn: true, source: "rule" } : { isOn: false, source: "absent" };
16
+ }
17
+ catch {
18
+ return { isOn: false, source: "absent" };
19
+ }
20
+ }
21
+ export function redirectHandler(handlers) {
22
+ return handlers.includes("redirect") ? "redirect" : "outline";
23
+ }
24
+ const NO_REDIRECT = { isRedirect: false };
25
+ async function defaultLoader() {
26
+ const { loadCodegraph } = await import("../graph/library.js");
27
+ return loadCodegraph();
28
+ }
29
+ export async function assessRedirect(file, cwd, env = process.env, deps = {}) {
30
+ try {
31
+ const size = regularFileSize(file);
32
+ if (size === null || size < REDIRECT_MIN_LINES + 1 || isSkippedExtension(file))
33
+ return NO_REDIRECT;
34
+ const root = resolveProjectRoot(env.CLAUDE_PROJECT_DIR, cwd);
35
+ if (root === null || !isRedirectOn(root, env).isOn)
36
+ return NO_REDIRECT;
37
+ const head = readLeadingLines(file, REDIRECT_MIN_LINES + 1);
38
+ if (head.isBinary || head.lines <= REDIRECT_MIN_LINES)
39
+ return NO_REDIRECT;
40
+ const relPath = relativeToRoot(root, file, cwd);
41
+ if (relPath === null)
42
+ return NO_REDIRECT;
43
+ const library = await (deps.loadLibrary ?? defaultLoader)();
44
+ if (typeof library.openReadOnly !== "function" || typeof library.indexedFile !== "function")
45
+ return NO_REDIRECT;
46
+ const db = library.openReadOnly(root);
47
+ if (db === null)
48
+ return NO_REDIRECT;
49
+ try {
50
+ if (library.indexedFile(db, relPath) === null)
51
+ return NO_REDIRECT;
52
+ }
53
+ finally {
54
+ db.close();
55
+ }
56
+ return { isRedirect: true, estimatedTokens: estimateReadTokens(readLeadingLines(file, READ_DEFAULT_LINE_LIMIT)) };
57
+ }
58
+ catch {
59
+ return NO_REDIRECT;
60
+ }
61
+ }
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { DIGEST_BUDGET_MS_MAX, DIGEST_BUDGET_MS_MIN, GATE_THRESHOLD_TOKENS_MAX, GATE_THRESHOLD_TOKENS_MIN, readDesktopSettings, } from "../desktop_settings.js";
2
3
  export const NUSTACK_GATE_ENV = "NUSTACK_GATE";
3
4
  export const NUSTACK_GATE_THRESHOLD_ENV = "NUSTACK_GATE_THRESHOLD_TOKENS";
@@ -45,3 +46,24 @@ export function resolveReadBulkBudget(flagBudgetMs, env = process.env, readSetti
45
46
  return { budgetMs: fromSettings, budgetSource: "settings" };
46
47
  return { budgetMs: null, budgetSource: "none" };
47
48
  }
49
+ export const GATE_COIN_RATE_ENV = "NUSTACK_GATE_COIN_RATE";
50
+ export const GATE_COIN_SEED_ENV = "NUSTACK_GATE_COIN_SEED";
51
+ export const GATE_COIN_DEFAULT_SEED = "0";
52
+ const COIN_RATE_PATTERN = /^(?:\d+(?:\.\d*)?|\.\d+)$/;
53
+ export function resolveCoin(env = process.env) {
54
+ const text = env[GATE_COIN_RATE_ENV]?.trim();
55
+ if (text === undefined || text === "")
56
+ return { coin: null, rejected: null };
57
+ const rate = COIN_RATE_PATTERN.test(text) ? Number(text) : Number.NaN;
58
+ if (!(rate >= 0 && rate <= 1))
59
+ return { coin: null, rejected: text };
60
+ const seed = env[GATE_COIN_SEED_ENV];
61
+ return { coin: { rate, seed: seed === undefined || seed === "" ? GATE_COIN_DEFAULT_SEED : seed }, rejected: null };
62
+ }
63
+ export function coinDraw(seed, sessionId, realPath) {
64
+ const digest = createHash("sha256").update(`${seed}\0${sessionId}\0${realPath}`).digest("hex");
65
+ return Number.parseInt(digest.slice(0, 8), 16) / 2 ** 32;
66
+ }
67
+ export function isCoinSelected(coin, sessionId, realPath) {
68
+ return coinDraw(coin.seed, sessionId, realPath) < coin.rate;
69
+ }
@@ -0,0 +1,39 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import path from "node:path";
3
+ function startOf(projectDir, cwd) {
4
+ if (projectDir !== undefined && projectDir.trim() !== "")
5
+ return projectDir;
6
+ if (cwd !== undefined && cwd.trim() !== "")
7
+ return cwd;
8
+ return process.cwd();
9
+ }
10
+ export function resolveProjectRoot(projectDir, cwd) {
11
+ let current;
12
+ try {
13
+ current = realpathSync(startOf(projectDir, cwd));
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ for (;;) {
19
+ if (existsSync(path.join(current, ".git")))
20
+ return current;
21
+ const parent = path.dirname(current);
22
+ if (parent === current)
23
+ return null;
24
+ current = parent;
25
+ }
26
+ }
27
+ export function relativeToRoot(root, file, base = root) {
28
+ let real;
29
+ try {
30
+ real = realpathSync(path.isAbsolute(file) ? file : path.resolve(base, file));
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ const relative = path.relative(root, real);
36
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative))
37
+ return null;
38
+ return relative.split(path.sep).join("/");
39
+ }
@@ -19,6 +19,9 @@ export function desktopSettingsPath() {
19
19
  export function cacheDir() {
20
20
  return path.join(nustackHomeDir(), "cache");
21
21
  }
22
+ export function contextMarkersDir() {
23
+ return path.join(cacheDir(), "context");
24
+ }
22
25
  export function sessionEventsDir() {
23
26
  return path.join(nustackHomeDir(), "session-events");
24
27
  }
package/dist/program.js CHANGED
@@ -8,6 +8,7 @@ import { runSync } from "./commands/sync.js";
8
8
  import { runDescribe } from "./commands/describe.js";
9
9
  import { runValidate } from "./commands/validate.js";
10
10
  import { runGraph } from "./commands/graph.js";
11
+ import { runContext } from "./commands/context.js";
11
12
  import { runLogscope } from "./commands/logscope.js";
12
13
  import { runDoctor } from "./commands/doctor.js";
13
14
  import { runTokenCreate, runTokenList, runTokenRevoke } from "./commands/token.js";
@@ -116,6 +117,16 @@ program
116
117
  .allowUnknownOption()
117
118
  .allowExcessArguments()
118
119
  .action(async (_fn, _args, options) => runGraph(process.argv, options));
120
+ program
121
+ .command("context [text...]")
122
+ .description("Resolve task text, paths or a diff to register objects and print the context pack")
123
+ .option("--path <p>", "a path the task touches (repeatable)", (value, previous = []) => [...previous, value])
124
+ .option("--diff <fileOrDash>", "a unified diff whose files the task touches; - reads stdin")
125
+ .option("--json", "emit the versioned machine envelope")
126
+ .option("--no-refresh", "answer from the code-graph index as it stands, however old")
127
+ .option("--budget <tokens>", "the pack's token cap (default 2500)")
128
+ .option("--hook", "Claude Code hook entry for UserPromptSubmit, PostToolUse and SessionStart (reads stdin)")
129
+ .action(async (text, options) => runContext(text, options));
119
130
  program
120
131
  .command("logscope [fn] [args...]")
121
132
  .description("Log tracking: ask this machine's log corpus a question, or `index` / `corpora` / `allow` / `prune`")
@@ -253,11 +264,14 @@ registerRecordsCommands(program.command("records").description("Emit, send and l
253
264
  registerRecordAliases(program, () => program.opts());
254
265
  const LOCAL_ONLY_COMMANDS = ["census", "check"];
255
266
  const UNDRAINED_GROUPS = ["records", "gate", "session", "read", "lane", ...RECORD_ALIAS_GROUPS];
267
+ const HOOK_PATH_COMMANDS = ["context"];
256
268
  program.hook("preAction", async (_program, action) => {
257
269
  if (program.opts().nonInteractive)
258
270
  process.env[NON_INTERACTIVE_VARIABLE] = "1";
259
271
  if (action.parent === program && LOCAL_ONLY_COMMANDS.includes(action.name()))
260
272
  return;
273
+ if (action.parent === program && HOOK_PATH_COMMANDS.includes(action.name()))
274
+ return;
261
275
  const group = action.parent?.name() ?? "";
262
276
  if (!UNDRAINED_GROUPS.includes(group))
263
277
  await startDrainIfDue(program.opts());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/nustack",
3
- "version": "0.23.21",
3
+ "version": "0.23.23",
4
4
  "description": "The nustack CLI — bootstrap NuStack services into a repo via the NuStack discovery plane.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -34,10 +34,10 @@
34
34
  "@types/node": "^22.19.20",
35
35
  "typescript": "^5.7.2",
36
36
  "vitest": "^4.1.11",
37
+ "@nurix/contracts": "2.0.2",
37
38
  "@nurix/harness": "0.0.0",
38
39
  "@nurix/nustack.studio": "0.0.0",
39
- "@nurix/nustack.ui": "0.0.0",
40
- "@nurix/contracts": "2.0.2"
40
+ "@nurix/nustack.ui": "0.0.0"
41
41
  },
42
42
  "publishConfig": {
43
43
  "access": "public"