@davesheffer/hunch 1.38.0 → 1.38.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +15 -3
- package/dist/core/taskRecord.js +6 -3
- package/dist/core/taskReport.d.ts +25 -2
- package/dist/core/taskReport.js +92 -18
- package/dist/core/taskReportEvidence.d.ts +4 -1
- package/dist/core/taskReportEvidence.js +5 -2
- package/dist/core/taskReportHook.d.ts +16 -3
- package/dist/core/taskReportHook.js +65 -13
- package/dist/core/taskTouched.d.ts +4 -0
- package/dist/core/taskTouched.js +30 -10
- package/dist/mcp/taskReportTools.d.ts +9 -0
- package/dist/mcp/taskReportTools.js +13 -5
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -88,7 +88,7 @@ import { recordServed, servedSummary } from "../core/served.js";
|
|
|
88
88
|
import { recordTaskDelivery, reportActivity, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
|
|
89
89
|
import { snapshotDeliveredRecords } from "../core/taskReportEvidence.js";
|
|
90
90
|
import { renderRecalledLine } from "../core/taskReportRender.js";
|
|
91
|
-
import { closeHookTask, hookReportTaskId, nativeHookCwd, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
|
|
91
|
+
import { closeHookTask, hookReportTaskId, nativeHookCwd, settleHookSession, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
|
|
92
92
|
import { persistTaskRecord } from "../core/taskRecord.js";
|
|
93
93
|
import { recordHookObservation } from "../core/hookObservations.js";
|
|
94
94
|
import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
|
|
@@ -4553,9 +4553,10 @@ program
|
|
|
4553
4553
|
// or not the agent called finish. Fail-open: the card below still renders.
|
|
4554
4554
|
try {
|
|
4555
4555
|
const closed = closeHookTask(root, provider, evt);
|
|
4556
|
-
if (closed) {
|
|
4556
|
+
if (closed.length) {
|
|
4557
4557
|
store ??= new HunchStore(paths);
|
|
4558
|
-
|
|
4558
|
+
for (const id of closed)
|
|
4559
|
+
persistTaskRecord(root, store, id);
|
|
4559
4560
|
}
|
|
4560
4561
|
}
|
|
4561
4562
|
catch { /* the ledger and the card remain authoritative; the next finish retries */ }
|
|
@@ -4586,6 +4587,17 @@ program
|
|
|
4586
4587
|
}
|
|
4587
4588
|
}
|
|
4588
4589
|
catch { /* passive reporting remains fail-open */ }
|
|
4590
|
+
// A task an earlier prompt of this session left open (interrupted before
|
|
4591
|
+
// its Stop) is over now: close it and keep its record.
|
|
4592
|
+
try {
|
|
4593
|
+
const settled = settleHookSession(root, provider, evt, { keepId: hookReportTaskId(root, provider, evt), keepNewest: true });
|
|
4594
|
+
if (settled.length) {
|
|
4595
|
+
store ??= new HunchStore(paths);
|
|
4596
|
+
for (const id of settled)
|
|
4597
|
+
persistTaskRecord(root, store, id);
|
|
4598
|
+
}
|
|
4599
|
+
}
|
|
4600
|
+
catch { /* the next Stop or prompt retries */ }
|
|
4589
4601
|
// Pipeline turn bookkeeping (fresh block budget) + the one nag that must
|
|
4590
4602
|
// repeat: edits from an earlier turn still unverified.
|
|
4591
4603
|
if (evt.session_id && pipelineEnabled()) {
|
package/dist/core/taskRecord.js
CHANGED
|
@@ -14,7 +14,7 @@ import { readFileSync } from "node:fs";
|
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { flushCapture } from "../integrations/sync.js";
|
|
16
16
|
import { hunchPaths } from "./paths.js";
|
|
17
|
-
import { episodeTasks, isEmptyTaskReport, readTaskReport, reportHash } from "./taskReport.js";
|
|
17
|
+
import { episodeTasks, isEmptyTaskReport, readTaskReport, reportHash, sessionsOverlap } from "./taskReport.js";
|
|
18
18
|
import { reportSourceSnapshot } from "./taskReportEvidence.js";
|
|
19
19
|
import { ENTITY_KINDS, TaskRecordSchema } from "./types.js";
|
|
20
20
|
import { refreshRankEval } from "./taskRankingMode.js";
|
|
@@ -184,7 +184,10 @@ export function persistTaskRecord(root, store, taskId, options = {}) {
|
|
|
184
184
|
const members = headId === own.task.task_id && !own.task.episode ? [own.task] : episodeTasks(root, headId);
|
|
185
185
|
const reports = (members.length ? members : [own.task]).map((t) => (t.task_id === taskId ? own : readTaskReport(root, t.task_id, snapshot)));
|
|
186
186
|
const window = { from: reports[0].task.started_at, to: reports.reduce((max, r) => (r.task.finished_at && (!max || r.task.finished_at > max) ? r.task.finished_at : max), null) };
|
|
187
|
-
|
|
187
|
+
// Another session working in the same checkout at the same time leaves the
|
|
188
|
+
// same mtimes: then only commits (attributable by author and time) count.
|
|
189
|
+
const workingTree = !sessionsOverlap(root, reports[0].task.session_key, window.from, window.to, reports.map((r) => r.task.task_id));
|
|
190
|
+
let built = taskRecordFromReports(reports, gitTouchedFiles(root, window.from, window.to, { workingTree }));
|
|
188
191
|
if (!built)
|
|
189
192
|
return null;
|
|
190
193
|
let inPrivate = store.hasPrivate ? store.getPrivateRec("tasks", built.id) : undefined;
|
|
@@ -194,7 +197,7 @@ export function persistTaskRecord(root, store, taskId, options = {}) {
|
|
|
194
197
|
// episode splits here: this prompt keeps its own record instead of naming
|
|
195
198
|
// private memory in a public one.
|
|
196
199
|
if (inPublic && !inPrivate && taskRecordHome(store, built) === "private" && built.id !== taskId) {
|
|
197
|
-
built = taskRecordFromReports([own], gitTouchedFiles(root, own.task.started_at, own.task.finished_at));
|
|
200
|
+
built = taskRecordFromReports([own], gitTouchedFiles(root, own.task.started_at, own.task.finished_at, { workingTree }));
|
|
198
201
|
if (!built)
|
|
199
202
|
return null;
|
|
200
203
|
inPrivate = store.hasPrivate ? store.getPrivateRec("tasks", built.id) : undefined;
|
|
@@ -200,6 +200,12 @@ export interface LessonHistory {
|
|
|
200
200
|
truncated: boolean;
|
|
201
201
|
next_before: number | null;
|
|
202
202
|
}
|
|
203
|
+
/** A prompt identity that reports to another prompt's task: a host notification
|
|
204
|
+
* turn continues the session's latest task instead of opening a row. Explicit,
|
|
205
|
+
* so Stop never selects a task by recency for a prompt it does not know. */
|
|
206
|
+
export declare function aliasReportTask(root: string, aliasId: string, taskId: string): void;
|
|
207
|
+
/** The task an aliased prompt identity reports to, or the identity itself. */
|
|
208
|
+
export declare function resolveReportTask(root: string, id: string): string;
|
|
203
209
|
/** The lookup is derived, never evidence. Backfill at most 64 events and
|
|
204
210
|
* 512 KB per read. Parse outside the writer transaction so a history view cannot
|
|
205
211
|
* hold the writer lock while validating a large legacy ledger. */
|
|
@@ -217,14 +223,31 @@ export declare function startReportTask(root: string, title: string, taskId?: st
|
|
|
217
223
|
* same work: its task continues the previous one and shares its episode. */
|
|
218
224
|
export declare const CONTINUATION_WINDOW_MS: number;
|
|
219
225
|
/** The links a new prompt's task takes from the latest task of its session, or
|
|
220
|
-
* null when that task is too old (measured from its close
|
|
221
|
-
*
|
|
226
|
+
* null when that task is too old (measured from its close) to be the same work.
|
|
227
|
+
* A task still open is the session's current work however long ago it started:
|
|
228
|
+
* the prompt was interrupted before Stop, or a late observation reopened it. */
|
|
222
229
|
export declare function continuationLinks(previous: ReportTask | null, nowMs?: number): {
|
|
223
230
|
continues: string;
|
|
224
231
|
episode: string;
|
|
225
232
|
} | null;
|
|
226
233
|
/** The most recent task of a session in this worktree, or null. */
|
|
227
234
|
export declare function latestSessionTask(root: string, sessionKey: string): ReportTask | null;
|
|
235
|
+
/** Tasks of a session that an earlier prompt left open are over once the
|
|
236
|
+
* session moves on: the prompt was interrupted before its Stop, a notification
|
|
237
|
+
* turn reused the task, or a late observation reopened it. Close them as host
|
|
238
|
+
* closes, except `keepId` (the prompt now running), the session's newest task
|
|
239
|
+
* when `keepNewest` (a notification turn continues it), and any task whose
|
|
240
|
+
* verification may still deliver a result. Returns the ids closed here so the
|
|
241
|
+
* caller can persist their records. */
|
|
242
|
+
export declare function settleSessionTasks(root: string, sessionKey: string, options?: {
|
|
243
|
+
keepId?: string | null;
|
|
244
|
+
keepNewest?: boolean;
|
|
245
|
+
}): string[];
|
|
246
|
+
/** Whether a task of ANOTHER session (or of no session) was open in this
|
|
247
|
+
* worktree during the window: working-tree edits made then cannot be told apart
|
|
248
|
+
* by mtime, so the caller attributes only commits. `ownIds` are the episode's
|
|
249
|
+
* own tasks; any other task without a session key counts as foreign. */
|
|
250
|
+
export declare function sessionsOverlap(root: string, sessionKey: string | undefined, from: string, to: string | null, ownIds?: readonly string[]): boolean;
|
|
228
251
|
/** Every task of an episode, oldest first: the head and the prompts that continued it. */
|
|
229
252
|
export declare function episodeTasks(root: string, headId: string): ReportTask[];
|
|
230
253
|
/** The record revisions among `records` that this task has not received before.
|
package/dist/core/taskReport.js
CHANGED
|
@@ -115,10 +115,25 @@ function taskDb(root, run) {
|
|
|
115
115
|
);
|
|
116
116
|
CREATE INDEX IF NOT EXISTS report_record_lookup ON report_record_links(kind, record_id, content_hash);
|
|
117
117
|
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)
|
|
118
|
+
INSERT OR IGNORE INTO report_history_progress VALUES (1, 0);
|
|
119
|
+
CREATE TABLE IF NOT EXISTS report_task_aliases (alias_id TEXT PRIMARY KEY, task_id TEXT NOT NULL);`);
|
|
119
120
|
return run(db);
|
|
120
121
|
});
|
|
121
122
|
}
|
|
123
|
+
/** A prompt identity that reports to another prompt's task: a host notification
|
|
124
|
+
* turn continues the session's latest task instead of opening a row. Explicit,
|
|
125
|
+
* so Stop never selects a task by recency for a prompt it does not know. */
|
|
126
|
+
export function aliasReportTask(root, aliasId, taskId) {
|
|
127
|
+
TaskIdSchema.parse(aliasId);
|
|
128
|
+
taskDb(root, db => transaction(db, () => {
|
|
129
|
+
readTask(db, root, taskId);
|
|
130
|
+
db.prepare("INSERT OR REPLACE INTO report_task_aliases VALUES (?, ?)").run(aliasId, taskId);
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
/** The task an aliased prompt identity reports to, or the identity itself. */
|
|
134
|
+
export function resolveReportTask(root, id) {
|
|
135
|
+
return taskDb(root, db => db.prepare("SELECT task_id FROM report_task_aliases WHERE alias_id = ?").get(id)?.task_id ?? id);
|
|
136
|
+
}
|
|
122
137
|
function deliveryRecords(kind, body) {
|
|
123
138
|
if (kind === "save")
|
|
124
139
|
return [ReportSaveSchema.parse(body).record];
|
|
@@ -261,21 +276,69 @@ export function startReportTask(root, title, taskId, links = {}) {
|
|
|
261
276
|
* same work: its task continues the previous one and shares its episode. */
|
|
262
277
|
export const CONTINUATION_WINDOW_MS = 30 * 60_000;
|
|
263
278
|
/** The links a new prompt's task takes from the latest task of its session, or
|
|
264
|
-
* null when that task is too old (measured from its close
|
|
265
|
-
*
|
|
279
|
+
* null when that task is too old (measured from its close) to be the same work.
|
|
280
|
+
* A task still open is the session's current work however long ago it started:
|
|
281
|
+
* the prompt was interrupted before Stop, or a late observation reopened it. */
|
|
266
282
|
export function continuationLinks(previous, nowMs = Date.now()) {
|
|
267
283
|
if (!previous)
|
|
268
284
|
return null;
|
|
285
|
+
const links = { continues: previous.task_id, episode: previous.episode ?? previous.task_id };
|
|
286
|
+
if (previous.state === "open")
|
|
287
|
+
return links;
|
|
269
288
|
const reference = Date.parse(previous.finished_at ?? previous.started_at);
|
|
270
289
|
if (!Number.isFinite(reference) || nowMs - reference > CONTINUATION_WINDOW_MS)
|
|
271
290
|
return null;
|
|
272
|
-
return
|
|
291
|
+
return links;
|
|
292
|
+
}
|
|
293
|
+
function newestSessionTask(db, root, sessionKey) {
|
|
294
|
+
const row = db.prepare("SELECT body FROM report_tasks WHERE scope IN (?, ?, ?) AND json_extract(body, '$.session_key') = ? ORDER BY rowid DESC LIMIT 1").get(...scopePair(root), sessionKey);
|
|
295
|
+
return row ? TaskSchema.parse(JSON.parse(row.body)) : null;
|
|
273
296
|
}
|
|
274
297
|
/** The most recent task of a session in this worktree, or null. */
|
|
275
298
|
export function latestSessionTask(root, sessionKey) {
|
|
299
|
+
return taskDb(root, db => newestSessionTask(db, root, sessionKey));
|
|
300
|
+
}
|
|
301
|
+
/** Check-starts still inside their own timeout plus the grace window, minus the
|
|
302
|
+
* results that arrived: while positive, a runner may still deliver a result. */
|
|
303
|
+
function pendingChecks(db, taskId) {
|
|
304
|
+
const { pending } = db.prepare(`SELECT SUM(CASE WHEN kind = 'check-start' AND (julianday('now') - julianday(at)) * 86400000 < COALESCE(json_extract(body, '$.timeout_ms'), ${MAX_PENDING_CHECK_MS}) + ${CHECK_RESULT_GRACE_MS} THEN 1 WHEN kind = 'check' AND json_extract(body, '$.check_id') IS NOT NULL THEN -1 ELSE 0 END) AS pending FROM report_events WHERE task_id = ?`).get(taskId);
|
|
305
|
+
return pending ?? 0;
|
|
306
|
+
}
|
|
307
|
+
/** Tasks of a session that an earlier prompt left open are over once the
|
|
308
|
+
* session moves on: the prompt was interrupted before its Stop, a notification
|
|
309
|
+
* turn reused the task, or a late observation reopened it. Close them as host
|
|
310
|
+
* closes, except `keepId` (the prompt now running), the session's newest task
|
|
311
|
+
* when `keepNewest` (a notification turn continues it), and any task whose
|
|
312
|
+
* verification may still deliver a result. Returns the ids closed here so the
|
|
313
|
+
* caller can persist their records. */
|
|
314
|
+
export function settleSessionTasks(root, sessionKey, options = {}) {
|
|
315
|
+
return taskDb(root, db => transaction(db, () => {
|
|
316
|
+
const rows = db.prepare("SELECT body FROM report_tasks WHERE scope IN (?, ?, ?) AND json_extract(body, '$.session_key') = ? AND json_extract(body, '$.state') = 'open' ORDER BY rowid").all(...scopePair(root), sessionKey);
|
|
317
|
+
const newest = options.keepNewest ? newestSessionTask(db, root, sessionKey)?.task_id : undefined;
|
|
318
|
+
const closed = [];
|
|
319
|
+
for (const row of rows) {
|
|
320
|
+
const task = TaskSchema.parse(JSON.parse(row.body));
|
|
321
|
+
if (task.task_id === options.keepId || task.task_id === newest || pendingChecks(db, task.task_id) > 0)
|
|
322
|
+
continue;
|
|
323
|
+
db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(TaskSchema.parse({ ...task, state: "completed", finished_at: new Date().toISOString(), closed_by: "host" })), task.task_id);
|
|
324
|
+
closed.push(task.task_id);
|
|
325
|
+
}
|
|
326
|
+
return closed;
|
|
327
|
+
}));
|
|
328
|
+
}
|
|
329
|
+
/** Whether a task of ANOTHER session (or of no session) was open in this
|
|
330
|
+
* worktree during the window: working-tree edits made then cannot be told apart
|
|
331
|
+
* by mtime, so the caller attributes only commits. `ownIds` are the episode's
|
|
332
|
+
* own tasks; any other task without a session key counts as foreign. */
|
|
333
|
+
export function sessionsOverlap(root, sessionKey, from, to, ownIds = []) {
|
|
276
334
|
return taskDb(root, db => {
|
|
277
|
-
const
|
|
278
|
-
return
|
|
335
|
+
const rows = db.prepare("SELECT body FROM report_tasks WHERE scope IN (?, ?, ?) AND json_extract(body, '$.started_at') <= ? AND (json_extract(body, '$.finished_at') IS NULL OR json_extract(body, '$.finished_at') >= ?)").all(...scopePair(root), to ?? new Date().toISOString(), from);
|
|
336
|
+
return rows.some(r => {
|
|
337
|
+
const task = TaskSchema.parse(JSON.parse(r.body));
|
|
338
|
+
if (ownIds.includes(task.task_id))
|
|
339
|
+
return false;
|
|
340
|
+
return !sessionKey || !task.session_key || task.session_key !== sessionKey;
|
|
341
|
+
});
|
|
279
342
|
});
|
|
280
343
|
}
|
|
281
344
|
/** Every task of an episode, oldest first: the head and the prompts that continued it. */
|
|
@@ -298,36 +361,47 @@ function appendEvent(root, taskId, kind, body, eventId) {
|
|
|
298
361
|
throw new Error("report event identity conflicts with existing evidence");
|
|
299
362
|
return id;
|
|
300
363
|
}
|
|
364
|
+
// The task the observation lands on: the named one, unless the host closed
|
|
365
|
+
// it and the session has since moved on.
|
|
366
|
+
let target = task;
|
|
301
367
|
if (task.state !== "open" && !(kind === "check" && task.state === "interrupted")) {
|
|
302
368
|
if (task.closed_by !== "host")
|
|
303
369
|
throw new Error("task is already closed; start a new task for new work");
|
|
304
|
-
// The host closed this task at Stop
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
370
|
+
// The host closed this task at Stop. When a later prompt of the session
|
|
371
|
+
// exists, this is that prompt's work named by an old id (the grounding
|
|
372
|
+
// says to reuse ids): it lands on the session's newest task, which the
|
|
373
|
+
// next Stop closes, instead of reopening one no Stop would close again.
|
|
374
|
+
// Verification stays on its own task (a result must match its start), and
|
|
375
|
+
// a task the agent closed is final. Otherwise the turn went on (another
|
|
376
|
+
// hook's block, a resumed prompt): reopen; the next Stop closes it again.
|
|
377
|
+
const newest = kind === "check" || kind === "check-start" || !task.session_key ? null : newestSessionTask(db, root, task.session_key);
|
|
378
|
+
if (newest && newest.task_id !== task.task_id && (newest.state === "open" || newest.closed_by === "host"))
|
|
379
|
+
target = newest;
|
|
380
|
+
if (target.state !== "open") {
|
|
381
|
+
target = TaskSchema.parse({ ...target, state: "open", finished_at: null, closed_by: undefined });
|
|
382
|
+
db.prepare("UPDATE report_tasks SET body = ? WHERE task_id = ?").run(JSON.stringify(target), target.task_id);
|
|
383
|
+
}
|
|
308
384
|
}
|
|
385
|
+
const tid = target.task_id;
|
|
309
386
|
if (kind === "check") {
|
|
310
387
|
const check = ReportCheckSchema.parse(body);
|
|
311
|
-
if (!check.check_id || !db.prepare("SELECT event_id FROM report_events WHERE event_id = ? AND task_id = ? AND kind = 'check-start'").get(check.check_id,
|
|
388
|
+
if (!check.check_id || !db.prepare("SELECT event_id FROM report_events WHERE event_id = ? AND task_id = ? AND kind = 'check-start'").get(check.check_id, tid))
|
|
312
389
|
throw new Error("verification result has no matching start in this task");
|
|
313
390
|
}
|
|
314
|
-
const { total, bytes
|
|
315
|
-
|
|
316
|
-
SUM(CASE WHEN kind = 'check-start' AND (julianday('now') - julianday(at)) * 86400000 < COALESCE(json_extract(body, '$.timeout_ms'), ${MAX_PENDING_CHECK_MS}) + ${CHECK_RESULT_GRACE_MS} THEN 1 WHEN kind = 'check' AND json_extract(body, '$.check_id') IS NOT NULL THEN -1 ELSE 0 END) AS pending
|
|
317
|
-
FROM report_events WHERE task_id = ?`).get(taskId);
|
|
318
|
-
const reserved = Math.max(0, (pending ?? 0) + (kind === "check-start" ? 1 : kind === "check" ? -1 : 0));
|
|
391
|
+
const { total, bytes } = db.prepare("SELECT COUNT(*) AS total, COALESCE(SUM(length(CAST(body AS BLOB))), 0) AS bytes FROM report_events WHERE task_id = ?").get(tid);
|
|
392
|
+
const reserved = Math.max(0, pendingChecks(db, tid) + (kind === "check-start" ? 1 : kind === "check" ? -1 : 0));
|
|
319
393
|
if (total + 1 + reserved > MAX_EVENTS || bytes + Buffer.byteLength(encoded) + reserved * MAX_EVENT_BYTES > MAX_TASK_BYTES)
|
|
320
394
|
throw new Error("task observation limit reached; start a new task");
|
|
321
395
|
if (kind === "claim") {
|
|
322
396
|
const claim = ReportClaimSchema.parse(body);
|
|
323
|
-
const row = db.prepare("SELECT body FROM report_events WHERE event_id = ? AND task_id = ? AND kind = 'delivery'").get(claim.occurrence_id,
|
|
397
|
+
const row = db.prepare("SELECT body FROM report_events WHERE event_id = ? AND task_id = ? AND kind = 'delivery'").get(claim.occurrence_id, tid);
|
|
324
398
|
if (!row)
|
|
325
399
|
throw new Error("claim does not refer to a delivery in this task");
|
|
326
400
|
const delivery = JSON.parse(row.body);
|
|
327
401
|
if (!delivery.records.some(r => r.record_id === claim.record_id && r.content_hash === claim.content_hash))
|
|
328
402
|
throw new Error("claim record revision was not delivered in this task");
|
|
329
403
|
}
|
|
330
|
-
const inserted = db.prepare("INSERT INTO report_events VALUES (?, ?, ?, ?, ?, ?)").run(id,
|
|
404
|
+
const inserted = db.prepare("INSERT INTO report_events VALUES (?, ?, ?, ?, ?, ?)").run(id, tid, kind, new Date().toISOString(), encoded, contentHash);
|
|
331
405
|
indexDeliveryRecords(db, id, kind, body);
|
|
332
406
|
// Advance only over contiguous observed inserts; an older writer may have
|
|
333
407
|
// left unindexed events. Historical gaps are filled by bounded reads above.
|
|
@@ -17,7 +17,10 @@ export declare function reportSourceSnapshot(root: string): ReportSnapshot;
|
|
|
17
17
|
* predicate's subject lives in a changed file. Everything else stays
|
|
18
18
|
* `not-exercised` or `unavailable` — never "satisfied" by file overlap. */
|
|
19
19
|
export declare function runReportConformance(root: string, store: HunchStore, taskId: string): ReportConformance[];
|
|
20
|
-
|
|
20
|
+
/** A full suite is the usual check; two minutes turned passing suites into
|
|
21
|
+
* recorded timeouts (#268). The bound is a safety net for an abandoned runner,
|
|
22
|
+
* not a verdict. */
|
|
23
|
+
export declare const DEFAULT_CHECK_TIMEOUT_MS: number;
|
|
21
24
|
export declare const MAX_CHECK_TIMEOUT_MS: number;
|
|
22
25
|
/** A deliberately explicit command wrapper. The caller chooses the command;
|
|
23
26
|
* reports never execute commands automatically to validate submitted claims. */
|
|
@@ -192,11 +192,14 @@ export function runReportConformance(root, store, taskId) {
|
|
|
192
192
|
return value;
|
|
193
193
|
});
|
|
194
194
|
}
|
|
195
|
-
|
|
195
|
+
/** A full suite is the usual check; two minutes turned passing suites into
|
|
196
|
+
* recorded timeouts (#268). The bound is a safety net for an abandoned runner,
|
|
197
|
+
* not a verdict. */
|
|
198
|
+
export const DEFAULT_CHECK_TIMEOUT_MS = 15 * 60_000;
|
|
196
199
|
export const MAX_CHECK_TIMEOUT_MS = 6 * 60 * 60_000;
|
|
197
200
|
/** A deliberately explicit command wrapper. The caller chooses the command;
|
|
198
201
|
* reports never execute commands automatically to validate submitted claims. */
|
|
199
|
-
export async function runReportCheck(root, taskId, command, label, timeoutMs =
|
|
202
|
+
export async function runReportCheck(root, taskId, command, label, timeoutMs = DEFAULT_CHECK_TIMEOUT_MS, options = {}) {
|
|
200
203
|
// A full suite can legitimately run for half an hour (fnd_70dd5c4034); the
|
|
201
204
|
// bound exists so an abandoned runner cannot hold a task open indefinitely.
|
|
202
205
|
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_CHECK_TIMEOUT_MS)
|
|
@@ -21,6 +21,18 @@ 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
|
+
/** A prompt the host generated to report a background command's completion,
|
|
25
|
+
* not something the user typed. */
|
|
26
|
+
export declare function isNotificationPrompt(prompt: string | undefined): boolean;
|
|
27
|
+
/** Close, as host closes, the tasks of this session that an earlier prompt left
|
|
28
|
+
* open: the prompt was interrupted before its Stop, or a late observation
|
|
29
|
+
* reopened its task. Called when a new prompt starts (`keepNewest`: the new
|
|
30
|
+
* task, or the task a notification turn continues, stays open) and when the
|
|
31
|
+
* current prompt stops. Returns the ids closed here for the caller to persist. */
|
|
32
|
+
export declare function settleHookSession(root: string, provider: HookProvider, event: HunchHookInput, options?: {
|
|
33
|
+
keepId?: string | null;
|
|
34
|
+
keepNewest?: boolean;
|
|
35
|
+
}): string[];
|
|
24
36
|
/** Stop ends the turn, so the prompt's task closes here as a HOST close: the
|
|
25
37
|
* ledger says the task completed even when the agent never called finish, and
|
|
26
38
|
* a task with observations becomes a graph record without anyone's cooperation.
|
|
@@ -28,9 +40,10 @@ export declare function startHookReport(root: string, provider: HookProvider, ev
|
|
|
28
40
|
* continuation: the next observation reopens the task and the following Stop
|
|
29
41
|
* closes it again (the record is refreshed from the report). An explicit agent
|
|
30
42
|
* finish with any outcome overrides a host close. Pending verification keeps
|
|
31
|
-
* the task open.
|
|
32
|
-
*
|
|
33
|
-
|
|
43
|
+
* the task open. Tasks an earlier prompt of the session left open close here
|
|
44
|
+
* too. Returns the ids of the tasks closed after this call, so the caller can
|
|
45
|
+
* persist their records; empty when nothing is closed. */
|
|
46
|
+
export declare function closeHookTask(root: string, provider: HookProvider, event: HunchHookInput): string[];
|
|
34
47
|
/** A presentation notice never denies Stop or injects another model turn.
|
|
35
48
|
* A prompt with no observation at all prints nothing: the empty task row stays
|
|
36
49
|
* in the ledger (hunch task list, the VS Code Contribution view) so "never
|
|
@@ -5,7 +5,7 @@ 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 { continuationLinks, finishReportTask, isEmptyTaskReport, latestSessionTask, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, startReportTask } from "./taskReport.js";
|
|
8
|
+
import { aliasReportTask, continuationLinks, finishReportTask, isEmptyTaskReport, latestSessionTask, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, resolveReportTask, settleSessionTasks, startReportTask } from "./taskReport.js";
|
|
9
9
|
import { reportSourceSnapshot } from "./taskReportEvidence.js";
|
|
10
10
|
import { renderTaskReport } from "./taskReportRender.js";
|
|
11
11
|
/** The exact task identity a native host prompt maps to. */
|
|
@@ -73,7 +73,14 @@ function identity(root, provider, event) {
|
|
|
73
73
|
return null;
|
|
74
74
|
if (!event.prompt_id)
|
|
75
75
|
return "legacy";
|
|
76
|
-
|
|
76
|
+
const id = promptTaskId(root, event.session_id, event.prompt_id, event.agent_id ?? null, provider);
|
|
77
|
+
// A notification turn reports to the task it continued (an explicit alias).
|
|
78
|
+
try {
|
|
79
|
+
return resolveReportTask(root, id);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return id;
|
|
83
|
+
}
|
|
77
84
|
}
|
|
78
85
|
export function hookReportTaskId(root, provider, event) {
|
|
79
86
|
try {
|
|
@@ -103,11 +110,19 @@ export function startHookReport(root, provider, event) {
|
|
|
103
110
|
// the episode's graph record is written under the first task's id. The key
|
|
104
111
|
// is a hash; the host session identifier itself is still never retained.
|
|
105
112
|
let links = {};
|
|
106
|
-
|
|
107
|
-
|
|
113
|
+
const sessionKey = hookSessionKey(cwd, provider, event);
|
|
114
|
+
if (sessionKey) {
|
|
108
115
|
links = { session_key: sessionKey };
|
|
109
116
|
try {
|
|
110
117
|
const previous = latestSessionTask(root, sessionKey);
|
|
118
|
+
// A host notification (a background command finished) is not new work:
|
|
119
|
+
// it continues the session's latest task instead of opening an empty row,
|
|
120
|
+
// unless the agent already closed that task for good. The alias makes
|
|
121
|
+
// this prompt's Stop and hook observations report to that task.
|
|
122
|
+
if (previous && previous.task_id !== id && isNotificationPrompt(event.prompt) && previous.closed_by !== "agent") {
|
|
123
|
+
aliasReportTask(root, id, previous.task_id);
|
|
124
|
+
return taskInstruction(previous, cwdLiteral);
|
|
125
|
+
}
|
|
111
126
|
const continued = previous && previous.task_id !== id ? continuationLinks(previous) : null;
|
|
112
127
|
if (continued)
|
|
113
128
|
links = { ...links, ...continued };
|
|
@@ -127,8 +142,41 @@ export function startHookReport(root, provider, event) {
|
|
|
127
142
|
throw error;
|
|
128
143
|
task = existing;
|
|
129
144
|
}
|
|
145
|
+
return taskInstruction(task, cwdLiteral);
|
|
146
|
+
}
|
|
147
|
+
function taskInstruction(task, cwdLiteral) {
|
|
130
148
|
return `Hunch has opened this prompt's report: ${task.task_id}. Reuse this exact ID for this prompt. Call hunch_task(action: "start", task_id: "${task.task_id}", title: ${JSON.stringify(task.title)}, cwd: ${cwdLiteral}) to obtain verification_argv; do not create another report. Pass this task_id and cwd: ${cwdLiteral} to hunch_context and decision/correction/finding captures, and pass the same cwd when finishing with hunch_task before responding. A host Stop notice will show the evidence even if no task-linked memory was observed.`;
|
|
131
149
|
}
|
|
150
|
+
/** The session key a hook event maps to: a hash of (root, provider, session,
|
|
151
|
+
* agent), never the identifier itself. Null without a host session. */
|
|
152
|
+
function hookSessionKey(cwd, provider, event) {
|
|
153
|
+
return event.session_id ? reportHash([cwd, provider, event.session_id, event.agent_id ?? null]) : null;
|
|
154
|
+
}
|
|
155
|
+
/** A prompt the host generated to report a background command's completion,
|
|
156
|
+
* not something the user typed. */
|
|
157
|
+
export function isNotificationPrompt(prompt) {
|
|
158
|
+
if (typeof prompt !== "string")
|
|
159
|
+
return false;
|
|
160
|
+
const firstLine = prompt.split(/\r?\n/).map(l => l.trim()).find(l => l.length > 0) ?? "";
|
|
161
|
+
return /^<task-notification>/i.test(firstLine);
|
|
162
|
+
}
|
|
163
|
+
/** Close, as host closes, the tasks of this session that an earlier prompt left
|
|
164
|
+
* open: the prompt was interrupted before its Stop, or a late observation
|
|
165
|
+
* reopened its task. Called when a new prompt starts (`keepNewest`: the new
|
|
166
|
+
* task, or the task a notification turn continues, stays open) and when the
|
|
167
|
+
* current prompt stops. Returns the ids closed here for the caller to persist. */
|
|
168
|
+
export function settleHookSession(root, provider, event, options = {}) {
|
|
169
|
+
const cwd = nativeHookCwd(root, provider, event);
|
|
170
|
+
const key = cwd ? hookSessionKey(cwd, provider, event) : null;
|
|
171
|
+
if (!key)
|
|
172
|
+
return [];
|
|
173
|
+
try {
|
|
174
|
+
return settleSessionTasks(root, key, options);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
132
180
|
/** Stop ends the turn, so the prompt's task closes here as a HOST close: the
|
|
133
181
|
* ledger says the task completed even when the agent never called finish, and
|
|
134
182
|
* a task with observations becomes a graph record without anyone's cooperation.
|
|
@@ -136,30 +184,34 @@ export function startHookReport(root, provider, event) {
|
|
|
136
184
|
* continuation: the next observation reopens the task and the following Stop
|
|
137
185
|
* closes it again (the record is refreshed from the report). An explicit agent
|
|
138
186
|
* finish with any outcome overrides a host close. Pending verification keeps
|
|
139
|
-
* the task open.
|
|
140
|
-
*
|
|
187
|
+
* the task open. Tasks an earlier prompt of the session left open close here
|
|
188
|
+
* too. Returns the ids of the tasks closed after this call, so the caller can
|
|
189
|
+
* persist their records; empty when nothing is closed. */
|
|
141
190
|
export function closeHookTask(root, provider, event) {
|
|
142
191
|
let id;
|
|
143
192
|
try {
|
|
144
193
|
id = identity(root, provider, event);
|
|
145
194
|
}
|
|
146
195
|
catch {
|
|
147
|
-
return
|
|
196
|
+
return [];
|
|
148
197
|
}
|
|
149
198
|
if (!id || id === "legacy")
|
|
150
|
-
return
|
|
199
|
+
return [];
|
|
200
|
+
const closed = [];
|
|
151
201
|
try {
|
|
152
202
|
const task = readTaskReport(root, id).task;
|
|
153
|
-
if (task.state === "interrupted")
|
|
154
|
-
return null;
|
|
155
203
|
if (task.state === "open")
|
|
156
204
|
finishReportTask(root, id, "completed", { by: "host" });
|
|
157
|
-
|
|
205
|
+
if (task.state !== "interrupted")
|
|
206
|
+
closed.push(id);
|
|
158
207
|
}
|
|
159
208
|
catch {
|
|
160
|
-
// No task for this prompt, or verification still running: leave it as it is.
|
|
161
|
-
return null;
|
|
209
|
+
// No task for this prompt (a notification turn), or verification still running: leave it as it is.
|
|
162
210
|
}
|
|
211
|
+
for (const other of settleHookSession(root, provider, event, { keepId: id }))
|
|
212
|
+
if (!closed.includes(other))
|
|
213
|
+
closed.push(other);
|
|
214
|
+
return closed;
|
|
163
215
|
}
|
|
164
216
|
/** A presentation notice never denies Stop or injects another model turn.
|
|
165
217
|
* A prompt with no observation at all prints nothing: the empty task row stays
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
/** Files Hunch regenerates on every capture (src/integrations/providers.ts,
|
|
2
|
+
* claudemd.ts): a fresh mtime on them is the capture, not the task's work. */
|
|
3
|
+
export declare const HUNCH_MANAGED_FILES: ReadonlySet<string>;
|
|
1
4
|
export declare function gitTouchedFiles(root: string, startedAt: string, finishedAt: string | null, options?: {
|
|
2
5
|
limit?: number;
|
|
3
6
|
timeoutMs?: number;
|
|
4
7
|
now?: number;
|
|
8
|
+
workingTree?: boolean;
|
|
5
9
|
}): string[];
|
package/dist/core/taskTouched.js
CHANGED
|
@@ -8,15 +8,26 @@
|
|
|
8
8
|
* never a failed finish):
|
|
9
9
|
* - commits authored by the configured git user whose commit time falls in
|
|
10
10
|
* the task window (merges excluded);
|
|
11
|
-
* - working-tree changes (modified, added, untracked) whose mtime falls in it
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* - working-tree changes (modified, added, untracked) whose mtime falls in it,
|
|
12
|
+
* unless the caller knows another session shared the checkout (`workingTree:
|
|
13
|
+
* false`): mtimes cannot say whose edit it was, commits can.
|
|
14
|
+
* Hunch's own work never counts as the task's: memory and cache paths, commits
|
|
15
|
+
* Hunch makes (`hunch:` subjects — captures, task records, repairs), and the
|
|
16
|
+
* grounding files a capture rewrites (CLAUDE.md, AGENTS.md, the host rule
|
|
17
|
+
* files) when they merely changed in the working tree; a user commit that
|
|
18
|
+
* edits one of those files still counts, as does a delivery that named it.
|
|
19
|
+
* Deleted paths are skipped: nothing dates the deletion. Commit dates get one
|
|
20
|
+
* second of slack (git keeps seconds); working-tree mtimes get none before
|
|
21
|
+
* the start. */
|
|
16
22
|
import { execFileSync } from "node:child_process";
|
|
17
23
|
import { statSync } from "node:fs";
|
|
18
24
|
import { join } from "node:path";
|
|
19
25
|
const EXCLUDED_SEGMENTS = new Set([".hunch", ".hunch-cache", ".git"]);
|
|
26
|
+
/** Files Hunch regenerates on every capture (src/integrations/providers.ts,
|
|
27
|
+
* claudemd.ts): a fresh mtime on them is the capture, not the task's work. */
|
|
28
|
+
export const HUNCH_MANAGED_FILES = new Set(["CLAUDE.md", "AGENTS.md", ".cursor/rules/hunch.mdc", ".github/copilot-instructions.md", ".windsurf/rules/hunch.md"]);
|
|
29
|
+
/** Commit subjects Hunch writes itself. */
|
|
30
|
+
const HUNCH_COMMIT_SUBJECT = /^hunch:/i;
|
|
20
31
|
function gitDate(ms) {
|
|
21
32
|
// Second resolution, a format every git accepts.
|
|
22
33
|
return `${new Date(ms).toISOString().slice(0, 19).replace("T", " ")} +0000`;
|
|
@@ -38,8 +49,9 @@ export function gitTouchedFiles(root, startedAt, finishedAt, options = {}) {
|
|
|
38
49
|
delete env[key];
|
|
39
50
|
const run = (args) => execFileSync("git", ["-C", root, "-c", "core.quotePath=false", ...args], { env, encoding: "utf8", timeout, maxBuffer: 4_000_000, stdio: ["ignore", "pipe", "ignore"] });
|
|
40
51
|
const out = new Set();
|
|
52
|
+
const normalize = (raw) => raw.trim().replace(/\\/g, "/").replace(/^\.\//, "");
|
|
41
53
|
const keep = (raw) => {
|
|
42
|
-
const path = raw
|
|
54
|
+
const path = normalize(raw);
|
|
43
55
|
if (!path || path.split("/").some((segment) => EXCLUDED_SEGMENTS.has(segment)))
|
|
44
56
|
return;
|
|
45
57
|
out.add(path);
|
|
@@ -47,12 +59,20 @@ export function gitTouchedFiles(root, startedAt, finishedAt, options = {}) {
|
|
|
47
59
|
try {
|
|
48
60
|
const email = run(["config", "--get", "user.email"]).trim();
|
|
49
61
|
if (email) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
62
|
+
// One record per commit: a separator, the subject, then the paths.
|
|
63
|
+
const log = run(["log", "--no-merges", "-n", "50", `--since=${gitDate(from)}`, `--until=${gitDate(to)}`, `--author=${email}`, "--format=%x1e%s", "--name-only"]);
|
|
64
|
+
for (const block of log.split("\x1e")) {
|
|
65
|
+
const [subject = "", ...paths] = block.split("\n");
|
|
66
|
+
if (HUNCH_COMMIT_SUBJECT.test(subject.trim()))
|
|
67
|
+
continue;
|
|
68
|
+
for (const line of paths)
|
|
69
|
+
keep(line);
|
|
70
|
+
}
|
|
53
71
|
}
|
|
54
72
|
}
|
|
55
73
|
catch { /* no commits, no git user, or no git: the working tree may still say something */ }
|
|
74
|
+
if (options.workingTree === false)
|
|
75
|
+
return [...out].sort().slice(0, limit);
|
|
56
76
|
try {
|
|
57
77
|
const entries = run(["status", "--porcelain=v1", "-z", "--untracked-files=all"]).split("\0").filter(Boolean);
|
|
58
78
|
for (let i = 0; i < entries.length; i++) {
|
|
@@ -61,7 +81,7 @@ export function gitTouchedFiles(root, startedAt, finishedAt, options = {}) {
|
|
|
61
81
|
// A rename or copy is followed by its original path as a separate entry.
|
|
62
82
|
if (code[0] === "R" || code[0] === "C")
|
|
63
83
|
i++;
|
|
64
|
-
if (code.includes("D") || !path)
|
|
84
|
+
if (code.includes("D") || !path || HUNCH_MANAGED_FILES.has(normalize(path)))
|
|
65
85
|
continue;
|
|
66
86
|
try {
|
|
67
87
|
const mtime = statSync(join(root, path)).mtimeMs;
|
|
@@ -193,4 +193,13 @@ export declare function boundedTaskReportForHost(report: ReturnType<typeof readT
|
|
|
193
193
|
};
|
|
194
194
|
full_report: string;
|
|
195
195
|
};
|
|
196
|
+
/** `metaUrl` is the module running (a `.ts` source checkout needs the tsx
|
|
197
|
+
* loader; a published `.js` build needs nothing) and `resolve` is that
|
|
198
|
+
* module's `import.meta.resolve`. The loader is resolved ONLY on the source
|
|
199
|
+
* path: `import.meta.resolve` throws for a package that is not installed, and
|
|
200
|
+
* `tsx` is a devDependency absent from every published install (#261). */
|
|
201
|
+
export declare function verificationLauncherFor(metaUrl: string, resolve: (specifier: string) => string): {
|
|
202
|
+
argv: string[];
|
|
203
|
+
shell: string;
|
|
204
|
+
};
|
|
196
205
|
export declare function registerTaskReportTools(server: McpServer, getRoot: () => string, getStore: () => HunchStore): void;
|
|
@@ -51,13 +51,21 @@ export function boundedTaskReportForHost(report) {
|
|
|
51
51
|
/** Reuse the MCP server's installation, not a potentially stale global binary.
|
|
52
52
|
* Structured argv is authoritative; the shell hint uses literal quoting. */
|
|
53
53
|
function verificationLauncher() {
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
return verificationLauncherFor(import.meta.url, (specifier) => import.meta.resolve(specifier));
|
|
55
|
+
}
|
|
56
|
+
/** `metaUrl` is the module running (a `.ts` source checkout needs the tsx
|
|
57
|
+
* loader; a published `.js` build needs nothing) and `resolve` is that
|
|
58
|
+
* module's `import.meta.resolve`. The loader is resolved ONLY on the source
|
|
59
|
+
* path: `import.meta.resolve` throws for a package that is not installed, and
|
|
60
|
+
* `tsx` is a devDependency absent from every published install (#261). */
|
|
61
|
+
export function verificationLauncherFor(metaUrl, resolve) {
|
|
62
|
+
const dev = metaUrl.endsWith(".ts");
|
|
63
|
+
const entry = fileURLToPath(new URL(`../cli/index.${dev ? "ts" : "js"}`, metaUrl));
|
|
56
64
|
// `--import` takes a URL. Converting the resolved loader to a path made Node on
|
|
57
65
|
// Windows reject it ("Received protocol 'c:'"), so every verification launched
|
|
58
66
|
// from a source checkout there failed before running and cards showed no check.
|
|
59
|
-
const loader =
|
|
60
|
-
const argv = [process.execPath, ...(
|
|
67
|
+
const loader = dev ? resolve("tsx") : null;
|
|
68
|
+
const argv = [process.execPath, ...(loader ? ["--import", loader.startsWith("file:") ? loader : pathToFileURL(loader).href] : []), entry];
|
|
61
69
|
const quote = (s) => process.platform === "win32" ? `'${s.replace(/'/g, "''")}'` : `'${s.replace(/'/g, "'\\''")}'`;
|
|
62
70
|
return { argv, shell: `${process.platform === "win32" ? "& " : ""}${argv.map(quote).join(" ")}` };
|
|
63
71
|
}
|
|
@@ -91,7 +99,7 @@ export function registerTaskReportTools(server, getRoot, getStore) {
|
|
|
91
99
|
task = readTaskReport(root, task_id, reportSourceSnapshot(root).hash).task;
|
|
92
100
|
}
|
|
93
101
|
const launcher = verificationLauncher();
|
|
94
|
-
return { content: [{ type: "text", text: `Task ${task.task_id} · ${task.state}. Pass task_id to every hunch_context and decision/correction/finding capture call. Before the final response, finish with hunch_task and include its contribution card. For checks use this exact installation (the global hunch binary may be stale): ${launcher.shell} task verify ${task.task_id} -- <command> [arguments]. The default budget is
|
|
102
|
+
return { content: [{ type: "text", text: `Task ${task.task_id} · ${task.state}. Pass task_id to every hunch_context and decision/correction/finding capture call. Before the final response, finish with hunch_task and include its contribution card. For checks use this exact installation (the global hunch binary may be stale): ${launcher.shell} task verify ${task.task_id} -- <command> [arguments]. The default budget is 15 minutes; add --timeout <seconds> before -- for a longer suite.` }], structuredContent: { task, verification_argv: [...launcher.argv, "task", "verify", task.task_id, "--"] } };
|
|
95
103
|
}
|
|
96
104
|
if (!task_id)
|
|
97
105
|
throw new Error("finish requires the exact task_id");
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
9
|
"websiteUrl": "https://www.hunchmemory.com",
|
|
10
|
-
"version": "1.38.
|
|
10
|
+
"version": "1.38.1",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "@davesheffer/hunch",
|
|
16
|
-
"version": "1.38.
|
|
16
|
+
"version": "1.38.1",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|