@davesheffer/hunch 1.33.0 → 1.35.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/README.md +9 -1
- package/dist/cli/index.js +50 -6
- package/dist/cli/invocation.d.ts +8 -0
- package/dist/cli/invocation.js +17 -10
- package/dist/cli/taskReport.js +52 -4
- package/dist/cli/update.js +5 -5
- package/dist/client/state.d.ts +4 -4
- package/dist/constitution/schema.d.ts +12 -12
- package/dist/core/stateContract.d.ts +3 -3
- package/dist/core/taskRecord.d.ts +39 -0
- package/dist/core/taskRecord.js +185 -0
- package/dist/core/taskReport.d.ts +8 -1
- package/dist/core/taskReport.js +28 -14
- package/dist/core/taskReportEvidence.js +2 -1
- package/dist/core/taskReportHook.d.ts +18 -3
- package/dist/core/taskReportHook.js +77 -8
- package/dist/core/taskReportPaths.d.ts +6 -0
- package/dist/core/taskReportPaths.js +13 -0
- package/dist/core/types.d.ts +176 -1
- package/dist/core/types.js +40 -1
- package/dist/core/updatecheck.d.ts +51 -0
- package/dist/core/updatecheck.js +266 -0
- package/dist/core/version.d.ts +2 -0
- package/dist/core/version.js +3 -1
- package/dist/integrations/gitignore.js +1 -0
- package/dist/integrations/health.js +27 -2
- package/dist/mcp/server.js +5 -1
- package/dist/mcp/taskReportTools.d.ts +4 -4
- package/dist/mcp/taskReportTools.js +32 -3
- package/dist/store/hunchStore.d.ts +5 -1
- package/dist/store/hunchStore.js +18 -0
- package/dist/taskReports.d.ts +1 -1
- package/dist/taskReports.js +16 -4
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/core/types.d.ts
CHANGED
|
@@ -578,6 +578,96 @@ export declare const FindingSchema: z.ZodObject<{
|
|
|
578
578
|
}, z.core.$strip>;
|
|
579
579
|
}, z.core.$strip>;
|
|
580
580
|
export type Finding = z.infer<typeof FindingSchema>;
|
|
581
|
+
/** A finished agent task as durable graph memory: what Hunch delivered, what the
|
|
582
|
+
* agent says it applied, what it saved and checked, and which files it touched.
|
|
583
|
+
* Written automatically when a task finishes with at least one observation; the
|
|
584
|
+
* raw observation ledger (.hunch-cache/served.db) stays machine-local. Titles are
|
|
585
|
+
* the only prose; no prompt text, transcript, or private context payload is kept. */
|
|
586
|
+
export declare const TaskRecordSchema: z.ZodObject<{
|
|
587
|
+
visibility: z.ZodOptional<z.ZodObject<{
|
|
588
|
+
owner: z.ZodString;
|
|
589
|
+
readers: z.ZodArray<z.ZodString>;
|
|
590
|
+
writers: z.ZodArray<z.ZodString>;
|
|
591
|
+
}, z.core.$strict>>;
|
|
592
|
+
id: z.ZodString;
|
|
593
|
+
title: z.ZodString;
|
|
594
|
+
state: z.ZodEnum<{
|
|
595
|
+
completed: "completed";
|
|
596
|
+
interrupted: "interrupted";
|
|
597
|
+
}>;
|
|
598
|
+
started_at: z.ZodString;
|
|
599
|
+
finished_at: z.ZodString;
|
|
600
|
+
coverage: z.ZodEnum<{
|
|
601
|
+
"no-delivery-observed": "no-delivery-observed";
|
|
602
|
+
"no-relevant-memory": "no-relevant-memory";
|
|
603
|
+
delivered: "delivered";
|
|
604
|
+
}>;
|
|
605
|
+
lessons: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
606
|
+
kind: z.ZodString;
|
|
607
|
+
record_id: z.ZodString;
|
|
608
|
+
content_hash: z.ZodString;
|
|
609
|
+
title: z.ZodString;
|
|
610
|
+
}, z.core.$strip>>>;
|
|
611
|
+
applied: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
612
|
+
record_id: z.ZodString;
|
|
613
|
+
content_hash: z.ZodString;
|
|
614
|
+
action: z.ZodString;
|
|
615
|
+
supported_by: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
|
616
|
+
}, z.core.$strip>>>;
|
|
617
|
+
saved: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
618
|
+
kind: z.ZodString;
|
|
619
|
+
record_id: z.ZodString;
|
|
620
|
+
content_hash: z.ZodString;
|
|
621
|
+
home: z.ZodEnum<{
|
|
622
|
+
public: "public";
|
|
623
|
+
private: "private";
|
|
624
|
+
}>;
|
|
625
|
+
operation: z.ZodEnum<{
|
|
626
|
+
updated: "updated";
|
|
627
|
+
created: "created";
|
|
628
|
+
}>;
|
|
629
|
+
durability: z.ZodEnum<{
|
|
630
|
+
local: "local";
|
|
631
|
+
committed: "committed";
|
|
632
|
+
pushed: "pushed";
|
|
633
|
+
}>;
|
|
634
|
+
}, z.core.$strip>>>;
|
|
635
|
+
checks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
636
|
+
label: z.ZodString;
|
|
637
|
+
state: z.ZodEnum<{
|
|
638
|
+
passed: "passed";
|
|
639
|
+
failed: "failed";
|
|
640
|
+
cancelled: "cancelled";
|
|
641
|
+
"timed out": "timed out";
|
|
642
|
+
}>;
|
|
643
|
+
exit_code: z.ZodNullable<z.ZodNumber>;
|
|
644
|
+
}, z.core.$strip>>>;
|
|
645
|
+
conformance: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
646
|
+
kind: z.ZodEnum<{
|
|
647
|
+
decisions: "decisions";
|
|
648
|
+
constraints: "constraints";
|
|
649
|
+
}>;
|
|
650
|
+
record_id: z.ZodString;
|
|
651
|
+
content_hash: z.ZodString;
|
|
652
|
+
outcome: z.ZodEnum<{
|
|
653
|
+
satisfied: "satisfied";
|
|
654
|
+
violated: "violated";
|
|
655
|
+
"not-exercised": "not-exercised";
|
|
656
|
+
unavailable: "unavailable";
|
|
657
|
+
}>;
|
|
658
|
+
}, z.core.$strip>>>;
|
|
659
|
+
refusals: z.ZodDefault<z.ZodNumber>;
|
|
660
|
+
files: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
661
|
+
source_snapshot: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
|
662
|
+
report_hash: z.ZodString;
|
|
663
|
+
provenance: z.ZodObject<{
|
|
664
|
+
source: z.ZodString;
|
|
665
|
+
confidence: z.ZodNumber;
|
|
666
|
+
evidence: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
667
|
+
last_verified: z.ZodOptional<z.ZodString>;
|
|
668
|
+
}, z.core.$strip>;
|
|
669
|
+
}, z.core.$strip>;
|
|
670
|
+
export type TaskRecord = z.infer<typeof TaskRecordSchema>;
|
|
581
671
|
export declare const LANDSCAPE_DRIFT_CANDIDATE_SCHEMA_VERSION: "hunch.landscape-drift-candidate/1";
|
|
582
672
|
/**
|
|
583
673
|
* An external observer's content-addressed mismatch claim. It is intake evidence
|
|
@@ -610,7 +700,7 @@ export declare function assertLandscapeDriftCandidate(value: unknown): asserts v
|
|
|
610
700
|
/** Convert one valid external observation into advisory Hunch memory, never graph authority. */
|
|
611
701
|
export declare function landscapeDriftCandidateFinding(value: unknown): Finding;
|
|
612
702
|
/** The entity collections, keyed by their on-disk directory name. */
|
|
613
|
-
export declare const ENTITY_KINDS: readonly ["components", "resources", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks", "findings", "receipts", "commitments", "derived", "entities", "relationships", "conventions"];
|
|
703
|
+
export declare const ENTITY_KINDS: readonly ["components", "resources", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks", "findings", "receipts", "commitments", "derived", "entities", "relationships", "conventions", "tasks"];
|
|
614
704
|
export type EntityKind = (typeof ENTITY_KINDS)[number];
|
|
615
705
|
export declare const SCHEMAS: {
|
|
616
706
|
readonly components: z.ZodObject<{
|
|
@@ -1358,6 +1448,90 @@ export declare const SCHEMAS: {
|
|
|
1358
1448
|
last_verified: z.ZodOptional<z.ZodString>;
|
|
1359
1449
|
}, z.core.$strip>;
|
|
1360
1450
|
}, z.core.$strict>;
|
|
1451
|
+
readonly tasks: z.ZodObject<{
|
|
1452
|
+
visibility: z.ZodOptional<z.ZodObject<{
|
|
1453
|
+
owner: z.ZodString;
|
|
1454
|
+
readers: z.ZodArray<z.ZodString>;
|
|
1455
|
+
writers: z.ZodArray<z.ZodString>;
|
|
1456
|
+
}, z.core.$strict>>;
|
|
1457
|
+
id: z.ZodString;
|
|
1458
|
+
title: z.ZodString;
|
|
1459
|
+
state: z.ZodEnum<{
|
|
1460
|
+
completed: "completed";
|
|
1461
|
+
interrupted: "interrupted";
|
|
1462
|
+
}>;
|
|
1463
|
+
started_at: z.ZodString;
|
|
1464
|
+
finished_at: z.ZodString;
|
|
1465
|
+
coverage: z.ZodEnum<{
|
|
1466
|
+
"no-delivery-observed": "no-delivery-observed";
|
|
1467
|
+
"no-relevant-memory": "no-relevant-memory";
|
|
1468
|
+
delivered: "delivered";
|
|
1469
|
+
}>;
|
|
1470
|
+
lessons: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
1471
|
+
kind: z.ZodString;
|
|
1472
|
+
record_id: z.ZodString;
|
|
1473
|
+
content_hash: z.ZodString;
|
|
1474
|
+
title: z.ZodString;
|
|
1475
|
+
}, z.core.$strip>>>;
|
|
1476
|
+
applied: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
1477
|
+
record_id: z.ZodString;
|
|
1478
|
+
content_hash: z.ZodString;
|
|
1479
|
+
action: z.ZodString;
|
|
1480
|
+
supported_by: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
|
1481
|
+
}, z.core.$strip>>>;
|
|
1482
|
+
saved: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
1483
|
+
kind: z.ZodString;
|
|
1484
|
+
record_id: z.ZodString;
|
|
1485
|
+
content_hash: z.ZodString;
|
|
1486
|
+
home: z.ZodEnum<{
|
|
1487
|
+
public: "public";
|
|
1488
|
+
private: "private";
|
|
1489
|
+
}>;
|
|
1490
|
+
operation: z.ZodEnum<{
|
|
1491
|
+
updated: "updated";
|
|
1492
|
+
created: "created";
|
|
1493
|
+
}>;
|
|
1494
|
+
durability: z.ZodEnum<{
|
|
1495
|
+
local: "local";
|
|
1496
|
+
committed: "committed";
|
|
1497
|
+
pushed: "pushed";
|
|
1498
|
+
}>;
|
|
1499
|
+
}, z.core.$strip>>>;
|
|
1500
|
+
checks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
1501
|
+
label: z.ZodString;
|
|
1502
|
+
state: z.ZodEnum<{
|
|
1503
|
+
passed: "passed";
|
|
1504
|
+
failed: "failed";
|
|
1505
|
+
cancelled: "cancelled";
|
|
1506
|
+
"timed out": "timed out";
|
|
1507
|
+
}>;
|
|
1508
|
+
exit_code: z.ZodNullable<z.ZodNumber>;
|
|
1509
|
+
}, z.core.$strip>>>;
|
|
1510
|
+
conformance: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
1511
|
+
kind: z.ZodEnum<{
|
|
1512
|
+
decisions: "decisions";
|
|
1513
|
+
constraints: "constraints";
|
|
1514
|
+
}>;
|
|
1515
|
+
record_id: z.ZodString;
|
|
1516
|
+
content_hash: z.ZodString;
|
|
1517
|
+
outcome: z.ZodEnum<{
|
|
1518
|
+
satisfied: "satisfied";
|
|
1519
|
+
violated: "violated";
|
|
1520
|
+
"not-exercised": "not-exercised";
|
|
1521
|
+
unavailable: "unavailable";
|
|
1522
|
+
}>;
|
|
1523
|
+
}, z.core.$strip>>>;
|
|
1524
|
+
refusals: z.ZodDefault<z.ZodNumber>;
|
|
1525
|
+
files: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
1526
|
+
source_snapshot: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
|
1527
|
+
report_hash: z.ZodString;
|
|
1528
|
+
provenance: z.ZodObject<{
|
|
1529
|
+
source: z.ZodString;
|
|
1530
|
+
confidence: z.ZodNumber;
|
|
1531
|
+
evidence: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
1532
|
+
last_verified: z.ZodOptional<z.ZodString>;
|
|
1533
|
+
}, z.core.$strip>;
|
|
1534
|
+
}, z.core.$strip>;
|
|
1361
1535
|
};
|
|
1362
1536
|
export type EntityFor = {
|
|
1363
1537
|
components: Component;
|
|
@@ -1375,6 +1549,7 @@ export type EntityFor = {
|
|
|
1375
1549
|
derived: DerivedState;
|
|
1376
1550
|
entities: ExternalEntity;
|
|
1377
1551
|
relationships: StateRelationship;
|
|
1552
|
+
tasks: TaskRecord;
|
|
1378
1553
|
};
|
|
1379
1554
|
/** Default provenance helper for deterministic (extracted) records. */
|
|
1380
1555
|
export declare function extracted(confidence: number, evidence?: string[]): Provenance;
|
package/dist/core/types.js
CHANGED
|
@@ -468,6 +468,44 @@ export const FindingSchema = z.object({
|
|
|
468
468
|
resolved_commit: z.string().nullable().default(null).describe("the commit that fixed it (set when triage becomes resolved)"),
|
|
469
469
|
provenance: ProvenanceSchema,
|
|
470
470
|
});
|
|
471
|
+
/** A finished agent task as durable graph memory: what Hunch delivered, what the
|
|
472
|
+
* agent says it applied, what it saved and checked, and which files it touched.
|
|
473
|
+
* Written automatically when a task finishes with at least one observation; the
|
|
474
|
+
* raw observation ledger (.hunch-cache/served.db) stays machine-local. Titles are
|
|
475
|
+
* the only prose; no prompt text, transcript, or private context payload is kept. */
|
|
476
|
+
export const TaskRecordSchema = z.object({
|
|
477
|
+
visibility: RecordVisibilitySchema.optional(),
|
|
478
|
+
id: z.string().describe("htask_*"),
|
|
479
|
+
title: z.string(),
|
|
480
|
+
state: z.enum(["completed", "interrupted"]),
|
|
481
|
+
started_at: z.string(),
|
|
482
|
+
finished_at: z.string(),
|
|
483
|
+
coverage: z.enum(["no-delivery-observed", "no-relevant-memory", "delivered"]),
|
|
484
|
+
lessons: z.array(z.object({
|
|
485
|
+
kind: z.string(), record_id: z.string(), content_hash: z.string(), title: z.string(),
|
|
486
|
+
})).default([]).describe("exact record revisions Hunch delivered to the agent"),
|
|
487
|
+
applied: z.array(z.object({
|
|
488
|
+
record_id: z.string(), content_hash: z.string(), action: z.string(),
|
|
489
|
+
supported_by: z.string().nullable().default(null),
|
|
490
|
+
})).default([]).describe("agent-reported applications; supported_by names Hunch's own rule evaluation when one held"),
|
|
491
|
+
saved: z.array(z.object({
|
|
492
|
+
kind: z.string(), record_id: z.string(), content_hash: z.string(),
|
|
493
|
+
home: z.enum(["public", "private"]), operation: z.enum(["created", "updated"]),
|
|
494
|
+
durability: z.enum(["local", "committed", "pushed"]),
|
|
495
|
+
})).default([]).describe("memory the task wrote, with its actual home and proven durability"),
|
|
496
|
+
checks: z.array(z.object({
|
|
497
|
+
label: z.string(), state: z.enum(["passed", "failed", "timed out", "cancelled"]), exit_code: z.number().int().nullable(),
|
|
498
|
+
})).default([]).describe("independently observed command results (hunch task verify)"),
|
|
499
|
+
conformance: z.array(z.object({
|
|
500
|
+
kind: z.enum(["constraints", "decisions"]), record_id: z.string(), content_hash: z.string(),
|
|
501
|
+
outcome: z.enum(["satisfied", "violated", "not-exercised", "unavailable"]),
|
|
502
|
+
})).default([]).describe("Hunch's deterministic evaluation of each delivered rule against the changed files"),
|
|
503
|
+
refusals: z.number().int().nonnegative().default(0).describe("edits the native gate denied during the task"),
|
|
504
|
+
files: z.array(z.string()).default([]).describe("files the task touched: delivery targets, rule-checked changes, denied edits"),
|
|
505
|
+
source_snapshot: z.string().nullable().default(null).describe("bounded source snapshot hash at the last check, when one ran"),
|
|
506
|
+
report_hash: z.string().describe("content hash of the full local report this record summarizes"),
|
|
507
|
+
provenance: ProvenanceSchema,
|
|
508
|
+
});
|
|
471
509
|
export const LANDSCAPE_DRIFT_CANDIDATE_SCHEMA_VERSION = "hunch.landscape-drift-candidate/1";
|
|
472
510
|
const LANDSCAPE_DRIFT_HASH = /^sha256:[a-f0-9]{64}$/;
|
|
473
511
|
const LANDSCAPE_DRIFT_RECEIPT_ID = /^[a-z][a-z0-9_:-]{2,127}$/;
|
|
@@ -599,7 +637,7 @@ export function landscapeDriftCandidateFinding(value) {
|
|
|
599
637
|
// loads exactly as before, and an older build ignores directories it does not know.
|
|
600
638
|
export const ENTITY_KINDS = [
|
|
601
639
|
"components", "resources", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks", "findings",
|
|
602
|
-
"receipts", "commitments", "derived", "entities", "relationships", "conventions",
|
|
640
|
+
"receipts", "commitments", "derived", "entities", "relationships", "conventions", "tasks",
|
|
603
641
|
];
|
|
604
642
|
export const SCHEMAS = {
|
|
605
643
|
components: ComponentSchema,
|
|
@@ -617,6 +655,7 @@ export const SCHEMAS = {
|
|
|
617
655
|
derived: DerivedStateSchema,
|
|
618
656
|
entities: ExternalEntitySchema,
|
|
619
657
|
relationships: StateRelationshipSchema,
|
|
658
|
+
tasks: TaskRecordSchema,
|
|
620
659
|
};
|
|
621
660
|
/** Default provenance helper for deterministic (extracted) records. */
|
|
622
661
|
export function extracted(confidence, evidence = []) {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Best-effort update notices for interactive CLI commands.
|
|
2
|
+
*
|
|
3
|
+
* The foreground process only reads a small cache and starts an unreferenced,
|
|
4
|
+
* detached worker when that cache is stale. Network I/O happens in the worker,
|
|
5
|
+
* so an unavailable registry cannot hold the user's command open. The first
|
|
6
|
+
* cold invocation populates the cache; later invocations can display it.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
export interface UpdateCheckResult {
|
|
10
|
+
current: string;
|
|
11
|
+
latest: string;
|
|
12
|
+
}
|
|
13
|
+
export interface UpdateCachePathOptions {
|
|
14
|
+
env?: NodeJS.ProcessEnv;
|
|
15
|
+
home?: string;
|
|
16
|
+
platform?: NodeJS.Platform;
|
|
17
|
+
}
|
|
18
|
+
/** Use each platform's normal per-user cache root. This deliberately never
|
|
19
|
+
* creates a `.hunch` path segment: `.hunch` is the repository marker used by
|
|
20
|
+
* findRoot(), so placing a cache there could redirect commands to the wrong
|
|
21
|
+
* project. */
|
|
22
|
+
export declare function defaultCacheFile(opts?: UpdateCachePathOptions): string;
|
|
23
|
+
/** Full SemVer precedence for the release and prerelease fields. */
|
|
24
|
+
export declare function isNewerVersion(candidate: string, current: string): boolean;
|
|
25
|
+
export interface RefreshUpdateCacheOptions {
|
|
26
|
+
cacheFile?: string;
|
|
27
|
+
fetchImpl?: typeof fetch;
|
|
28
|
+
now?: () => number;
|
|
29
|
+
}
|
|
30
|
+
/** Worker entry point, exported so network/cache behavior is testable without
|
|
31
|
+
* spawning a process or contacting the real registry. */
|
|
32
|
+
export declare function refreshUpdateCache(opts?: RefreshUpdateCacheOptions): Promise<boolean>;
|
|
33
|
+
export interface ScheduleUpdateCheckOptions {
|
|
34
|
+
cacheFile?: string;
|
|
35
|
+
currentVersion?: string;
|
|
36
|
+
now?: () => number;
|
|
37
|
+
workerFile?: string;
|
|
38
|
+
spawnImpl?: typeof spawn;
|
|
39
|
+
}
|
|
40
|
+
/** Read any known update immediately and schedule at most one refresh per day.
|
|
41
|
+
* Claiming the interval before spawning also bounds offline requests: failed
|
|
42
|
+
* checks are not retried on every CLI command. */
|
|
43
|
+
export declare function scheduleUpdateCheck(opts?: ScheduleUpdateCheckOptions): UpdateCheckResult | null;
|
|
44
|
+
export interface UpdateCheckGateOptions {
|
|
45
|
+
commandName: string;
|
|
46
|
+
isTTY: boolean;
|
|
47
|
+
installed: boolean;
|
|
48
|
+
env?: NodeJS.ProcessEnv;
|
|
49
|
+
}
|
|
50
|
+
export declare function shouldCheckForUpdate({ commandName, isTTY, installed, env }: UpdateCheckGateOptions): boolean;
|
|
51
|
+
export declare function formatUpdateNotice(result: UpdateCheckResult): string;
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/** Best-effort update notices for interactive CLI commands.
|
|
2
|
+
*
|
|
3
|
+
* The foreground process only reads a small cache and starts an unreferenced,
|
|
4
|
+
* detached worker when that cache is stale. Network I/O happens in the worker,
|
|
5
|
+
* so an unavailable registry cannot hold the user's command open. The first
|
|
6
|
+
* cold invocation populates the cache; later invocations can display it.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { basename, dirname, isAbsolute, join, resolve, win32 } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { HUNCH_PACKAGE_NAME, HUNCH_VERSION } from "./version.js";
|
|
15
|
+
const REGISTRY_URL = `https://registry.npmjs.org/${HUNCH_PACKAGE_NAME}/latest`;
|
|
16
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
const FETCH_TIMEOUT_MS = 1500;
|
|
18
|
+
const WORKER_FLAG = "--hunch-refresh-update-cache";
|
|
19
|
+
const MAX_CACHE_BYTES = 4096;
|
|
20
|
+
function configuredCacheRoot(value, platform) {
|
|
21
|
+
if (!value)
|
|
22
|
+
return null;
|
|
23
|
+
const absolute = platform === "win32" ? win32.isAbsolute(value) : isAbsolute(value);
|
|
24
|
+
const containsMarker = value.replace(/\\/g, "/").split("/").some(part => {
|
|
25
|
+
// Win32 aliases path components with trailing spaces or periods to the
|
|
26
|
+
// unadorned name, so `.hunch.` and `.hunch ` can address `.hunch` too.
|
|
27
|
+
const normalized = platform === "win32" ? part.replace(/[ .]+$/g, "") : part;
|
|
28
|
+
return normalized.toLowerCase() === ".hunch";
|
|
29
|
+
});
|
|
30
|
+
if (!absolute || containsMarker)
|
|
31
|
+
return null;
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
/** Use each platform's normal per-user cache root. This deliberately never
|
|
35
|
+
* creates a `.hunch` path segment: `.hunch` is the repository marker used by
|
|
36
|
+
* findRoot(), so placing a cache there could redirect commands to the wrong
|
|
37
|
+
* project. */
|
|
38
|
+
export function defaultCacheFile(opts = {}) {
|
|
39
|
+
const env = opts.env ?? process.env;
|
|
40
|
+
const home = opts.home ?? homedir();
|
|
41
|
+
const platform = opts.platform ?? process.platform;
|
|
42
|
+
const cacheHome = configuredCacheRoot(env.XDG_CACHE_HOME, platform)
|
|
43
|
+
|| (platform === "win32" && configuredCacheRoot(env.LOCALAPPDATA, platform))
|
|
44
|
+
|| (platform === "darwin" ? join(home, "Library", "Caches") : join(home, ".cache"));
|
|
45
|
+
return join(cacheHome, "hunch", "update-check.json");
|
|
46
|
+
}
|
|
47
|
+
function readCache(file) {
|
|
48
|
+
try {
|
|
49
|
+
const stat = lstatSync(file);
|
|
50
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_CACHE_BYTES)
|
|
51
|
+
return null;
|
|
52
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
53
|
+
if (!Number.isFinite(parsed.lastCheckedAt) || (parsed.lastCheckedAt ?? -1) < 0)
|
|
54
|
+
return null;
|
|
55
|
+
if (parsed.latestSeen !== undefined && parseVersion(parsed.latestSeen) === null)
|
|
56
|
+
return null;
|
|
57
|
+
return { lastCheckedAt: parsed.lastCheckedAt, ...(parsed.latestSeen ? { latestSeen: parsed.latestSeen } : {}) };
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Cache writes are atomic even though this cache is derived and disposable:
|
|
64
|
+
* concurrent CLI processes must not leave malformed JSON that causes every
|
|
65
|
+
* subsequent command to schedule another network request. */
|
|
66
|
+
function writeCache(file, cache) {
|
|
67
|
+
const temp = join(dirname(file), `.${basename(file)}.${process.pid}.${randomUUID()}.tmp`);
|
|
68
|
+
try {
|
|
69
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
70
|
+
writeFileSync(temp, `${JSON.stringify(cache)}\n`, { mode: 0o600 });
|
|
71
|
+
renameSync(temp, file);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
try {
|
|
76
|
+
unlinkSync(temp);
|
|
77
|
+
}
|
|
78
|
+
catch { /* best-effort cleanup */ }
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Strict SemVer parsing keeps untrusted registry text out of terminal output. */
|
|
83
|
+
function parseVersion(version) {
|
|
84
|
+
if (version.length > 256)
|
|
85
|
+
return null;
|
|
86
|
+
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(version);
|
|
87
|
+
if (!match)
|
|
88
|
+
return null;
|
|
89
|
+
const prerelease = match[4]?.split(".") ?? null;
|
|
90
|
+
if (prerelease?.some(part => /^\d+$/.test(part) && part.length > 1 && part.startsWith("0")))
|
|
91
|
+
return null;
|
|
92
|
+
return {
|
|
93
|
+
core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])],
|
|
94
|
+
prerelease,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** Full SemVer precedence for the release and prerelease fields. */
|
|
98
|
+
export function isNewerVersion(candidate, current) {
|
|
99
|
+
const a = parseVersion(candidate);
|
|
100
|
+
const b = parseVersion(current);
|
|
101
|
+
if (!a || !b)
|
|
102
|
+
return false;
|
|
103
|
+
for (let i = 0; i < a.core.length; i++) {
|
|
104
|
+
if (a.core[i] !== b.core[i])
|
|
105
|
+
return a.core[i] > b.core[i];
|
|
106
|
+
}
|
|
107
|
+
if (a.prerelease === null || b.prerelease === null)
|
|
108
|
+
return a.prerelease === null && b.prerelease !== null;
|
|
109
|
+
for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i++) {
|
|
110
|
+
const left = a.prerelease[i];
|
|
111
|
+
const right = b.prerelease[i];
|
|
112
|
+
if (left === undefined || right === undefined)
|
|
113
|
+
return right === undefined;
|
|
114
|
+
if (left === right)
|
|
115
|
+
continue;
|
|
116
|
+
const leftNumeric = /^\d+$/.test(left);
|
|
117
|
+
const rightNumeric = /^\d+$/.test(right);
|
|
118
|
+
if (leftNumeric && rightNumeric)
|
|
119
|
+
return BigInt(left) > BigInt(right);
|
|
120
|
+
if (leftNumeric !== rightNumeric)
|
|
121
|
+
return !leftNumeric;
|
|
122
|
+
return left > right;
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
async function fetchLatestVersion(fetchImpl) {
|
|
127
|
+
try {
|
|
128
|
+
const response = await fetchImpl(REGISTRY_URL, {
|
|
129
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
130
|
+
redirect: "error",
|
|
131
|
+
});
|
|
132
|
+
if (!response.ok)
|
|
133
|
+
return null;
|
|
134
|
+
const body = (await response.json());
|
|
135
|
+
return typeof body.version === "string" && parseVersion(body.version) ? body.version : null;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Worker entry point, exported so network/cache behavior is testable without
|
|
142
|
+
* spawning a process or contacting the real registry. */
|
|
143
|
+
export async function refreshUpdateCache(opts = {}) {
|
|
144
|
+
const cacheFile = opts.cacheFile ?? defaultCacheFile();
|
|
145
|
+
const latest = await fetchLatestVersion(opts.fetchImpl ?? fetch);
|
|
146
|
+
if (latest === null)
|
|
147
|
+
return false;
|
|
148
|
+
return writeCache(cacheFile, { lastCheckedAt: (opts.now ?? Date.now)(), latestSeen: latest });
|
|
149
|
+
}
|
|
150
|
+
function cacheIsFresh(cache, now) {
|
|
151
|
+
return cache !== null && cache.lastCheckedAt <= now && now - cache.lastCheckedAt < CHECK_INTERVAL_MS;
|
|
152
|
+
}
|
|
153
|
+
/** Serialize the short cache claim across simultaneous CLI processes. This
|
|
154
|
+
* advisory fails closed when a prior process left the lock behind: reclaiming
|
|
155
|
+
* a pathname without an OS lock cannot distinguish that stale file from a new
|
|
156
|
+
* owner's lock on both POSIX and Windows. */
|
|
157
|
+
function acquireRefreshLock(cacheFile, now) {
|
|
158
|
+
const lockFile = `${cacheFile}.lock`;
|
|
159
|
+
try {
|
|
160
|
+
mkdirSync(dirname(cacheFile), { recursive: true });
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
writeFileSync(lockFile, `${now}\n`, { flag: "wx", mode: 0o600 });
|
|
167
|
+
return lockFile;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** Read any known update immediately and schedule at most one refresh per day.
|
|
174
|
+
* Claiming the interval before spawning also bounds offline requests: failed
|
|
175
|
+
* checks are not retried on every CLI command. */
|
|
176
|
+
export function scheduleUpdateCheck(opts = {}) {
|
|
177
|
+
try {
|
|
178
|
+
const cacheFile = opts.cacheFile ?? defaultCacheFile();
|
|
179
|
+
const currentVersion = opts.currentVersion ?? HUNCH_VERSION;
|
|
180
|
+
const now = (opts.now ?? Date.now)();
|
|
181
|
+
const cache = readCache(cacheFile);
|
|
182
|
+
const notice = cache?.latestSeen && isNewerVersion(cache.latestSeen, currentVersion)
|
|
183
|
+
? { current: currentVersion, latest: cache.latestSeen }
|
|
184
|
+
: null;
|
|
185
|
+
if (cacheIsFresh(cache, now))
|
|
186
|
+
return notice;
|
|
187
|
+
const lockFile = acquireRefreshLock(cacheFile, now);
|
|
188
|
+
if (!lockFile)
|
|
189
|
+
return notice;
|
|
190
|
+
let claimed = false;
|
|
191
|
+
try {
|
|
192
|
+
// Another process may have refreshed while this one was acquiring the
|
|
193
|
+
// claim. Re-read under the lock before deciding to schedule a worker.
|
|
194
|
+
const current = readCache(cacheFile);
|
|
195
|
+
if (cacheIsFresh(current, now))
|
|
196
|
+
return notice;
|
|
197
|
+
// If the cache cannot record the claim, skip the request. Otherwise a
|
|
198
|
+
// read-only/misconfigured cache directory would trigger a request forever.
|
|
199
|
+
claimed = writeCache(cacheFile, { lastCheckedAt: now, ...(current?.latestSeen ? { latestSeen: current.latestSeen } : {}) });
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
try {
|
|
203
|
+
unlinkSync(lockFile);
|
|
204
|
+
}
|
|
205
|
+
catch { /* derived lock cleanup is best effort */ }
|
|
206
|
+
}
|
|
207
|
+
if (!claimed)
|
|
208
|
+
return notice;
|
|
209
|
+
const workerFile = opts.workerFile ?? fileURLToPath(import.meta.url);
|
|
210
|
+
try {
|
|
211
|
+
const child = (opts.spawnImpl ?? spawn)(process.execPath, [workerFile, WORKER_FLAG, cacheFile], {
|
|
212
|
+
detached: true,
|
|
213
|
+
stdio: "ignore",
|
|
214
|
+
windowsHide: true,
|
|
215
|
+
});
|
|
216
|
+
child.once?.("error", () => { });
|
|
217
|
+
child.unref();
|
|
218
|
+
}
|
|
219
|
+
catch { /* the cached notice remains useful even when refresh cannot start */ }
|
|
220
|
+
return notice;
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** Commands used by hooks, servers, CI, or the updater itself must never start
|
|
227
|
+
* an advisory worker, even when they inherit a terminal. */
|
|
228
|
+
const PLUMBING_COMMANDS = new Set([
|
|
229
|
+
"mcp",
|
|
230
|
+
"check",
|
|
231
|
+
"merge-driver",
|
|
232
|
+
"merge-driver-grounding",
|
|
233
|
+
"sync",
|
|
234
|
+
"repair-provenance",
|
|
235
|
+
"hook",
|
|
236
|
+
"ci",
|
|
237
|
+
"serve",
|
|
238
|
+
"update",
|
|
239
|
+
]);
|
|
240
|
+
export function shouldCheckForUpdate({ commandName, isTTY, installed, env = process.env }) {
|
|
241
|
+
if (PLUMBING_COMMANDS.has(commandName) || /^(?:task|integrations|serve)\s/.test(commandName) || !isTTY || !installed)
|
|
242
|
+
return false;
|
|
243
|
+
return !env.CI && !env.HUNCH_NO_UPDATE_CHECK && !env.NO_UPDATE_NOTIFIER;
|
|
244
|
+
}
|
|
245
|
+
export function formatUpdateNotice(result) {
|
|
246
|
+
return (`A newer Hunch version is available: ${result.current} -> ${result.latest}\n` +
|
|
247
|
+
"Run `hunch update` in the repository (`hunch update --global` if this CLI is global). " +
|
|
248
|
+
"Set HUNCH_NO_UPDATE_CHECK=1 to stop checking.");
|
|
249
|
+
}
|
|
250
|
+
function isWorkerInvocation() {
|
|
251
|
+
if (process.argv[2] !== WORKER_FLAG || !process.argv[1])
|
|
252
|
+
return false;
|
|
253
|
+
try {
|
|
254
|
+
return resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (isWorkerInvocation()) {
|
|
261
|
+
// This process is detached from the user's command. All errors are local to
|
|
262
|
+
// the derived cache and intentionally produce neither output nor a nonzero
|
|
263
|
+
// exit that could be mistaken for the command's result.
|
|
264
|
+
void refreshUpdateCache({ cacheFile: process.argv[3] }).catch(() => { });
|
|
265
|
+
}
|
|
266
|
+
//# sourceMappingURL=updatecheck.js.map
|
package/dist/core/version.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export declare const HUNCH_VERSION: string;
|
|
2
|
+
/** Shared identity for Hunch's published package. */
|
|
3
|
+
export declare const HUNCH_PACKAGE_NAME = "@davesheffer/hunch";
|
|
2
4
|
/** Exact public npm package consumed by generated CI and shared MCP/provider
|
|
3
5
|
* configs. A floating package name would let one committed configuration run
|
|
4
6
|
* different Hunch semantics as npm's latest release changes. */
|
package/dist/core/version.js
CHANGED
|
@@ -16,10 +16,12 @@ export const HUNCH_VERSION = (() => {
|
|
|
16
16
|
return "0.0.0";
|
|
17
17
|
}
|
|
18
18
|
})();
|
|
19
|
+
/** Shared identity for Hunch's published package. */
|
|
20
|
+
export const HUNCH_PACKAGE_NAME = "@davesheffer/hunch";
|
|
19
21
|
/** Exact public npm package consumed by generated CI and shared MCP/provider
|
|
20
22
|
* configs. A floating package name would let one committed configuration run
|
|
21
23
|
* different Hunch semantics as npm's latest release changes. */
|
|
22
|
-
export const HUNCH_PACKAGE_SPEC =
|
|
24
|
+
export const HUNCH_PACKAGE_SPEC = `${HUNCH_PACKAGE_NAME}@${HUNCH_VERSION}`;
|
|
23
25
|
/** npm alias used by npx launchers. Giving the fetched package a distinct local
|
|
24
26
|
* alias prevents npm exec from treating this repository (which has the same
|
|
25
27
|
* package name) as satisfying the request and then falling through to an older
|
|
@@ -164,14 +164,39 @@ export function inspectIntegrations(root, selected) {
|
|
|
164
164
|
continue;
|
|
165
165
|
const capabilities = Object.fromEntries(CAPABILITIES.map(c => [c, { status: "untested", detail: "No runtime evidence" }]));
|
|
166
166
|
report.harnesses.push({ harness, capabilities });
|
|
167
|
+
const mcpPath = join(root, spec.mcp);
|
|
168
|
+
const hooksFileExists = Boolean(spec.hooks && existsSync(join(root, spec.hooks)));
|
|
169
|
+
let mcpEntryAbsent = false;
|
|
170
|
+
try {
|
|
171
|
+
lstatSync(mcpPath);
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
mcpEntryAbsent = e.code === "ENOENT";
|
|
175
|
+
}
|
|
167
176
|
try {
|
|
168
177
|
const launcher = readLauncher(root, harness);
|
|
169
178
|
recordPins(spec.mcp, [launcher.command, ...launcher.args]);
|
|
170
179
|
capabilities.mcp.detail = "Configured locally; use --probe to verify a fresh server, then reconnect the host";
|
|
171
180
|
}
|
|
172
181
|
catch (e) {
|
|
173
|
-
|
|
174
|
-
|
|
182
|
+
// A harness can be detected here via its hooks file alone —
|
|
183
|
+
// some hooks files are deliberately committed while their MCP config is
|
|
184
|
+
// a per-clone, gitignored scaffold (e.g. this repo's own
|
|
185
|
+
// .windsurf/hooks.json). On a fresh checkout that config simply doesn't
|
|
186
|
+
// exist yet, which is a "not configured on this machine" state, not a
|
|
187
|
+
// repository-level misconfiguration — it must stay `untested`
|
|
188
|
+
// (informational, matching every other not-yet-evidenced capability
|
|
189
|
+
// here), not a hard `issues` entry that fails `hunch doctor` on every
|
|
190
|
+
// clone forever. A file that EXISTS but is malformed/disabled/
|
|
191
|
+
// unreadable in some other way is still a genuine issue.
|
|
192
|
+
// lstat distinguishes a truly absent per-machine file from a dangling
|
|
193
|
+
// symlink. The latter is a broken configuration and must remain loud.
|
|
194
|
+
const notConfiguredHere = !selected && hooksFileExists && mcpEntryAbsent && e.code === "ENOENT";
|
|
195
|
+
if (!notConfiguredHere)
|
|
196
|
+
report.issues.push({ file: spec.mcp, code: "mcp-config", detail: e.message });
|
|
197
|
+
capabilities.mcp.detail = notConfiguredHere
|
|
198
|
+
? "Not configured on this machine — a hooks file exists, but no local MCP config exists yet; run `hunch init` or set up this host"
|
|
199
|
+
: "MCP configuration disabled, invalid, or outside supported inspection format";
|
|
175
200
|
}
|
|
176
201
|
let events = {};
|
|
177
202
|
let disabled = false;
|