@davesheffer/hunch 1.39.1 → 1.39.3
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 +164 -55
- package/dist/cli/serve.d.ts +14 -0
- package/dist/cli/serve.js +18 -4
- package/dist/client/state.d.ts +2 -1
- package/dist/client/state.js +1 -0
- package/dist/core/agenthook.js +7 -3
- package/dist/core/capturetoken.d.ts +30 -3
- package/dist/core/capturetoken.js +29 -3
- package/dist/core/correction.d.ts +10 -4
- package/dist/core/correction.js +19 -11
- package/dist/core/countersign.d.ts +28 -0
- package/dist/core/countersign.js +50 -0
- package/dist/core/docanchors.js +16 -2
- package/dist/core/glob.d.ts +19 -0
- package/dist/core/glob.js +37 -0
- package/dist/core/paths.d.ts +27 -5
- package/dist/core/paths.js +51 -6
- package/dist/core/procstart.d.ts +94 -0
- package/dist/core/procstart.js +244 -0
- package/dist/core/reviewqueue.js +6 -1
- package/dist/core/served.d.ts +6 -3
- package/dist/core/served.js +10 -6
- package/dist/core/spawnCommand.js +41 -9
- package/dist/core/stateHttp.d.ts +1 -1
- package/dist/core/stateHttp.js +3 -1
- package/dist/core/taskReport.d.ts +2 -0
- package/dist/core/taskReport.js +46 -11
- package/dist/core/taskReportEvidence.js +42 -6
- package/dist/core/taskReportHook.js +14 -2
- package/dist/core/topics.js +1 -1
- 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 +17 -1
- package/dist/extractors/k8sManifest.js +104 -16
- package/dist/integrations/hookmatch.d.ts +25 -0
- package/dist/integrations/hookmatch.js +58 -0
- package/dist/integrations/hooks.d.ts +38 -7
- package/dist/integrations/hooks.js +250 -24
- package/dist/integrations/providers.js +2 -17
- package/dist/integrations/scaffold.d.ts +1 -1
- package/dist/integrations/scaffold.js +25 -22
- 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 +131 -64
- package/dist/serve/app.d.ts +4 -0
- package/dist/serve/app.js +56 -36
- package/dist/serve/writelock.d.ts +21 -1
- package/dist/serve/writelock.js +157 -16
- package/dist/store/hunchStore.js +41 -11
- 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/store/stateBinding.js +28 -7
- package/dist/wiki/adopt.js +13 -2
- package/dist/wiki/wiki.d.ts +7 -0
- package/dist/wiki/wiki.js +19 -8
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -25,12 +25,18 @@ export interface CorrectionInput {
|
|
|
25
25
|
* consumer matches repo-relative paths — so without this an absolute hint mints a
|
|
26
26
|
* scope that can never match. */
|
|
27
27
|
root?: string;
|
|
28
|
-
/** True when a
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
/** True only when a HUMAN confirmed this write outside the agent's channel (an MCP
|
|
29
|
+
* elicitation answered in the client UI). A consumed capture token alone is NOT
|
|
30
|
+
* enough — any agent can mint and consume one. Determines the TIER, never whether
|
|
31
|
+
* the write lands: an un-vouched correction still records immediately and still
|
|
32
|
+
* surfaces at edit time and in CI. Only the authority to DENY waits for a countersign
|
|
33
|
+
* (`hunch review --confirm <id>`). */
|
|
32
34
|
vouched?: boolean;
|
|
33
35
|
}
|
|
36
|
+
/** Default rationales, exported so a later human countersign can replace the testimony
|
|
37
|
+
* wording without touching a rationale a person actually wrote. */
|
|
38
|
+
export declare const VOUCHED_CORRECTION_RATIONALE = "Captured from a human correction of the agent (Never Twice).";
|
|
39
|
+
export declare const TESTIMONY_CORRECTION_RATIONALE = "Recorded by the agent as a correction, without a human confirmation \u2014 advisory testimony until a human countersigns it (`hunch review --confirm`) (Never Twice).";
|
|
34
40
|
/**
|
|
35
41
|
* Build the Constraint a correction mints. Pure (caller passes `now`), so the
|
|
36
42
|
* scope/severity policy is testable in isolation. Key safety rule (research
|
package/dist/core/correction.js
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* - buildCorrectionConstraint(): mint the Constraint record (human-confirmed,
|
|
9
9
|
* scoped conservatively) that the pre-edit hook + CI guard then enforce.
|
|
10
10
|
*/
|
|
11
|
-
import { isAbsolute
|
|
11
|
+
import { isAbsolute } from "node:path";
|
|
12
12
|
import { constraintId } from "./ids.js";
|
|
13
|
-
import { toPosixTarget } from "./paths.js";
|
|
13
|
+
import { toPosixTarget, repoRelativeTarget } from "./paths.js";
|
|
14
14
|
import { deriveForbids } from "./constraintmatch.js";
|
|
15
15
|
/** Correction cues. Deliberately conservative — anchored to imperative/rebuke
|
|
16
16
|
* phrasing, not bare "no", so ordinary conversational negation ("no idea",
|
|
@@ -41,6 +41,10 @@ export const CORRECTION_NUDGE = "This looks like a correction. If it's a rule th
|
|
|
41
41
|
"call hunch_record_correction({ rule, scope_hint_file, severity, applies_to_all }) so it " +
|
|
42
42
|
"becomes an enforced, scoped constraint (held at edit-time and in CI) — not a one-off the next session forgets. " +
|
|
43
43
|
"Use severity:\"blocking\" only when the human said never/must; set applies_to_all:true only if the rule is genuinely repo-wide.";
|
|
44
|
+
/** Default rationales, exported so a later human countersign can replace the testimony
|
|
45
|
+
* wording without touching a rationale a person actually wrote. */
|
|
46
|
+
export const VOUCHED_CORRECTION_RATIONALE = "Captured from a human correction of the agent (Never Twice).";
|
|
47
|
+
export const TESTIMONY_CORRECTION_RATIONALE = "Recorded by the agent as a correction, without a human confirmation — advisory testimony until a human countersigns it (`hunch review --confirm`) (Never Twice).";
|
|
44
48
|
/** Normalize a scope hint to a repo-relative POSIX path.
|
|
45
49
|
*
|
|
46
50
|
* An ABSOLUTE hint is the shape an agent naturally sends, but `checkConstraints`
|
|
@@ -53,7 +57,13 @@ export const CORRECTION_NUDGE = "This looks like a correction. If it's a rule th
|
|
|
53
57
|
* Returns "" when the hint cannot be made repo-relative (no root, or a path outside the
|
|
54
58
|
* repo). The caller then falls back to "**", where the existing severity guard
|
|
55
59
|
* down-ranks a non-explicit blocking rule to a warning — fail-safe and honest, rather
|
|
56
|
-
* than a blocking rule enforced nowhere.
|
|
60
|
+
* than a blocking rule enforced nowhere.
|
|
61
|
+
*
|
|
62
|
+
* Thin adapter over the shared `repoRelativeTarget` (core/paths.ts): that function
|
|
63
|
+
* passes an unresolvable absolute hint through UNCHANGED (still absolute) rather
|
|
64
|
+
* than signaling failure directly, since other callers (checkConstraints, why())
|
|
65
|
+
* want the original target back to fail their own match safely. This adapter
|
|
66
|
+
* converts that "still absolute" signal to "" — the fail-safe this caller needs. */
|
|
57
67
|
function repoRelativeHint(rawHint, root) {
|
|
58
68
|
if (!rawHint)
|
|
59
69
|
return "";
|
|
@@ -62,10 +72,9 @@ function repoRelativeHint(rawHint, root) {
|
|
|
62
72
|
return rawHint;
|
|
63
73
|
if (!root)
|
|
64
74
|
return "";
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return rel;
|
|
75
|
+
const rewritten = repoRelativeTarget(rawHint, root);
|
|
76
|
+
const stillAbsolute = isAbsolute(rewritten) || /^[a-zA-Z]:/.test(rewritten);
|
|
77
|
+
return stillAbsolute ? "" : rewritten;
|
|
69
78
|
}
|
|
70
79
|
/**
|
|
71
80
|
* Build the Constraint a correction mints. Pure (caller passes `now`), so the
|
|
@@ -94,7 +103,8 @@ export function buildCorrectionConstraint(input, now) {
|
|
|
94
103
|
// the highest-authority write path the least gated one, and strictly worse than
|
|
95
104
|
// hunch_record_decision, which only ever produced advisory memory and is now tiered.
|
|
96
105
|
//
|
|
97
|
-
//
|
|
106
|
+
// A HUMAN confirmation sets the TIER (a capture token alone is not one — any agent can
|
|
107
|
+
// mint and consume a token), never whether the write lands. An un-vouched correction is
|
|
98
108
|
// still recorded immediately and still held against every assistant at edit time and
|
|
99
109
|
// in CI — Never Twice keeps its promise. What waits for a countersign is only the
|
|
100
110
|
// authority to DENY.
|
|
@@ -114,9 +124,7 @@ export function buildCorrectionConstraint(input, now) {
|
|
|
114
124
|
// rule that goes stale. Validated against the repo's real deps when supplied → never mints a
|
|
115
125
|
// never-firing rule for a non-dependency. null when nothing derivable → falls back to scope.
|
|
116
126
|
forbids: deriveForbids(rule, input.knownDeps),
|
|
117
|
-
rationale: input.rationale ?? (vouched
|
|
118
|
-
? "Captured from a human correction of the agent (Never Twice)."
|
|
119
|
-
: "Recorded by the agent as a correction, WITHOUT a capture interview — advisory testimony until a human countersigns it via /capture (Never Twice)."),
|
|
127
|
+
rationale: input.rationale ?? (vouched ? VOUCHED_CORRECTION_RATIONALE : TESTIMONY_CORRECTION_RATIONALE),
|
|
120
128
|
source_decision: input.source_decision ?? null,
|
|
121
129
|
violations: [],
|
|
122
130
|
status: "active",
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Human countersign: turn agent testimony into a human-confirmed record.
|
|
2
|
+
*
|
|
3
|
+
* A capture token proves an interview protocol was issued, not that a human answered
|
|
4
|
+
* (see capturetoken.ts), so agent-written decisions and corrections land as
|
|
5
|
+
* `agent_recorded` testimony. This is the pure record transform behind the human act
|
|
6
|
+
* that upgrades them — `hunch review --confirm <id>` (and, for a correction, the
|
|
7
|
+
* severity the human grants). It never changes a record's content or status: confirming
|
|
8
|
+
* a proposed decision is not shipping it. Pure (caller passes `now`) so it is testable. */
|
|
9
|
+
import type { Constraint, Decision } from "./types.js";
|
|
10
|
+
/** Is this record agent testimony awaiting a human countersign? Token-aware ("+"-joined
|
|
11
|
+
* sources), and a record carrying a human signature is never testimony. */
|
|
12
|
+
export declare function isAgentTestimony(source: string | undefined): boolean;
|
|
13
|
+
/** The exact command a HUMAN runs to countersign agent testimony (outside the agent
|
|
14
|
+
* channel). `private` targets the overlay home; `severity` grants a correction's authority. */
|
|
15
|
+
export declare function confirmCommand(id: string, opts?: {
|
|
16
|
+
private?: boolean;
|
|
17
|
+
severity?: string;
|
|
18
|
+
}): string;
|
|
19
|
+
/** Replace the agent testimony tier with the human signature, keeping every other
|
|
20
|
+
* "+"-joined source token ("llm_draft+agent_recorded" → "llm_draft+human_confirmed"). */
|
|
21
|
+
export declare function withHumanSignature(source: string): string;
|
|
22
|
+
/** Countersign a decision. Same tier + confidence the capture path grants a human-confirmed
|
|
23
|
+
* write; status, content, and tripwires are untouched (`hunch review --accept` is the path
|
|
24
|
+
* that ships a draft and arms its tripwires). */
|
|
25
|
+
export declare function countersignDecision(d: Decision, now: string): Decision;
|
|
26
|
+
/** Countersign a correction. `severity`, when given, is the authority the human grants
|
|
27
|
+
* (an unconfirmed "blocking" request was capped to "warning"); otherwise it is kept. */
|
|
28
|
+
export declare function countersignConstraint(c: Constraint, now: string, severity?: Constraint["severity"]): Constraint;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { TESTIMONY_CORRECTION_RATIONALE, VOUCHED_CORRECTION_RATIONALE } from "./correction.js";
|
|
2
|
+
/** Is this record agent testimony awaiting a human countersign? Token-aware ("+"-joined
|
|
3
|
+
* sources), and a record carrying a human signature is never testimony. */
|
|
4
|
+
export function isAgentTestimony(source) {
|
|
5
|
+
const tokens = (source ?? "").split("+");
|
|
6
|
+
return tokens.includes("agent_recorded") && !tokens.includes("human_confirmed");
|
|
7
|
+
}
|
|
8
|
+
/** The exact command a HUMAN runs to countersign agent testimony (outside the agent
|
|
9
|
+
* channel). `private` targets the overlay home; `severity` grants a correction's authority. */
|
|
10
|
+
export function confirmCommand(id, opts = {}) {
|
|
11
|
+
return `hunch review --confirm ${id}${opts.severity ? ` --severity ${opts.severity}` : ""}${opts.private ? " --private" : ""}`;
|
|
12
|
+
}
|
|
13
|
+
/** Replace the agent testimony tier with the human signature, keeping every other
|
|
14
|
+
* "+"-joined source token ("llm_draft+agent_recorded" → "llm_draft+human_confirmed"). */
|
|
15
|
+
export function withHumanSignature(source) {
|
|
16
|
+
const tokens = source.split("+").filter((t) => t && t !== "agent_recorded");
|
|
17
|
+
if (!tokens.includes("human_confirmed"))
|
|
18
|
+
tokens.push("human_confirmed");
|
|
19
|
+
return tokens.join("+");
|
|
20
|
+
}
|
|
21
|
+
/** Countersign a decision. Same tier + confidence the capture path grants a human-confirmed
|
|
22
|
+
* write; status, content, and tripwires are untouched (`hunch review --accept` is the path
|
|
23
|
+
* that ships a draft and arms its tripwires). */
|
|
24
|
+
export function countersignDecision(d, now) {
|
|
25
|
+
return {
|
|
26
|
+
...d,
|
|
27
|
+
provenance: {
|
|
28
|
+
...d.provenance,
|
|
29
|
+
source: withHumanSignature(d.provenance.source),
|
|
30
|
+
confidence: Math.max(d.provenance.confidence ?? 0, 0.95),
|
|
31
|
+
last_verified: now,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Countersign a correction. `severity`, when given, is the authority the human grants
|
|
36
|
+
* (an unconfirmed "blocking" request was capped to "warning"); otherwise it is kept. */
|
|
37
|
+
export function countersignConstraint(c, now, severity) {
|
|
38
|
+
return {
|
|
39
|
+
...c,
|
|
40
|
+
severity: severity ?? c.severity,
|
|
41
|
+
rationale: c.rationale === TESTIMONY_CORRECTION_RATIONALE ? VOUCHED_CORRECTION_RATIONALE : c.rationale,
|
|
42
|
+
provenance: {
|
|
43
|
+
...c.provenance,
|
|
44
|
+
source: withHumanSignature(c.provenance.source),
|
|
45
|
+
confidence: 1,
|
|
46
|
+
last_verified: now,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=countersign.js.map
|
package/dist/core/docanchors.js
CHANGED
|
@@ -4,7 +4,9 @@ const MARKER = /<!--\s*hunch:topic\s+([A-Za-z0-9._/-]+)(?:\s+(dec_[A-Za-z0-9]+))
|
|
|
4
4
|
* documentation EXAMPLE of a marker never registers as a live anchor.
|
|
5
5
|
* CommonMark-lite: a fence of N chars (≤3 leading spaces) closes only on a
|
|
6
6
|
* line of ≥N of the same char and nothing else; an unclosed fence runs to
|
|
7
|
-
* EOF; a backtick fence's info string may not itself contain a backtick.
|
|
7
|
+
* EOF; a backtick fence's info string may not itself contain a backtick.
|
|
8
|
+
* Expects LF-normalized text — see parseDocAnchors's normalization; a
|
|
9
|
+
* caller that skips it re-opens the CRLF fence-detection bug. */
|
|
8
10
|
function fencedRanges(text) {
|
|
9
11
|
const ranges = [];
|
|
10
12
|
let open = null;
|
|
@@ -31,7 +33,11 @@ function fencedRanges(text) {
|
|
|
31
33
|
/** Character ranges covered by inline code spans (`…`), same rationale as
|
|
32
34
|
* fencedRanges: prose quoting a marker in backticks is showing an example.
|
|
33
35
|
* CommonMark-lite: an opener run pairs with the next run of the SAME length
|
|
34
|
-
* on the same line; unpaired runs never open a span.
|
|
36
|
+
* on the same line; unpaired runs never open a span.
|
|
37
|
+
* Expects LF-normalized text — see parseDocAnchors's normalization.
|
|
38
|
+
* `split("\n")` does not split a bare CR, so on CR-only input the whole
|
|
39
|
+
* document reads as one line and stray backticks on different lines
|
|
40
|
+
* falsely pair. */
|
|
35
41
|
function inlineSpanRanges(text) {
|
|
36
42
|
const ranges = [];
|
|
37
43
|
let offset = 0;
|
|
@@ -55,6 +61,14 @@ function inlineSpanRanges(text) {
|
|
|
55
61
|
* fenced code blocks or inline code spans are examples, not declarations,
|
|
56
62
|
* and are skipped. */
|
|
57
63
|
export function parseDocAnchors(text) {
|
|
64
|
+
// fencedRanges is CRLF-sensitive (its fence-line regex's `.` excludes \r, so
|
|
65
|
+
// "```\r" never matched at all on a CRLF checkout); inlineSpanRanges is
|
|
66
|
+
// lone-CR-sensitive (split("\n") doesn't split a bare CR — see its
|
|
67
|
+
// docblock). Either way an example marker inside a fence/span registered as
|
|
68
|
+
// a live, pinned anchor. Normalizing both CRLF and lone CR once here keeps
|
|
69
|
+
// fencedRanges/inlineSpanRanges/MARKER offsets consistent with each other
|
|
70
|
+
// and with the line numbers reported below.
|
|
71
|
+
text = text.replace(/\r\n?/g, "\n");
|
|
58
72
|
const out = [];
|
|
59
73
|
const skip = [...fencedRanges(text), ...inlineSpanRanges(text)];
|
|
60
74
|
MARKER.lastIndex = 0;
|
package/dist/core/glob.d.ts
CHANGED
|
@@ -7,3 +7,22 @@ export declare function pathMatchesGlob(path: string, glob: string): boolean;
|
|
|
7
7
|
/** Do two concrete repo paths identify the same path at different prefix depth?
|
|
8
8
|
* Segment-anchored: `x/scenario.ts` relates to `scenario.ts`, never `io.ts`. */
|
|
9
9
|
export declare function pathsRelated(left: string, right: string): boolean;
|
|
10
|
+
/** True when `target` is a path Hunch's index already knows about: an indexed
|
|
11
|
+
* symbol's exact file, or a path an indexed component's `paths` glob(s) cover.
|
|
12
|
+
* The latter catches a symbol-less-but-indexed file (README.md, package.json,
|
|
13
|
+
* Dockerfile, ...) that has zero tree-sitter symbols but is still a real,
|
|
14
|
+
* known file. Derived entirely from already-loaded graph data — never the
|
|
15
|
+
* filesystem — so the answer doesn't depend on untracked working-tree state
|
|
16
|
+
* (a deleted-but-still-indexed path stays "real"; issue #299). */
|
|
17
|
+
export declare function isIndexedPath(target: string, symbolFiles: Iterable<string>, componentPaths: Iterable<readonly string[]>): boolean;
|
|
18
|
+
/** Resolve symbols matching `target`, tiered: exact id > exact name > exact file >
|
|
19
|
+
* (only when `target` is NOT a path already known to the index) segment-anchored
|
|
20
|
+
* suffix. A real indexed file with zero symbols must return [] rather than fall
|
|
21
|
+
* through to the suffix tier, which would leak an unrelated same-basename file's
|
|
22
|
+
* records (issue #299) — callers compute `indexed` via `isIndexedPath` first so
|
|
23
|
+
* the "is this a real path" question is answered identically everywhere. */
|
|
24
|
+
export declare function matchSymbolsTiered<S extends {
|
|
25
|
+
id: string;
|
|
26
|
+
file: string;
|
|
27
|
+
name: string;
|
|
28
|
+
}>(target: string, symbols: readonly S[], indexed: boolean): S[];
|
package/dist/core/glob.js
CHANGED
|
@@ -66,4 +66,41 @@ export function pathsRelated(left, right) {
|
|
|
66
66
|
const b = norm(right);
|
|
67
67
|
return a === b || a.endsWith(`/${b}`) || b.endsWith(`/${a}`);
|
|
68
68
|
}
|
|
69
|
+
/** True when `target` is a path Hunch's index already knows about: an indexed
|
|
70
|
+
* symbol's exact file, or a path an indexed component's `paths` glob(s) cover.
|
|
71
|
+
* The latter catches a symbol-less-but-indexed file (README.md, package.json,
|
|
72
|
+
* Dockerfile, ...) that has zero tree-sitter symbols but is still a real,
|
|
73
|
+
* known file. Derived entirely from already-loaded graph data — never the
|
|
74
|
+
* filesystem — so the answer doesn't depend on untracked working-tree state
|
|
75
|
+
* (a deleted-but-still-indexed path stays "real"; issue #299). */
|
|
76
|
+
export function isIndexedPath(target, symbolFiles, componentPaths) {
|
|
77
|
+
for (const f of symbolFiles)
|
|
78
|
+
if (f === target)
|
|
79
|
+
return true;
|
|
80
|
+
for (const globs of componentPaths)
|
|
81
|
+
for (const g of globs)
|
|
82
|
+
if (pathMatchesGlob(target, g))
|
|
83
|
+
return true;
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
/** Resolve symbols matching `target`, tiered: exact id > exact name > exact file >
|
|
87
|
+
* (only when `target` is NOT a path already known to the index) segment-anchored
|
|
88
|
+
* suffix. A real indexed file with zero symbols must return [] rather than fall
|
|
89
|
+
* through to the suffix tier, which would leak an unrelated same-basename file's
|
|
90
|
+
* records (issue #299) — callers compute `indexed` via `isIndexedPath` first so
|
|
91
|
+
* the "is this a real path" question is answered identically everywhere. */
|
|
92
|
+
export function matchSymbolsTiered(target, symbols, indexed) {
|
|
93
|
+
const byId = symbols.find((s) => s.id === target);
|
|
94
|
+
if (byId)
|
|
95
|
+
return [byId];
|
|
96
|
+
const byName = symbols.filter((s) => s.name === target);
|
|
97
|
+
if (byName.length)
|
|
98
|
+
return byName;
|
|
99
|
+
const exact = symbols.filter((s) => s.file === target);
|
|
100
|
+
if (exact.length)
|
|
101
|
+
return exact;
|
|
102
|
+
if (indexed)
|
|
103
|
+
return [];
|
|
104
|
+
return symbols.filter((s) => pathsRelated(s.file, target));
|
|
105
|
+
}
|
|
69
106
|
//# sourceMappingURL=glob.js.map
|
package/dist/core/paths.d.ts
CHANGED
|
@@ -1,10 +1,32 @@
|
|
|
1
1
|
export declare const HUNCH_DIR = ".hunch";
|
|
2
|
-
/** Canonicalize a free-form path/target to
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
/** Canonicalize a free-form path/target to forward-slash form (Hunch stores every
|
|
3
|
+
* path with "/" — git emits it on all OSes — so any user- or agent-supplied
|
|
4
|
+
* target must be normalized before comparison, otherwise a Windows caller passing
|
|
5
|
+
* `src\auth\session.ts` never matches the stored `src/auth/session.ts`). Safe on
|
|
6
|
+
* symbol names too: they contain no backslashes. This does NOT make a path
|
|
7
|
+
* repo-relative — an absolute path passes through with its separators flipped,
|
|
8
|
+
* unchanged otherwise; use `repoRelativeTarget` for that. */
|
|
7
9
|
export declare function toPosixTarget(target: string): string;
|
|
10
|
+
/** realpath a path even if it doesn't exist yet (e.g. a new file an agent is about
|
|
11
|
+
* to Write, or a glob whose concrete segments aren't literal): resolve the longest
|
|
12
|
+
* existing ancestor, then re-append the missing tail. Idempotent on an
|
|
13
|
+
* already-resolved path. */
|
|
14
|
+
export declare function realpathNorm(p: string): string;
|
|
15
|
+
/** Rewrite an ABSOLUTE target to repo-relative POSIX form when it falls inside
|
|
16
|
+
* `root`. Hunch's stored paths (symbols, constraint scopes, ...) are always
|
|
17
|
+
* repo-relative, so a caller that hands over an absolute edit-payload path
|
|
18
|
+
* matched nothing (issue #296: hunch_check_constraints / hunch_blast_radius
|
|
19
|
+
* returned empty for an absolute target that plainly had a matching rule). A
|
|
20
|
+
* relative target, a glob, or an absolute path outside `root` passes through
|
|
21
|
+
* unchanged (toPosixTarget'd) — there is nothing safe to rewrite it to.
|
|
22
|
+
*
|
|
23
|
+
* BOTH ends are realpath-normalized before the relative-path computation: on
|
|
24
|
+
* macOS `process.cwd()` (hence `findRoot`) resolves /var → /private/var, but a
|
|
25
|
+
* hook event's `file_path` arrives UN-resolved, so a naive `relative()` yields a
|
|
26
|
+
* bogus "../" path under a symlinked root (/var, /tmp, symlinked $HOME) and the
|
|
27
|
+
* caller treats the file as outside the repo, silently dropping all context
|
|
28
|
+
* (dec_e0a36efbf5). */
|
|
29
|
+
export declare function repoRelativeTarget(target: string, root: string): string;
|
|
8
30
|
export interface HunchPaths {
|
|
9
31
|
/** Repo root (where .hunch/ lives). */
|
|
10
32
|
root: string;
|
package/dist/core/paths.js
CHANGED
|
@@ -1,16 +1,61 @@
|
|
|
1
1
|
/** Filesystem layout for the Hunch (DESIGN.md §6 folder structure). */
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
4
|
-
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|
5
5
|
export const HUNCH_DIR = ".hunch";
|
|
6
|
-
/** Canonicalize a free-form path/target to
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
/** Canonicalize a free-form path/target to forward-slash form (Hunch stores every
|
|
7
|
+
* path with "/" — git emits it on all OSes — so any user- or agent-supplied
|
|
8
|
+
* target must be normalized before comparison, otherwise a Windows caller passing
|
|
9
|
+
* `src\auth\session.ts` never matches the stored `src/auth/session.ts`). Safe on
|
|
10
|
+
* symbol names too: they contain no backslashes. This does NOT make a path
|
|
11
|
+
* repo-relative — an absolute path passes through with its separators flipped,
|
|
12
|
+
* unchanged otherwise; use `repoRelativeTarget` for that. */
|
|
11
13
|
export function toPosixTarget(target) {
|
|
12
14
|
return target.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
13
15
|
}
|
|
16
|
+
/** realpath a path even if it doesn't exist yet (e.g. a new file an agent is about
|
|
17
|
+
* to Write, or a glob whose concrete segments aren't literal): resolve the longest
|
|
18
|
+
* existing ancestor, then re-append the missing tail. Idempotent on an
|
|
19
|
+
* already-resolved path. */
|
|
20
|
+
export function realpathNorm(p) {
|
|
21
|
+
try {
|
|
22
|
+
return realpathSync.native(p);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
const parent = dirname(p);
|
|
26
|
+
if (parent === p)
|
|
27
|
+
return p; // hit the root; nothing more to resolve
|
|
28
|
+
return join(realpathNorm(parent), basename(p));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Rewrite an ABSOLUTE target to repo-relative POSIX form when it falls inside
|
|
32
|
+
* `root`. Hunch's stored paths (symbols, constraint scopes, ...) are always
|
|
33
|
+
* repo-relative, so a caller that hands over an absolute edit-payload path
|
|
34
|
+
* matched nothing (issue #296: hunch_check_constraints / hunch_blast_radius
|
|
35
|
+
* returned empty for an absolute target that plainly had a matching rule). A
|
|
36
|
+
* relative target, a glob, or an absolute path outside `root` passes through
|
|
37
|
+
* unchanged (toPosixTarget'd) — there is nothing safe to rewrite it to.
|
|
38
|
+
*
|
|
39
|
+
* BOTH ends are realpath-normalized before the relative-path computation: on
|
|
40
|
+
* macOS `process.cwd()` (hence `findRoot`) resolves /var → /private/var, but a
|
|
41
|
+
* hook event's `file_path` arrives UN-resolved, so a naive `relative()` yields a
|
|
42
|
+
* bogus "../" path under a symlinked root (/var, /tmp, symlinked $HOME) and the
|
|
43
|
+
* caller treats the file as outside the repo, silently dropping all context
|
|
44
|
+
* (dec_e0a36efbf5). */
|
|
45
|
+
export function repoRelativeTarget(target, root) {
|
|
46
|
+
const t = toPosixTarget(target);
|
|
47
|
+
const looksAbsolute = isAbsolute(t) || /^[a-zA-Z]:/.test(t);
|
|
48
|
+
if (!looksAbsolute)
|
|
49
|
+
return t;
|
|
50
|
+
// `root` is posix'd too, not just `target`: realpathNorm's fallback walk decomposes
|
|
51
|
+
// by "/" (via node:path, which on a POSIX host does NOT treat "\" as a separator),
|
|
52
|
+
// so an un-posix'd root and a posix'd target would decompose into differently-shaped
|
|
53
|
+
// ancestor chains and never share a common realpath'd prefix.
|
|
54
|
+
const rel = toPosixTarget(relative(realpathNorm(toPosixTarget(root)), realpathNorm(t)));
|
|
55
|
+
if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel) || /^[a-zA-Z]:/.test(rel))
|
|
56
|
+
return t;
|
|
57
|
+
return rel;
|
|
58
|
+
}
|
|
14
59
|
export function hunchPaths(root) {
|
|
15
60
|
const hunch = join(root, HUNCH_DIR);
|
|
16
61
|
return {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** `starttime` in clock ticks since boot, as the exact decimal STRING the kernel
|
|
2
|
+
* printed — it is an identity component, never a quantity, so it is never
|
|
3
|
+
* converted to a Number (which would lose precision above 2^53 and invite
|
|
4
|
+
* arithmetic). `comm` (field 2) may contain spaces and parentheses, so the
|
|
5
|
+
* numeric fields are only unambiguous AFTER the last `)`. starttime is overall
|
|
6
|
+
* field 22, i.e. index 19 of that remainder (whose [0] is the state field). */
|
|
7
|
+
export declare function starttimeTicks(stat: string): string | null;
|
|
8
|
+
/** Reader seam so the pure token logic is testable off linux. */
|
|
9
|
+
interface ProcReader {
|
|
10
|
+
read: (path: string) => string;
|
|
11
|
+
readlink: (path: string) => string;
|
|
12
|
+
}
|
|
13
|
+
/** The process-instance token `<bootId>:<pidNsInum>:<timeNsInum>:<starttimeTicks>`
|
|
14
|
+
* for `pid` ("self" for us), or `null` when any piece is missing or malformed.
|
|
15
|
+
* Linux only, and no spawn: every piece is a file read.
|
|
16
|
+
*
|
|
17
|
+
* The pid namespace is ALWAYS the caller's own (`/proc/self/ns/pid`), including
|
|
18
|
+
* when tokenizing another pid: a pid we can see in our `/proc` is by definition
|
|
19
|
+
* addressed in our namespace, so that is the namespace the token belongs to.
|
|
20
|
+
*
|
|
21
|
+
* The TIME namespace matters because field 22 is rendered relative to the
|
|
22
|
+
* READER's time namespace: the same live process yields different ticks to
|
|
23
|
+
* readers in different time namespaces, so a token compared across them would
|
|
24
|
+
* read "recycled" for a perfectly healthy owner. Kernels before 5.6 have no
|
|
25
|
+
* `/proc/self/ns/time` at all — that one missing link is normalized to "0"
|
|
26
|
+
* (every process on such a kernel shares the same, only, time namespace).
|
|
27
|
+
*
|
|
28
|
+
* `/proc` itself can be a view from an ANCESTOR pid namespace (a bind-mounted
|
|
29
|
+
* or not-remounted /proc inside a container), in which case `/proc/<pid>` names
|
|
30
|
+
* a process by the ancestor's numbering while `kill(pid, 0)` uses ours — two
|
|
31
|
+
* different pid spaces silently mixed. `readlink /proc/self` returns our pid AS
|
|
32
|
+
* /proc numbers it, so requiring it to equal our own `process.pid` is the cheap
|
|
33
|
+
* proof that the mount we are reading is our own namespace's. */
|
|
34
|
+
export declare function linuxStartToken(pid: number | "self", io?: ProcReader, selfPid?: number): string | null;
|
|
35
|
+
/** OUR process-instance token, or `null` off linux / when /proc proves nothing.
|
|
36
|
+
* Only a NON-NULL token is memoized: a process's own identity never changes,
|
|
37
|
+
* but a transient failure to READ it (a momentarily unreadable /proc) must not
|
|
38
|
+
* be frozen into a permanent "we have no identity" for the life of the process. */
|
|
39
|
+
export declare function selfStartToken(): string | null;
|
|
40
|
+
/** The process-instance token of whoever currently holds `pid`, or `null` when
|
|
41
|
+
* that cannot be established (not linux, pid gone, /proc hidden). */
|
|
42
|
+
export declare function startTokenOf(pid: number): string | null;
|
|
43
|
+
/** What the recorded owner is, relative to whoever holds its pid NOW.
|
|
44
|
+
* - `same` — byte-identical token: the genuine owner, still running.
|
|
45
|
+
* - `recycled` — the pid is held by a DIFFERENT process instance.
|
|
46
|
+
* - `unprobeable` — the pid number carries no meaning here (another boot, another
|
|
47
|
+
* namespace, or a token read on a platform that has none), so
|
|
48
|
+
* neither "alive" nor "dead" is evidence.
|
|
49
|
+
* - `unknown` — no token recorded, or none readable now: not proven either way. */
|
|
50
|
+
export type InstanceVerdict = "same" | "recycled" | "unprobeable" | "unknown";
|
|
51
|
+
/** The whole verdict, pure and injectable. String comparison only — no clocks. */
|
|
52
|
+
export declare function ownerInstance(ownerStart: string | undefined, pid: number, self?: string | null, tokenOf?: (pid: number) => string | null, platform?: NodeJS.Platform): InstanceVerdict;
|
|
53
|
+
/** Nonces of the locks THIS process currently holds.
|
|
54
|
+
*
|
|
55
|
+
* The own-pid fallback for platforms without tokens: when the token path says
|
|
56
|
+
* "unknown" and the lock names OUR OWN pid, a nonce we do not hold means the
|
|
57
|
+
* lock is not one WE took — most likely a PREDECESSOR that happened to have this
|
|
58
|
+
* pid (the container-restart shape) — so the record falls to the age rule
|
|
59
|
+
* instead of our own liveness vouching for a corpse forever.
|
|
60
|
+
*
|
|
61
|
+
* This is module state, i.e. per THREAD. `src/` has no `worker_threads` users
|
|
62
|
+
* (verified by grep), so every holder in a process shares this set; if a worker
|
|
63
|
+
* ever takes these locks, this fallback must be revisited. It is also per MODULE
|
|
64
|
+
* INSTANCE: were this module ever loaded twice in one process (a cache-busting
|
|
65
|
+
* import, a duplicated dependency), one copy would not recognise the other's
|
|
66
|
+
* held nonce and could age-steal its live lock. */
|
|
67
|
+
export declare const heldLockNonces: Set<string>;
|
|
68
|
+
/** What a contender should do about a SAME-HOST lock owner.
|
|
69
|
+
* - `stale` — provably not the owner's process any more: take it over.
|
|
70
|
+
* - `live` — authoritative, however old the file looks: never steal it.
|
|
71
|
+
* - `age` — nothing is proven; fall back to the caller's own age rule. */
|
|
72
|
+
export type SameHostVerdict = "stale" | "live" | "age";
|
|
73
|
+
/** The parts of a lock record this decision reads. Both lock formats supply it. */
|
|
74
|
+
export interface SameHostOwner {
|
|
75
|
+
pid: number;
|
|
76
|
+
start?: string;
|
|
77
|
+
nonce?: string;
|
|
78
|
+
}
|
|
79
|
+
/** Everything the decision consults, injected so every row is unit-testable. */
|
|
80
|
+
export interface SameHostEnv {
|
|
81
|
+
alive: (pid: number) => boolean;
|
|
82
|
+
self: string | null;
|
|
83
|
+
tokenOf: (pid: number) => string | null;
|
|
84
|
+
platform: NodeJS.Platform;
|
|
85
|
+
selfPid: number;
|
|
86
|
+
heldNonces: ReadonlySet<string>;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* THE decision table, shared by `src/serve/writelock.ts` and jsonStore's
|
|
90
|
+
* `.rmw-lock` so the two locks can never drift apart. Pure: no clock, no fs, no
|
|
91
|
+
* process state beyond what `env` hands it.
|
|
92
|
+
*/
|
|
93
|
+
export declare function judgeSameHostOwner(owner: SameHostOwner, env: SameHostEnv): SameHostVerdict;
|
|
94
|
+
export {};
|