@davesheffer/hunch 1.39.2 → 1.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +154 -64
- package/dist/cli/serve.d.ts +14 -0
- package/dist/cli/serve.js +18 -4
- package/dist/constitution/g2BehaviorCandidates.js +6 -1
- package/dist/constitution/g2Candidates.js +11 -1
- package/dist/constitution/structural.js +10 -0
- package/dist/core/correction.js +12 -7
- package/dist/core/delivery.d.ts +34 -0
- package/dist/core/delivery.js +60 -1
- package/dist/core/docanchors.js +195 -11
- package/dist/core/format.js +6 -1
- package/dist/core/glob.d.ts +25 -0
- package/dist/core/glob.js +43 -0
- package/dist/core/hookcache.d.ts +9 -2
- package/dist/core/hookcache.js +10 -3
- package/dist/core/paths.d.ts +48 -5
- package/dist/core/paths.js +89 -6
- package/dist/core/procstart.d.ts +94 -0
- package/dist/core/procstart.js +244 -0
- package/dist/core/served.d.ts +6 -3
- package/dist/core/served.js +10 -6
- package/dist/core/taskDelivery.js +27 -1
- package/dist/core/taskReport.d.ts +2 -0
- package/dist/core/taskReport.js +46 -11
- package/dist/core/taskReportEvidence.js +17 -1
- package/dist/core/taskReportHook.d.ts +19 -0
- package/dist/core/taskReportHook.js +54 -6
- package/dist/core/verifyLauncher.d.ts +21 -0
- package/dist/core/verifyLauncher.js +37 -0
- package/dist/core/workspace.d.ts +9 -0
- package/dist/core/workspace.js +15 -0
- package/dist/extractors/git.d.ts +5 -0
- package/dist/extractors/git.js +87 -10
- package/dist/extractors/indexer.d.ts +10 -0
- package/dist/extractors/indexer.js +56 -10
- package/dist/extractors/k8sManifest.d.ts +13 -0
- package/dist/extractors/k8sManifest.js +207 -23
- package/dist/extractors/landscapeDiscovery.js +9 -1
- package/dist/extractors/nativeTreeSitter.d.ts +24 -0
- package/dist/extractors/nativeTreeSitter.js +54 -1
- package/dist/extractors/parse.js +6 -1
- package/dist/integrations/claudemd.js +3 -3
- package/dist/integrations/hookmatch.d.ts +25 -0
- package/dist/integrations/hookmatch.js +58 -0
- package/dist/integrations/hooks.d.ts +79 -9
- package/dist/integrations/hooks.js +559 -46
- package/dist/integrations/providers.js +2 -17
- package/dist/integrations/scaffold.d.ts +1 -1
- package/dist/integrations/scaffold.js +24 -21
- package/dist/integrations/workspaceLedger.d.ts +10 -0
- package/dist/integrations/workspaceLedger.js +9 -1
- package/dist/mcp/server.d.ts +10 -0
- package/dist/mcp/server.js +32 -16
- package/dist/mcp/taskReportTools.d.ts +5 -9
- package/dist/mcp/taskReportTools.js +9 -20
- package/dist/serve/writelock.d.ts +21 -1
- package/dist/serve/writelock.js +157 -16
- package/dist/store/changeLedger.d.ts +9 -3
- package/dist/store/changeLedger.js +36 -10
- package/dist/store/hunchStore.d.ts +42 -2
- package/dist/store/hunchStore.js +105 -17
- package/dist/store/jsonStore.d.ts +6 -4
- package/dist/store/jsonStore.js +225 -32
- package/dist/store/merge.d.ts +7 -4
- package/dist/store/merge.js +65 -5
- package/dist/store/privateMigrate.js +10 -0
- package/dist/wiki/adopt.js +13 -2
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/core/taskReport.js
CHANGED
|
@@ -99,7 +99,10 @@ const TaskSchema = z.object({
|
|
|
99
99
|
export const LessonReferenceSchema = z.object({
|
|
100
100
|
kind: safeText(64), record_id: safeText(512), content_hash: hashSchema.optional(),
|
|
101
101
|
}).strict();
|
|
102
|
-
|
|
102
|
+
/** Explicit report operations wait out a competing writer; hook/delivery paths keep
|
|
103
|
+
* the ledger's 100 ms default so a receipt never costs a delivery. */
|
|
104
|
+
const PATIENT_BUSY_MS = 5_000;
|
|
105
|
+
function taskDb(root, run, patient = false) {
|
|
103
106
|
return withServedDatabase(root, (db) => {
|
|
104
107
|
db.exec(`CREATE TABLE IF NOT EXISTS report_tasks (
|
|
105
108
|
task_id TEXT PRIMARY KEY, scope TEXT NOT NULL, body TEXT NOT NULL
|
|
@@ -115,10 +118,13 @@ function taskDb(root, run) {
|
|
|
115
118
|
);
|
|
116
119
|
CREATE INDEX IF NOT EXISTS report_record_lookup ON report_record_links(kind, record_id, content_hash);
|
|
117
120
|
CREATE TABLE IF NOT EXISTS report_history_progress (id INTEGER PRIMARY KEY CHECK(id = 1), through_rowid INTEGER NOT NULL);
|
|
118
|
-
INSERT OR IGNORE INTO report_history_progress VALUES (1, 0);
|
|
119
121
|
CREATE TABLE IF NOT EXISTS report_task_aliases (alias_id TEXT PRIMARY KEY, task_id TEXT NOT NULL);`);
|
|
122
|
+
// Seed only when the row is missing: an unconditional INSERT made every pure
|
|
123
|
+
// read take the writer lock. INSERT OR IGNORE still settles the race.
|
|
124
|
+
if (!db.prepare("SELECT 1 FROM report_history_progress WHERE id = 1").get())
|
|
125
|
+
db.exec("INSERT OR IGNORE INTO report_history_progress VALUES (1, 0)");
|
|
120
126
|
return run(db);
|
|
121
|
-
});
|
|
127
|
+
}, patient ? { busyTimeoutMs: PATIENT_BUSY_MS } : {});
|
|
122
128
|
}
|
|
123
129
|
/** A prompt identity that reports to another prompt's task: a host notification
|
|
124
130
|
* turn continues the session's latest task instead of opening a row. Explicit,
|
|
@@ -134,6 +140,11 @@ export function aliasReportTask(root, aliasId, taskId) {
|
|
|
134
140
|
export function resolveReportTask(root, id) {
|
|
135
141
|
return taskDb(root, db => db.prepare("SELECT task_id FROM report_task_aliases WHERE alias_id = ?").get(id)?.task_id ?? id);
|
|
136
142
|
}
|
|
143
|
+
/** Whether a task id names a real task of THIS repository/worktree. */
|
|
144
|
+
export function reportTaskExists(root, id) {
|
|
145
|
+
TaskIdSchema.parse(id);
|
|
146
|
+
return taskDb(root, db => !!db.prepare("SELECT 1 FROM report_tasks WHERE task_id = ? AND scope IN (?, ?, ?)").get(id, ...scopePair(root)));
|
|
147
|
+
}
|
|
137
148
|
function deliveryRecords(kind, body) {
|
|
138
149
|
if (kind === "save")
|
|
139
150
|
return [ReportSaveSchema.parse(body).record];
|
|
@@ -298,10 +309,12 @@ function newestSessionTask(db, root, sessionKey) {
|
|
|
298
309
|
export function latestSessionTask(root, sessionKey) {
|
|
299
310
|
return taskDb(root, db => newestSessionTask(db, root, sessionKey));
|
|
300
311
|
}
|
|
301
|
-
/** Check-starts still inside their own timeout plus the grace window
|
|
302
|
-
*
|
|
312
|
+
/** Check-starts still inside their own timeout plus the grace window whose own
|
|
313
|
+
* result has not arrived: while positive, a runner may still deliver a result.
|
|
314
|
+
* A result clears the start it names (its `check_id`) and nothing else — netting
|
|
315
|
+
* results against starts let a finished old check hide a running new one. */
|
|
303
316
|
function pendingChecks(db, taskId) {
|
|
304
|
-
const { pending } = db.prepare(`SELECT
|
|
317
|
+
const { pending } = db.prepare(`SELECT COUNT(*) AS pending FROM report_events s WHERE s.task_id = ? AND s.kind = 'check-start' AND (julianday('now') - julianday(s.at)) * 86400000 < COALESCE(json_extract(s.body, '$.timeout_ms'), ${MAX_PENDING_CHECK_MS}) + ${CHECK_RESULT_GRACE_MS} AND NOT EXISTS (SELECT 1 FROM report_events c WHERE c.task_id = s.task_id AND c.kind = 'check' AND json_extract(c.body, '$.check_id') = s.event_id)`).get(taskId);
|
|
305
318
|
return pending ?? 0;
|
|
306
319
|
}
|
|
307
320
|
/** Tasks of a session that an earlier prompt left open are over once the
|
|
@@ -353,6 +366,9 @@ function appendEvent(root, taskId, kind, body, eventId) {
|
|
|
353
366
|
if (!/^(hev|hocc)_[a-f0-9]{24}$/.test(id))
|
|
354
367
|
throw new Error("invalid report event identity");
|
|
355
368
|
const contentHash = reportHash(body);
|
|
369
|
+
// Explicit CLI/agent operations; passive observers (delivery, save, refusal,
|
|
370
|
+
// conformance) keep the default so an observation never costs a delivery.
|
|
371
|
+
const patient = kind === "check-start" || kind === "check" || kind === "claim";
|
|
356
372
|
return taskDb(root, db => transaction(db, () => {
|
|
357
373
|
const task = readTask(db, root, taskId);
|
|
358
374
|
const prior = db.prepare("SELECT task_id, kind, content_hash FROM report_events WHERE event_id = ?").get(id);
|
|
@@ -408,7 +424,7 @@ function appendEvent(root, taskId, kind, body, eventId) {
|
|
|
408
424
|
const seq = Number(inserted.lastInsertRowid);
|
|
409
425
|
db.prepare("UPDATE report_history_progress SET through_rowid = ? WHERE id = 1 AND through_rowid = ?").run(seq, seq - 1);
|
|
410
426
|
return id;
|
|
411
|
-
}));
|
|
427
|
+
}), patient);
|
|
412
428
|
}
|
|
413
429
|
/** The record revisions among `records` that this task has not received before.
|
|
414
430
|
* Powers the one-line "Hunch recalled …" indication on a lesson's FIRST delivery
|
|
@@ -472,12 +488,32 @@ export function recordReportConformance(root, taskId, conformance) {
|
|
|
472
488
|
throw new Error("rule evaluation does not name a record revision delivered in this task");
|
|
473
489
|
return appendEvent(root, taskId, "conformance", value);
|
|
474
490
|
}
|
|
491
|
+
/** SQLITE_BUSY (5) / SQLITE_LOCKED (6); node:sqlite carries the extended code. */
|
|
492
|
+
function isBusyError(error) {
|
|
493
|
+
const code = error.errcode;
|
|
494
|
+
if (typeof code === "number")
|
|
495
|
+
return (code & 0xff) === 5 || (code & 0xff) === 6;
|
|
496
|
+
return /database is locked/i.test(error?.message ?? "");
|
|
497
|
+
}
|
|
475
498
|
/** Only the local runner calls this. MCP never accepts a claimed successful check. */
|
|
476
499
|
export function recordReportCheck(root, taskId, check) {
|
|
477
500
|
const value = ReportCheckSchema.parse(check);
|
|
478
501
|
if (!value.check_id)
|
|
479
502
|
throw new Error("verification result requires a reserved check identity");
|
|
480
|
-
|
|
503
|
+
const id = `hev_${reportHash({ taskId, check: value.check_id }).slice(7, 31)}`;
|
|
504
|
+
// A result that took minutes to produce must not be lost to a busy ledger. The
|
|
505
|
+
// write is idempotent (deterministic id; appendEvent returns the existing id for
|
|
506
|
+
// identical content), so a retry is safe. Each attempt already waits out
|
|
507
|
+
// PATIENT_BUSY_MS in BEGIN IMMEDIATE; no sleep between them.
|
|
508
|
+
for (let attempt = 1;; attempt++) {
|
|
509
|
+
try {
|
|
510
|
+
return appendEvent(root, taskId, "check", value, id);
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
if (attempt >= 3 || !isBusyError(error))
|
|
514
|
+
throw error;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
481
517
|
}
|
|
482
518
|
/** A start without a result blocks completion only while the runner could still
|
|
483
519
|
* deliver one: its own timeout plus a minute of grace. After that the runner is
|
|
@@ -509,14 +545,13 @@ export function finishReportTask(root, taskId, state = "completed", options = {}
|
|
|
509
545
|
return task;
|
|
510
546
|
}
|
|
511
547
|
if (state === "completed") {
|
|
512
|
-
|
|
513
|
-
if ((pending ?? 0) > 0)
|
|
548
|
+
if (pendingChecks(db, taskId) > 0)
|
|
514
549
|
throw new Error("verification is still running or was interrupted; wait for its result or close the task as interrupted");
|
|
515
550
|
}
|
|
516
551
|
const finished = TaskSchema.parse({ ...task, state, finished_at: new Date().toISOString(), closed_by: by });
|
|
517
552
|
db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(finished), taskId);
|
|
518
553
|
return finished;
|
|
519
|
-
}));
|
|
554
|
+
}), by === "agent");
|
|
520
555
|
}
|
|
521
556
|
/** A report with no observation of any kind. Presentation surfaces may stay
|
|
522
557
|
* silent for it; the task row itself is retained so "never touched Hunch" is
|
|
@@ -197,6 +197,8 @@ export function runReportConformance(root, store, taskId) {
|
|
|
197
197
|
* not a verdict. */
|
|
198
198
|
export const DEFAULT_CHECK_TIMEOUT_MS = 15 * 60_000;
|
|
199
199
|
export const MAX_CHECK_TIMEOUT_MS = 6 * 60 * 60_000;
|
|
200
|
+
/** After the command itself exits, everything it wrote is already in the pipe; the window only lets those buffered chunks be read before settling. */
|
|
201
|
+
const EXIT_DRAIN_MS = 500;
|
|
200
202
|
/** A deliberately explicit command wrapper. The caller chooses the command;
|
|
201
203
|
* reports never execute commands automatically to validate submitted claims. */
|
|
202
204
|
export async function runReportCheck(root, taskId, command, label, timeoutMs = DEFAULT_CHECK_TIMEOUT_MS, options = {}) {
|
|
@@ -242,7 +244,7 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
|
|
|
242
244
|
}
|
|
243
245
|
const child = started;
|
|
244
246
|
let timedOut = false, cancelled = false, settled = false;
|
|
245
|
-
let cleanupTimer;
|
|
247
|
+
let cleanupTimer, drainTimer;
|
|
246
248
|
const settle = (code) => {
|
|
247
249
|
if (settled)
|
|
248
250
|
return;
|
|
@@ -251,6 +253,8 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
|
|
|
251
253
|
options.signal?.removeEventListener("abort", cancel);
|
|
252
254
|
if (cleanupTimer)
|
|
253
255
|
clearTimeout(cleanupTimer);
|
|
256
|
+
if (drainTimer)
|
|
257
|
+
clearTimeout(drainTimer);
|
|
254
258
|
child.stdout.destroy();
|
|
255
259
|
child.stderr.destroy();
|
|
256
260
|
resolveResult({ code, timedOut, cancelled, hash: reportHash({ stdout: stdout.digest("hex"), stderr: stderr.digest("hex") }) });
|
|
@@ -296,6 +300,18 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
|
|
|
296
300
|
}
|
|
297
301
|
settle(null);
|
|
298
302
|
});
|
|
303
|
+
// `close` waits for EVERY holder of the stdio pipes; a command that leaves a
|
|
304
|
+
// helper inheriting stdout (dev server, watcher) and exits 0 would otherwise
|
|
305
|
+
// burn the whole budget and be recorded as a timeout (#304). The command's own
|
|
306
|
+
// `exit` is the result; settle on it after a short drain of the buffered output.
|
|
307
|
+
child.once("exit", code => {
|
|
308
|
+
if (settled)
|
|
309
|
+
return;
|
|
310
|
+
// The command finished inside its budget; the drain window is not part of it.
|
|
311
|
+
if (code !== null && !timedOut)
|
|
312
|
+
clearTimeout(timer);
|
|
313
|
+
drainTimer = setTimeout(() => settle(code), EXIT_DRAIN_MS);
|
|
314
|
+
});
|
|
299
315
|
child.once("close", code => settle(code));
|
|
300
316
|
options.signal?.addEventListener("abort", cancel, { once: true });
|
|
301
317
|
if (options.signal?.aborted)
|
|
@@ -21,6 +21,25 @@ export declare function hookReportTaskId(root: string, provider: HookProvider, e
|
|
|
21
21
|
* No raw prompt, host session identifier, or transcript is retained; a repository
|
|
22
22
|
* that opts in (`taskTitles: "prompt"`) keeps only a bounded first-line title. */
|
|
23
23
|
export declare function startHookReport(root: string, provider: HookProvider, event: HunchHookInput): string | null;
|
|
24
|
+
/** The hook already opened the task, so the model needs no start call: the only
|
|
25
|
+
* thing start used to supply was verification_argv, and the launcher is printed
|
|
26
|
+
* inline here. Identical in substance for EVERY hook provider that reaches this
|
|
27
|
+
* function; the one variation is capability-driven, never host-named — where the
|
|
28
|
+
* host is not PROVEN to close the task it opened (HOST_CLOSES_TASK) nobody but the
|
|
29
|
+
* next prompt's settle would close it, so finish stays mandatory there. Elsewhere
|
|
30
|
+
* finish is CONDITIONAL: ~87 start/finish round trips a day mostly returned "No
|
|
31
|
+
* task-linked delivery observed", and the host's Stop hook closes the task and
|
|
32
|
+
* shows the evidence either way. FAILS OPEN (con_03a0b94b2e): if the launcher
|
|
33
|
+
* cannot be computed, fall back to asking for the start call — that path is then
|
|
34
|
+
* the only source of both the launcher and the finish instruction, so it carries
|
|
35
|
+
* its own finish sentence. */
|
|
36
|
+
export declare function taskInstruction(task: {
|
|
37
|
+
task_id: string;
|
|
38
|
+
title: string;
|
|
39
|
+
}, cwdLiteral: string, provider: HookProvider, launcher?: () => {
|
|
40
|
+
shell: string;
|
|
41
|
+
note?: string;
|
|
42
|
+
}): string;
|
|
24
43
|
/** A prompt the host generated to report a background command's completion,
|
|
25
44
|
* not something the user typed. */
|
|
26
45
|
export declare function isNotificationPrompt(prompt: string | undefined): boolean;
|
|
@@ -5,9 +5,10 @@ import { join } from "node:path";
|
|
|
5
5
|
import { findRoot } from "./paths.js";
|
|
6
6
|
import { canonicalReportRoot } from "./taskReportPaths.js";
|
|
7
7
|
import { isCredentialFreeText } from "./types.js";
|
|
8
|
-
import { aliasReportTask, continuationLinks, finishReportTask, isEmptyTaskReport, latestSessionTask, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, resolveReportTask, settleSessionTasks, startReportTask } from "./taskReport.js";
|
|
8
|
+
import { aliasReportTask, continuationLinks, finishReportTask, isEmptyTaskReport, latestSessionTask, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, reportTaskExists, resolveReportTask, settleSessionTasks, startReportTask } from "./taskReport.js";
|
|
9
9
|
import { reportSourceSnapshot } from "./taskReportEvidence.js";
|
|
10
10
|
import { renderTaskReport } from "./taskReportRender.js";
|
|
11
|
+
import { verificationLauncher } from "./verifyLauncher.js";
|
|
11
12
|
/** The exact task identity a native host prompt maps to. */
|
|
12
13
|
export function promptTaskId(root, sessionId, promptId, agentId = null, provider = "claude") {
|
|
13
14
|
return `htask_${reportHash([canonicalReportRoot(root), provider, sessionId, promptId, agentId]).slice(7, 31)}`;
|
|
@@ -15,6 +16,16 @@ export function promptTaskId(root, sessionId, promptId, agentId = null, provider
|
|
|
15
16
|
/** Hosts whose hooks deliver a native per-prompt identity (Claude Code's
|
|
16
17
|
* prompt_id, Codex's turn_id). Others get no task from a hook. */
|
|
17
18
|
const NATIVE_PROMPT_HOSTS = new Set(["claude", "codex"]);
|
|
19
|
+
/** Hosts PROVEN to close a task they opened, so its evidence is shown without
|
|
20
|
+
* the agent's cooperation and the finish call may be made conditional. A host
|
|
21
|
+
* belongs here only when BOTH hold: (1) `hunch init` wires its stop event, and
|
|
22
|
+
* (2) its stop payload carries the same native prompt identity the task was
|
|
23
|
+
* opened under, so `closeHookTask` actually resolves that task and closes it.
|
|
24
|
+
* (2) is what excludes a host with a stop hook but no native identity: outside
|
|
25
|
+
* NATIVE_PROMPT_HOSTS `nativeHookCwd` returns null on Stop, so nothing is
|
|
26
|
+
* closed and a skipped finish would leak an open task. Everywhere else finish
|
|
27
|
+
* stays mandatory. Adding a host requires proving both, never its name. */
|
|
28
|
+
const HOST_CLOSES_TASK = new Set(["claude", "codex"]);
|
|
18
29
|
const NATIVE_TASK_TITLE = "Assistant task";
|
|
19
30
|
const GENERIC_TASK_TITLES = new Set(["Assistant task", "Claude task"]);
|
|
20
31
|
const TASK_TITLE_MAX = 72;
|
|
@@ -85,7 +96,19 @@ function identity(root, provider, event) {
|
|
|
85
96
|
export function hookReportTaskId(root, provider, event) {
|
|
86
97
|
try {
|
|
87
98
|
const id = identity(root, provider, event);
|
|
88
|
-
|
|
99
|
+
if (!id || id === "legacy")
|
|
100
|
+
return null;
|
|
101
|
+
// A subagent's tool call carries the prompt's session and prompt identity
|
|
102
|
+
// plus its own agent_id, and no prompt ever opens a task under that
|
|
103
|
+
// identity (subagents never fire UserPromptSubmit): its deliveries and
|
|
104
|
+
// denials belong to the prompt's task. An agent-scoped task that DOES
|
|
105
|
+
// exist keeps its own evidence, and without a prompt task nothing is guessed.
|
|
106
|
+
if (event.agent_id && event.session_id && event.prompt_id && !reportTaskExists(root, id)) {
|
|
107
|
+
const parent = resolveReportTask(root, promptTaskId(root, event.session_id, event.prompt_id, null, provider));
|
|
108
|
+
if (reportTaskExists(root, parent))
|
|
109
|
+
return parent;
|
|
110
|
+
}
|
|
111
|
+
return id;
|
|
89
112
|
}
|
|
90
113
|
catch {
|
|
91
114
|
return null;
|
|
@@ -121,7 +144,7 @@ export function startHookReport(root, provider, event) {
|
|
|
121
144
|
// this prompt's Stop and hook observations report to that task.
|
|
122
145
|
if (previous && previous.task_id !== id && isNotificationPrompt(event.prompt) && previous.closed_by !== "agent") {
|
|
123
146
|
aliasReportTask(root, id, previous.task_id);
|
|
124
|
-
return taskInstruction(previous, cwdLiteral);
|
|
147
|
+
return taskInstruction(previous, cwdLiteral, provider);
|
|
125
148
|
}
|
|
126
149
|
const continued = previous && previous.task_id !== id ? continuationLinks(previous) : null;
|
|
127
150
|
if (continued)
|
|
@@ -142,10 +165,35 @@ export function startHookReport(root, provider, event) {
|
|
|
142
165
|
throw error;
|
|
143
166
|
task = existing;
|
|
144
167
|
}
|
|
145
|
-
return taskInstruction(task, cwdLiteral);
|
|
168
|
+
return taskInstruction(task, cwdLiteral, provider);
|
|
146
169
|
}
|
|
147
|
-
|
|
148
|
-
|
|
170
|
+
/** The hook already opened the task, so the model needs no start call: the only
|
|
171
|
+
* thing start used to supply was verification_argv, and the launcher is printed
|
|
172
|
+
* inline here. Identical in substance for EVERY hook provider that reaches this
|
|
173
|
+
* function; the one variation is capability-driven, never host-named — where the
|
|
174
|
+
* host is not PROVEN to close the task it opened (HOST_CLOSES_TASK) nobody but the
|
|
175
|
+
* next prompt's settle would close it, so finish stays mandatory there. Elsewhere
|
|
176
|
+
* finish is CONDITIONAL: ~87 start/finish round trips a day mostly returned "No
|
|
177
|
+
* task-linked delivery observed", and the host's Stop hook closes the task and
|
|
178
|
+
* shows the evidence either way. FAILS OPEN (con_03a0b94b2e): if the launcher
|
|
179
|
+
* cannot be computed, fall back to asking for the start call — that path is then
|
|
180
|
+
* the only source of both the launcher and the finish instruction, so it carries
|
|
181
|
+
* its own finish sentence. */
|
|
182
|
+
export function taskInstruction(task, cwdLiteral, provider, launcher = verificationLauncher) {
|
|
183
|
+
const head = `Hunch has already opened this prompt's report: ${task.task_id}. Reuse this exact ID; never open another report. Pass this task_id and cwd: ${cwdLiteral} to hunch_context and decision/correction/finding captures.`;
|
|
184
|
+
let verify;
|
|
185
|
+
try {
|
|
186
|
+
const l = launcher();
|
|
187
|
+
verify = ` Never call hunch_task start for it. For checks, run: ${l.shell} task verify ${task.task_id} -- <command> [arguments]${l.note ?? ""}. Default budget 15 min; add --timeout <seconds> before -- for longer suites.`;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return `${head} Call hunch_task(action: "start", task_id: "${task.task_id}", title: ${JSON.stringify(task.title)}, cwd: ${cwdLiteral}) to obtain verification_argv, and finish with hunch_task(action: "finish", task_id, cwd) before responding and show its card.`;
|
|
191
|
+
}
|
|
192
|
+
const used = `this task used Hunch (a hunch_* call on this ID, a task verify check, Hunch hook context you acted on, or an application to claim)`;
|
|
193
|
+
const finish = HOST_CLOSES_TASK.has(provider)
|
|
194
|
+
? ` ONLY if ${used}, call hunch_task(action: "finish", task_id, cwd) before responding and show its card; otherwise skip it — this host's stop hook closes the task and shows the evidence.`
|
|
195
|
+
: ` No stop hook closes this task, so finish it yourself whether or not ${used}: call hunch_task(action: "finish", task_id, cwd) before responding and show its card.`;
|
|
196
|
+
return `${head}${verify}${finish}`;
|
|
149
197
|
}
|
|
150
198
|
/** The session key a hook event maps to: a hash of (root, provider, session,
|
|
151
199
|
* agent), never the identifier itself. Null without a host session. */
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Structured argv is authoritative; the shell hint uses literal quoting, and
|
|
2
|
+
* `note` disambiguates it where one platform has two incompatible shells. */
|
|
3
|
+
export declare function verificationLauncher(): {
|
|
4
|
+
argv: string[];
|
|
5
|
+
shell: string;
|
|
6
|
+
note: string;
|
|
7
|
+
};
|
|
8
|
+
/** `metaUrl` is the module running (a `.ts` source checkout needs the tsx
|
|
9
|
+
* loader; a published `.js` build needs nothing) and `resolve` is that
|
|
10
|
+
* module's `import.meta.resolve`. Callers in sibling directories (src/core,
|
|
11
|
+
* src/mcp) resolve the same `../cli/index.{ts|js}`, but each must pass ITS OWN
|
|
12
|
+
* import.meta so the dev/published discrimination stays honest. The loader is
|
|
13
|
+
* resolved ONLY on the source path: `import.meta.resolve` throws for a package
|
|
14
|
+
* that is not installed, and `tsx` is a devDependency absent from every
|
|
15
|
+
* published install (#261). `platform` defaults to the running one and exists so
|
|
16
|
+
* the Windows quoting branch is testable from any machine. */
|
|
17
|
+
export declare function verificationLauncherFor(metaUrl: string, resolve: (specifier: string) => string, platform?: NodeJS.Platform): {
|
|
18
|
+
argv: string[];
|
|
19
|
+
shell: string;
|
|
20
|
+
note: string;
|
|
21
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** The launcher that runs `hunch task verify` from THIS installation, not a
|
|
2
|
+
* potentially stale global binary. Core, not mcp: the prompt hook prints the
|
|
3
|
+
* command inline (so the model needs no hunch_task start call just to learn it)
|
|
4
|
+
* and a hook must never pull in the MCP SDK. */
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
/** Structured argv is authoritative; the shell hint uses literal quoting, and
|
|
7
|
+
* `note` disambiguates it where one platform has two incompatible shells. */
|
|
8
|
+
export function verificationLauncher() {
|
|
9
|
+
return verificationLauncherFor(import.meta.url, (specifier) => import.meta.resolve(specifier));
|
|
10
|
+
}
|
|
11
|
+
/** `metaUrl` is the module running (a `.ts` source checkout needs the tsx
|
|
12
|
+
* loader; a published `.js` build needs nothing) and `resolve` is that
|
|
13
|
+
* module's `import.meta.resolve`. Callers in sibling directories (src/core,
|
|
14
|
+
* src/mcp) resolve the same `../cli/index.{ts|js}`, but each must pass ITS OWN
|
|
15
|
+
* import.meta so the dev/published discrimination stays honest. The loader is
|
|
16
|
+
* resolved ONLY on the source path: `import.meta.resolve` throws for a package
|
|
17
|
+
* that is not installed, and `tsx` is a devDependency absent from every
|
|
18
|
+
* published install (#261). `platform` defaults to the running one and exists so
|
|
19
|
+
* the Windows quoting branch is testable from any machine. */
|
|
20
|
+
export function verificationLauncherFor(metaUrl, resolve, platform = process.platform) {
|
|
21
|
+
const dev = metaUrl.endsWith(".ts");
|
|
22
|
+
const entry = fileURLToPath(new URL(`../cli/index.${dev ? "ts" : "js"}`, metaUrl));
|
|
23
|
+
// `--import` takes a URL. Converting the resolved loader to a path made Node on
|
|
24
|
+
// Windows reject it ("Received protocol 'c:'"), so every verification launched
|
|
25
|
+
// from a source checkout there failed before running and cards showed no check.
|
|
26
|
+
const loader = dev ? resolve("tsx") : null;
|
|
27
|
+
const argv = [process.execPath, ...(loader ? ["--import", loader.startsWith("file:") ? loader : pathToFileURL(loader).href] : []), entry];
|
|
28
|
+
const win = platform === "win32";
|
|
29
|
+
const quote = (s) => win ? `'${s.replace(/'/g, "''")}'` : `'${s.replace(/'/g, "'\\''")}'`;
|
|
30
|
+
// Windows hosts run either PowerShell or a POSIX shell (Git Bash), and the
|
|
31
|
+
// call operator that PowerShell needs is a syntax error in the other. The hint
|
|
32
|
+
// is quoted for PowerShell and says how to use it in the other; the structured
|
|
33
|
+
// argv stays the unambiguous form.
|
|
34
|
+
const note = win ? ` (PowerShell form; in a POSIX shell such as Git Bash drop the leading "& ")` : "";
|
|
35
|
+
return { argv, shell: `${win ? "& " : ""}${argv.map(quote).join(" ")}`, note };
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=verifyLauncher.js.map
|
package/dist/core/workspace.d.ts
CHANGED
|
@@ -253,4 +253,13 @@ export interface PrunePlan {
|
|
|
253
253
|
/** Why a branch must not be pruned, or null when it may. The rules are the documented ones:
|
|
254
254
|
* proven merged, not the default branch, worktree (if any) clean, unlocked and present. */
|
|
255
255
|
export declare function pruneRefusal(b: WorkspaceBranch, wt: WorkspaceWorktree | undefined): string | null;
|
|
256
|
+
/** The repo-relative path of a workspace record committed into the PUBLIC `.hunch/`. */
|
|
257
|
+
export declare function publicWorkspaceRecordPath(id: string): string;
|
|
258
|
+
/** The manual recipe for removing a workspace record that lives in the repo-tracked
|
|
259
|
+
* `.hunch/`. Hunch's own publication pump is deliberately ADDITIVE — it refuses to stage
|
|
260
|
+
* a tracked deletion so a stale clone can never erase team history (the same stance
|
|
261
|
+
* `compact --apply` takes) — so deleting such a record here would strand `D .hunch/…` in
|
|
262
|
+
* the working tree and wedge every later auto-commit. The removal is a normal, reviewable
|
|
263
|
+
* git change the human makes instead. */
|
|
264
|
+
export declare function publicWorkspaceRemovalRecipe(id: string, machine: string): string;
|
|
256
265
|
export declare function planPrune(live: Workspace, others: readonly Workspace[]): PrunePlan;
|
package/dist/core/workspace.js
CHANGED
|
@@ -346,6 +346,21 @@ export function pruneRefusal(b, wt) {
|
|
|
346
346
|
return wt.dirty === null ? "worktree state could not be read" : "worktree has uncommitted or untracked changes";
|
|
347
347
|
return null;
|
|
348
348
|
}
|
|
349
|
+
/** The repo-relative path of a workspace record committed into the PUBLIC `.hunch/`. */
|
|
350
|
+
export function publicWorkspaceRecordPath(id) {
|
|
351
|
+
return `.hunch/workspaces/${id}.json`;
|
|
352
|
+
}
|
|
353
|
+
/** The manual recipe for removing a workspace record that lives in the repo-tracked
|
|
354
|
+
* `.hunch/`. Hunch's own publication pump is deliberately ADDITIVE — it refuses to stage
|
|
355
|
+
* a tracked deletion so a stale clone can never erase team history (the same stance
|
|
356
|
+
* `compact --apply` takes) — so deleting such a record here would strand `D .hunch/…` in
|
|
357
|
+
* the working tree and wedge every later auto-commit. The removal is a normal, reviewable
|
|
358
|
+
* git change the human makes instead. */
|
|
359
|
+
export function publicWorkspaceRemovalRecipe(id, machine) {
|
|
360
|
+
const path = publicWorkspaceRecordPath(id);
|
|
361
|
+
return ` · git rm ${path} (never committed? delete the file instead)\n`
|
|
362
|
+
+ ` · git commit -m "hunch: forget workspace ${machine}"`;
|
|
363
|
+
}
|
|
349
364
|
export function planPrune(live, others) {
|
|
350
365
|
const mine = pruneStepsFor(live, { skip: pruneRefusal });
|
|
351
366
|
const plan = { local: mine.steps, others: {}, skipped: mine.skipped };
|
package/dist/extractors/git.d.ts
CHANGED
|
@@ -162,6 +162,11 @@ export declare function isLinkedWorktree(cwd: string): boolean;
|
|
|
162
162
|
/** Current branch name (e.g. "main", "feat/x"), or "" in detached HEAD / non-repo.
|
|
163
163
|
* Stamped onto auto-captured decisions so branch-scoped work stays filterable. */
|
|
164
164
|
export declare function currentBranch(cwd: string): string;
|
|
165
|
+
/** Whether HEAD is somewhere a write must not land: mid rebase/merge/cherry-pick/revert/bisect
|
|
166
|
+
* ("git-operation-in-progress", which wins when both hold), or detached ("detached-head").
|
|
167
|
+
* null when HEAD is a settled branch — and also when git cannot answer at all (not a repo,
|
|
168
|
+
* git missing), so a caller keeps its pre-existing behavior rather than failing closed. */
|
|
169
|
+
export declare function gitHeadUnsettled(cwd: string): "git-operation-in-progress" | "detached-head" | null;
|
|
165
170
|
/** Files changed in a single commit. `--root` makes the initial commit (which
|
|
166
171
|
* has no parent) report its files as additions instead of returning nothing. */
|
|
167
172
|
export declare function commitFiles(sha: string, cwd: string): string[];
|
package/dist/extractors/git.js
CHANGED
|
@@ -4,7 +4,7 @@ import { execFileSync } from "node:child_process";
|
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { devNull, tmpdir } from "node:os";
|
|
6
6
|
import { isAbsolute, resolve, join, basename, dirname, relative, sep } from "node:path";
|
|
7
|
-
import { mkdtempSync, openSync, closeSync, readSync, mkdirSync, rmSync, statSync, lstatSync, realpathSync, readFileSync, renameSync, readdirSync } from "node:fs";
|
|
7
|
+
import { mkdtempSync, openSync, closeSync, readSync, mkdirSync, rmSync, statSync, lstatSync, realpathSync, readFileSync, renameSync, readdirSync, existsSync } from "node:fs";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { MEMLOG_FORMAT } from "../core/memorylog.js";
|
|
10
10
|
import { hunchAttributesAreSafe, hunchTreeAttributesAreSafe, safeOverlayTree } from "../core/overlaySafety.js";
|
|
@@ -1686,14 +1686,25 @@ export function syncExistingHunch(hunchDir, protectedRepoRoot, timeoutMs = CAPTU
|
|
|
1686
1686
|
}
|
|
1687
1687
|
}
|
|
1688
1688
|
const UNKNOWN_LOCK_STALE_MS = 10 * 60_000;
|
|
1689
|
+
// An owner-less lock directory is also a NORMAL transient state: the window
|
|
1690
|
+
// between the two mkdir calls in createOwnedCommitLock, the release window where
|
|
1691
|
+
// recursive cleanup removes owner-<pid> before the outer directory, and a lost
|
|
1692
|
+
// reclaim race. This bounds how long a lock may stay owner-less before it is
|
|
1693
|
+
// treated as stranded. At ENTRY it is compared with the directory's mtime
|
|
1694
|
+
// (creating the directory, or adding/removing a child entry, touches it), so an
|
|
1695
|
+
// old stranded lock no-ops instantly. INSIDE the wait loop it is a local
|
|
1696
|
+
// continuously-owner-less timer instead, so the wait depends on neither
|
|
1697
|
+
// filesystem mtime semantics nor clock agreement between writers.
|
|
1698
|
+
const OWNERLESS_LOCK_FRESH_MS = 2_000;
|
|
1689
1699
|
function createOwnedCommitLock(lock) {
|
|
1690
1700
|
let created = false;
|
|
1691
1701
|
try {
|
|
1692
1702
|
// mkdir is the exclusive atomic operation here. Renaming a staged directory
|
|
1693
1703
|
// is NOT exclusive on POSIX: it may replace an already-existing empty lock
|
|
1694
1704
|
// directory, which would steal a fresh legacy/ownerless lock. There is a
|
|
1695
|
-
// harmless ownerless window between these two mkdir calls
|
|
1696
|
-
//
|
|
1705
|
+
// harmless ownerless window between these two mkdir calls: it reads as held,
|
|
1706
|
+
// and is reclaimed only after the conservative legacy TTL expires. A capture
|
|
1707
|
+
// waits it out (waitForCommitLockHandoff); other callers just report busy.
|
|
1697
1708
|
mkdirSync(lock);
|
|
1698
1709
|
created = true;
|
|
1699
1710
|
// Empty directories are not Git worktree entries, so owner metadata cannot
|
|
@@ -1781,21 +1792,66 @@ function acquireCommitLock(lock) {
|
|
|
1781
1792
|
}
|
|
1782
1793
|
return createOwnedCommitLock(lock) ? { state: "acquired" } : { state: "held-unknown" };
|
|
1783
1794
|
}
|
|
1795
|
+
/** Is an owner-less lock directory recent enough to be a transient window
|
|
1796
|
+
* rather than a stranded one? Only meaningful for the FIRST snapshot, where
|
|
1797
|
+
* there is no local history to measure against. An unreadable directory is not
|
|
1798
|
+
* assumed transient: only ENOENT is, because the owner may have just finished
|
|
1799
|
+
* releasing. `Math.abs` so a clock-skewed far-FUTURE mtime is not read as
|
|
1800
|
+
* recent forever. */
|
|
1801
|
+
function ownerlessLockLooksTransient(lock) {
|
|
1802
|
+
try {
|
|
1803
|
+
return Math.abs(Date.now() - statSync(lock).mtimeMs) <= OWNERLESS_LOCK_FRESH_MS;
|
|
1804
|
+
}
|
|
1805
|
+
catch (error) {
|
|
1806
|
+
return error.code === "ENOENT";
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1784
1809
|
function waitForCommitLockHandoff(lock, first, timeoutMs) {
|
|
1785
|
-
if (first.state
|
|
1810
|
+
if (first.state === "held-live" && first.ownerPid === process.pid)
|
|
1811
|
+
return false;
|
|
1812
|
+
// An owner-less FIRST snapshot is either a transient window (creation,
|
|
1813
|
+
// release, lost reclaim race) or a stranded/legacy lock. Only the former is
|
|
1814
|
+
// worth waiting for: a stranded lock must not block the capture for the whole
|
|
1815
|
+
// handoff. This is the one place the WAIT POLICY consults the directory's
|
|
1816
|
+
// mtime; acquireCommitLock still stats it for the legacy reclaim TTL.
|
|
1817
|
+
if (first.state === "held-unknown" && !ownerlessLockLooksTransient(lock))
|
|
1786
1818
|
return false;
|
|
1787
1819
|
const deadline = Date.now() + timeoutMs;
|
|
1788
1820
|
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
1789
1821
|
let attempt = first;
|
|
1822
|
+
// How long the lock has read as held-unknown CONTINUOUSLY, measured locally:
|
|
1823
|
+
// a sighting of a live owner resets it, because a genuine release window lasts
|
|
1824
|
+
// milliseconds. Reading it from this clock keeps the wait independent of the
|
|
1825
|
+
// filesystem's mtime granularity and of other writers' clocks. Besides a truly
|
|
1826
|
+
// owner-less directory this also covers a dead owner whose reclaim another
|
|
1827
|
+
// process has claimed — equally not a handoff this contender can wait for.
|
|
1828
|
+
let ownerlessSince = first.state === "held-unknown" ? Date.now() : null;
|
|
1829
|
+
// "Held" while the directory is not even there means mkdir keeps failing for a
|
|
1830
|
+
// reason that is not contention — a missing, read-only or full store — and no
|
|
1831
|
+
// amount of waiting will hand anything off. Require a short streak so one
|
|
1832
|
+
// sample taken mid-release is not mistaken for it.
|
|
1833
|
+
let vanishedStreak = 0;
|
|
1790
1834
|
while (Date.now() < deadline) {
|
|
1791
1835
|
if (attempt.state === "acquired")
|
|
1792
1836
|
return true;
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1837
|
+
if (attempt.state === "held-live") {
|
|
1838
|
+
if (attempt.ownerPid === process.pid)
|
|
1839
|
+
return false;
|
|
1840
|
+
ownerlessSince = null;
|
|
1841
|
+
vanishedStreak = 0;
|
|
1842
|
+
}
|
|
1843
|
+
else if (attempt.state === "held-unknown") {
|
|
1844
|
+
ownerlessSince ??= Date.now();
|
|
1845
|
+
if (Date.now() - ownerlessSince > OWNERLESS_LOCK_FRESH_MS)
|
|
1846
|
+
return false;
|
|
1847
|
+
if (!existsSync(lock)) {
|
|
1848
|
+
vanishedStreak += 1;
|
|
1849
|
+
if (vanishedStreak >= 3)
|
|
1850
|
+
return false;
|
|
1851
|
+
}
|
|
1852
|
+
else
|
|
1853
|
+
vanishedStreak = 0;
|
|
1854
|
+
}
|
|
1799
1855
|
Atomics.wait(sleeper, 0, 0, Math.min(25, deadline - Date.now()));
|
|
1800
1856
|
attempt = acquireCommitLock(lock);
|
|
1801
1857
|
}
|
|
@@ -1877,6 +1933,27 @@ export function currentBranch(cwd) {
|
|
|
1877
1933
|
const b = gitSafe(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
|
|
1878
1934
|
return b === "HEAD" ? "" : b; // detached HEAD reports "HEAD" — treat as no branch
|
|
1879
1935
|
}
|
|
1936
|
+
/** The state files git writes while it is replaying history. `git rev-parse --git-path`
|
|
1937
|
+
* resolves each against THIS worktree's private git dir (a linked worktree keeps its own
|
|
1938
|
+
* rebase-merge/MERGE_HEAD), so a rebase in one worktree never reads as one in another. */
|
|
1939
|
+
const UNSETTLED_HEAD_PATHS = ["rebase-merge", "rebase-apply", "MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "BISECT_LOG"];
|
|
1940
|
+
/** Whether HEAD is somewhere a write must not land: mid rebase/merge/cherry-pick/revert/bisect
|
|
1941
|
+
* ("git-operation-in-progress", which wins when both hold), or detached ("detached-head").
|
|
1942
|
+
* null when HEAD is a settled branch — and also when git cannot answer at all (not a repo,
|
|
1943
|
+
* git missing), so a caller keeps its pre-existing behavior rather than failing closed. */
|
|
1944
|
+
export function gitHeadUnsettled(cwd) {
|
|
1945
|
+
// One spawn: `git rev-parse --git-path a --git-path b …` prints one line per path.
|
|
1946
|
+
const out = gitSafe(["rev-parse", ...UNSETTLED_HEAD_PATHS.flatMap((p) => ["--git-path", p])], cwd);
|
|
1947
|
+
if (!out)
|
|
1948
|
+
return null; // git could not answer (not a repo) — fail open
|
|
1949
|
+
const paths = out.split("\n").map((p) => p.trim()).filter(Boolean);
|
|
1950
|
+
if (paths.length !== UNSETTLED_HEAD_PATHS.length)
|
|
1951
|
+
return null;
|
|
1952
|
+
if (paths.some((p) => existsSync(isAbsolute(p) ? p : resolve(cwd, p))))
|
|
1953
|
+
return "git-operation-in-progress";
|
|
1954
|
+
// `git symbolic-ref -q HEAD` exits non-zero exactly when HEAD is detached.
|
|
1955
|
+
return gitSafe(["symbolic-ref", "-q", "HEAD"], cwd) ? null : "detached-head";
|
|
1956
|
+
}
|
|
1880
1957
|
/** Files changed in a single commit. `--root` makes the initial commit (which
|
|
1881
1958
|
* has no parent) report its files as additions instead of returning nothing. */
|
|
1882
1959
|
export function commitFiles(sha, cwd) {
|
|
@@ -51,5 +51,15 @@ export declare function assertCompleteRepoScan(scan: RepoScan): void;
|
|
|
51
51
|
* the durable graph merely by inspecting it. Existing public graph records remain
|
|
52
52
|
* inputs for the same churn/component enrichment semantics as a persisted index. */
|
|
53
53
|
export declare function scanRepo(store: HunchStore, root: string, opts?: ScanRepoOptions): RepoScan;
|
|
54
|
+
/** True for an edge this indexer produced: the scan re-derives exactly these on
|
|
55
|
+
* every pass, so they — and only they — are safe to replace wholesale. */
|
|
56
|
+
export declare function isExtractorEdge(edge: Edge): boolean;
|
|
57
|
+
/** Merge a fresh scan into the stored edge set. A scan can only re-derive what
|
|
58
|
+
* it extracted; `supersedes` edges (written by the store) and human-reviewed
|
|
59
|
+
* Landscape relationships have no other source, so replacing the whole index
|
|
60
|
+
* with the scan deleted them on every `hunch index` (issue #288). Non-extractor
|
|
61
|
+
* edges are carried forward in their stored order and win an id collision:
|
|
62
|
+
* a reviewed or store-written fact outranks a re-derivable one. */
|
|
63
|
+
export declare function mergeScannedEdges(stored: Edge[], scanned: Edge[]): Edge[];
|
|
54
64
|
/** Persist one pure scan into the Git-native source of truth. */
|
|
55
65
|
export declare function indexRepo(store: HunchStore, root: string, opts?: IndexRepoOptions): IndexResult;
|