@davesheffer/hunch 1.45.0 → 1.45.2

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.
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * - reindex(): JSON -> SQLite (rebuild the derived index + FTS)
6
6
  * - search(): FTS5 ranked query (hunch_query)
7
- * - why(): decisions/bugs/constraints explaining a path/symbol
7
+ * - why(): decisions/bugs/constraints explaining a path/symbol, or one record by id
8
8
  * - getDependents(): recursive-CTE blast radius over the call/dep graph
9
9
  * - checkConstraints(): constraints whose scope matches a glob/path
10
10
  * - bugLineage(): bugs matching a symptom/symbol + their lineage
@@ -26,6 +26,7 @@ import { normalizePath, rankTaskRecords, recordIdsOf, selectLatestTasks, selectT
26
26
  import { resolveTaskRankingMode } from "../core/taskRankingMode.js";
27
27
  import { currentForTopic, isInForce } from "../core/topics.js";
28
28
  import { edgeId } from "../core/ids.js";
29
+ import { WHY_GENERATED_ID, WHY_RECORD_ID } from "../core/whyRecord.js";
29
30
  import { isStrictBlocker, isVetoBlocker } from "../core/strictgate.js";
30
31
  import { effectiveForbids, matchForbids } from "../core/constraintmatch.js";
31
32
  import { analyzeDiff, diffContentGaps } from "../extractors/diff.js";
@@ -1343,6 +1344,14 @@ export class HunchStore {
1343
1344
  // An absolute target (an agent's edit-payload path, verbatim) never matched
1344
1345
  // the repo-relative stored file paths below (issue #299).
1345
1346
  target = repoRelativeTarget(target, this.paths.root);
1347
+ // A record id (every delivered brief line ends in `hunch_why("dec_…")`) resolves
1348
+ // to that record directly (issue #474). With `asOf`, a record not in force then
1349
+ // is "no record"; status stays as stored, like the path view (see whyRecord).
1350
+ const lookup = WHY_RECORD_ID.test(target) ? this.whyRecord(target, opts.canRead, opts.asOf) : undefined;
1351
+ if (lookup?.record)
1352
+ return { target, decisions: [], bugs: [], constraints: [], symbols: [], components: [], byId: lookup };
1353
+ // A miss is reported only for a generated-shape id; anything else resolves as before.
1354
+ const byId = lookup && WHY_GENERATED_ID.test(target) ? lookup : undefined;
1346
1355
  const decisions = this.recs("decisions").filter(opts.canRead ?? (() => true));
1347
1356
  const bugs = this.recs("bugs").filter(opts.canRead ?? (() => true));
1348
1357
  const constraints = this.recs("constraints").filter(opts.canRead ?? (() => true));
@@ -1379,8 +1388,62 @@ export class HunchStore {
1379
1388
  && inWindow(c.valid_from, c.valid_to, asOf)),
1380
1389
  symbols: matchedSymbols,
1381
1390
  components: components.filter((c) => c.paths.some((g) => pathMatchesGlob(target, g) || [...fileSet].some((f) => pathMatchesGlob(f, g)))),
1391
+ ...(byId ? { byId } : {}),
1382
1392
  };
1383
1393
  }
1394
+ /** Look a record up by id across the record kinds why() explains (public ∪ private
1395
+ * via recs(), narrowed by `canRead`). A decision carries its supersession chain.
1396
+ * With `asOf`, a record outside its valid window then is "no record", and only
1397
+ * chain links/neighbours already in effect then are shown. Status is the STORED
1398
+ * status, exactly as the path/symbol view prints it.
1399
+ * Neighbour ids the caller may not read are never printed. */
1400
+ whyRecord(id, canRead = () => true, asOf) {
1401
+ const find = (kind) => this.recs(kind).find((r) => r.id === id && canRead(r));
1402
+ // Decisions the caller may read that had taken effect by `asOf` (all when no asOf).
1403
+ const decs = new Map(this.recs("decisions").filter((x) => canRead(x) && inWindow(x.valid_from, null, asOf)).map((x) => [x.id, x]));
1404
+ const visible = (ref) => (ref && decs.has(ref) ? ref : null);
1405
+ const existed = (instant) => !asOf || instant <= asOf;
1406
+ let record = null;
1407
+ switch (id.slice(0, id.indexOf("_"))) {
1408
+ case "dec": {
1409
+ const d = find("decisions");
1410
+ if (!d || !inWindow(d.valid_from, d.valid_to, asOf))
1411
+ break;
1412
+ // Stored status, as the path view prints it; asOf only hides neighbours not yet in effect.
1413
+ const shown = { ...d, supersedes: visible(d.supersedes), superseded_by: visible(d.superseded_by) };
1414
+ record = { kind: "decision", record: shown, chain: supersessionChain(shown, decs) };
1415
+ break;
1416
+ }
1417
+ case "con": {
1418
+ const c = find("constraints");
1419
+ if (!c || !inWindow(c.valid_from, c.valid_to, asOf))
1420
+ break;
1421
+ record = { kind: "constraint", record: { ...c, source_decision: visible(c.source_decision) } };
1422
+ break;
1423
+ }
1424
+ case "bug": {
1425
+ const b = find("bugs");
1426
+ if (b)
1427
+ record = { kind: "bug", record: b };
1428
+ break;
1429
+ }
1430
+ case "fnd": {
1431
+ const f = find("findings");
1432
+ if (f && existed(f.observed_at))
1433
+ record = { kind: "finding", record: f };
1434
+ break;
1435
+ }
1436
+ // Not gated by asOf: the path view lists components regardless of asOf too.
1437
+ // Findings stay gated by observed_at (the path view does not list findings).
1438
+ case "cmp": {
1439
+ const c = find("components");
1440
+ if (c)
1441
+ record = { kind: "component", record: c };
1442
+ break;
1443
+ }
1444
+ }
1445
+ return { id, record, ...(asOf ? { asOf } : {}) };
1446
+ }
1384
1447
  /** Transitive blast radius: every symbol/component that (in)directly depends on
1385
1448
  * `id`, via a recursive CTE over the edges graph (hunch_get_dependents). We
1386
1449
  * walk edges BACKWARD (edges.to = current) following call/dep/import/contains. */
@@ -2334,6 +2397,22 @@ function inWindow(valid_from, valid_to, asOf) {
2334
2397
  return false;
2335
2398
  return true;
2336
2399
  }
2400
+ /** A decision's supersedes/superseded_by chain over `decs` (the readable, already
2401
+ * in-effect decisions), oldest first, cycle-guarded. `d` is the record as shown. */
2402
+ function supersessionChain(d, decs) {
2403
+ const seen = new Set([d.id]);
2404
+ const back = [];
2405
+ for (let cur = d.supersedes ? decs.get(d.supersedes) : undefined; cur && !seen.has(cur.id); cur = cur.supersedes ? decs.get(cur.supersedes) : undefined) {
2406
+ seen.add(cur.id);
2407
+ back.unshift(cur);
2408
+ }
2409
+ const fwd = [];
2410
+ for (let cur = d.superseded_by ? decs.get(d.superseded_by) : undefined; cur && !seen.has(cur.id); cur = cur.superseded_by ? decs.get(cur.superseded_by) : undefined) {
2411
+ seen.add(cur.id);
2412
+ fwd.push(cur);
2413
+ }
2414
+ return [...back, d, ...fwd].map((x) => ({ id: x.id, status: x.status, title: x.title }));
2415
+ }
2337
2416
  function round(n) {
2338
2417
  return Math.round(n * 100) / 100;
2339
2418
  }
@@ -6,8 +6,11 @@ export declare const MAX_JSON_RECORD_BYTES: number;
6
6
  export declare const MAX_JSON_INDEX_BYTES: number;
7
7
  export declare const MAX_JSON_MANIFEST_BYTES: number;
8
8
  export declare const MAX_JSON_DIRECTORY_ENTRIES_PER_KIND = 100000;
9
+ /** When the process now holding `pid` was created (wall-clock ms), or null. */
10
+ type StartedAtProbe = (pid: number) => number | null;
9
11
  export declare class JsonStore {
10
12
  private readonly paths;
13
+ private readonly lockOpts;
11
14
  private _warnedForward;
12
15
  /** Memoized validated records per kind. loadAll() is on every read-path method
13
16
  * (why/checkConstraints/fragility/assembleContext…), each of which previously
@@ -20,7 +23,13 @@ export declare class JsonStore {
20
23
  private readonly lexicalRoot;
21
24
  private readonly canonicalRoot;
22
25
  private readonly lexicalHunch;
23
- constructor(paths: HunchPaths);
26
+ /** `lockOpts.startedAt` is an INTERNAL TEST SEAM, not API: it replaces the OS
27
+ * creation-time probe of the `.rmw-lock` "unproven" rule so the suite can
28
+ * drive it on linux, where the real probe reports nothing. Callers outside
29
+ * this repo must leave it unset — `processStartedAt` is the only sound probe. */
30
+ constructor(paths: HunchPaths, lockOpts?: {
31
+ startedAt?: StartedAtProbe;
32
+ });
24
33
  private lstatOrMissing;
25
34
  private expectedCanonical;
26
35
  /** Require an ordinary, canonically-contained directory. Creation is one
@@ -125,3 +134,4 @@ export declare class JsonStore {
125
134
  skipped: number;
126
135
  };
127
136
  }
137
+ export {};
@@ -11,7 +11,7 @@ import { ENTITY_KINDS, SCHEMAS } from "../core/types.js";
11
11
  import { BASELINE_VERSION, migrateRaw, SCHEMA_VERSION } from "../core/migrate.js";
12
12
  import { writeFileAtomic } from "../core/io.js";
13
13
  import { readStoreArtifact } from "../core/storeArtifact.js";
14
- import { heldLockNonces, judgeSameHostOwner, selfStartToken, startTokenOf } from "../core/procstart.js";
14
+ import { createdAfterLock, heldLockNonces, judgeSameHostOwner, processStartedAt, selfStartToken, startTokenOf } from "../core/procstart.js";
15
15
  /** High-cardinality collections (symbols, edges) are stored as a single
16
16
  * index.json array — there can be thousands, and one file per edge would create
17
17
  * enormous git noise. Curated, low-volume entities (components, decisions, bugs,
@@ -118,6 +118,7 @@ function readRmwOwnerOnce(lock) {
118
118
  host: parsed.host,
119
119
  ...(typeof parsed.nonce === "string" ? { nonce: parsed.nonce } : {}),
120
120
  ...(typeof parsed.start === "string" ? { start: parsed.start } : {}),
121
+ ...(typeof parsed.at === "string" ? { at: parsed.at } : {}),
121
122
  };
122
123
  }
123
124
  catch {
@@ -143,9 +144,14 @@ const RMW_CLAIM_STALE_MS = 10_000;
143
144
  * The owner's recorded process-instance token settles that by string equality
144
145
  * (core/procstart.ts); no clock is compared, because a lock's mtime and a start
145
146
  * time come from different clocks and any skew would "prove" a live owner
146
- * recycled. `readRmwOwner` stays FIRST: it throws on unsafe/hardlinked or
147
- * oversized ownership metadata, and unsafe ownership never licenses removal. */
148
- function rmwLockIsStale(lock) {
147
+ * recycled. Where no token exists (macOS, Windows) a live pid is "unproven",
148
+ * and an AGED lock is still taken over once the OS proves the process now
149
+ * holding that pid was created after the lock's recorded `at` (issue #457) —
150
+ * the same `createdAfterLock` rule as the write lock. `startedAt` is
151
+ * memoized per acquisition by the caller, because the OS probe spawns.
152
+ * `readRmwOwner` stays FIRST: it throws on unsafe/hardlinked or oversized
153
+ * ownership metadata, and unsafe ownership never licenses removal. */
154
+ function rmwLockIsStale(lock, startedAt) {
149
155
  let stat;
150
156
  try {
151
157
  stat = lstatSync(lock);
@@ -157,9 +163,10 @@ function rmwLockIsStale(lock) {
157
163
  }
158
164
  const owner = readRmwOwner(lock);
159
165
  if (owner && owner.host === hostname()) {
160
- // One shared table with src/serve/writelock.ts (core/procstart.ts), so the
161
- // two locks can never drift apart; "age" means nothing was proven and the
162
- // fallback below governs, exactly as for a foreign host.
166
+ // One shared table with src/serve/writelock.ts (core/procstart.ts), and one
167
+ // shared creation-time check for an "unproven" pid, so the two locks apply
168
+ // the same rule; "age" means nothing was proven and the fallback below
169
+ // governs, exactly as for a foreign host.
163
170
  const verdict = judgeSameHostOwner(owner, {
164
171
  alive: rmwPidAlive,
165
172
  self: selfStartToken(),
@@ -168,6 +175,8 @@ function rmwLockIsStale(lock) {
168
175
  selfPid: process.pid,
169
176
  heldNonces: heldLockNonces,
170
177
  });
178
+ if (verdict === "unproven")
179
+ return Date.now() - stat.mtimeMs > 10_000 && createdAfterLock(owner, startedAt);
171
180
  if (verdict !== "age")
172
181
  return verdict === "stale";
173
182
  }
@@ -210,7 +219,7 @@ function rmwLockIsStale(lock) {
210
219
  * so the race persists until every writer on the store is upgraded. Returns true
211
220
  * when the path may now be free.
212
221
  */
213
- function takeOverRmwLock(lock) {
222
+ function takeOverRmwLock(lock, startedAt) {
214
223
  const claim = `${lock}.reclaim`;
215
224
  try {
216
225
  mkdirSync(claim);
@@ -229,7 +238,7 @@ function takeOverRmwLock(lock) {
229
238
  return false;
230
239
  }
231
240
  try {
232
- if (!rmwLockIsStale(lock))
241
+ if (!rmwLockIsStale(lock, startedAt))
233
242
  return false; // re-judged from scratch under the claim
234
243
  rmSync(lock, { recursive: true, force: true });
235
244
  return true;
@@ -253,6 +262,7 @@ function unsafePath(path, reason) {
253
262
  }
254
263
  export class JsonStore {
255
264
  paths;
265
+ lockOpts;
256
266
  _warnedForward = false;
257
267
  /** Memoized validated records per kind. loadAll() is on every read-path method
258
268
  * (why/checkConstraints/fragility/assembleContext…), each of which previously
@@ -265,8 +275,13 @@ export class JsonStore {
265
275
  lexicalRoot;
266
276
  canonicalRoot;
267
277
  lexicalHunch;
268
- constructor(paths) {
278
+ /** `lockOpts.startedAt` is an INTERNAL TEST SEAM, not API: it replaces the OS
279
+ * creation-time probe of the `.rmw-lock` "unproven" rule so the suite can
280
+ * drive it on linux, where the real probe reports nothing. Callers outside
281
+ * this repo must leave it unset — `processStartedAt` is the only sound probe. */
282
+ constructor(paths, lockOpts = {}) {
269
283
  this.paths = paths;
284
+ this.lockOpts = lockOpts;
270
285
  this.lexicalRoot = resolve(paths.root);
271
286
  this.canonicalRoot = realpathSync(this.lexicalRoot);
272
287
  this.lexicalHunch = resolve(paths.hunch);
@@ -666,6 +681,15 @@ export class JsonStore {
666
681
  const lock = join(directory.lexical, ".rmw-lock");
667
682
  const deadline = Date.now() + 2_000;
668
683
  const nonce = randomBytes(8).toString("hex");
684
+ // The creation-time probe spawns; ask it once per pid for this acquisition,
685
+ // not on every poll nor again under the takeover claim (as writelock.ts).
686
+ const createdAt = new Map();
687
+ const probe = this.lockOpts.startedAt ?? processStartedAt;
688
+ const startedAt = (pid) => {
689
+ if (!createdAt.has(pid))
690
+ createdAt.set(pid, probe(pid));
691
+ return createdAt.get(pid);
692
+ };
669
693
  for (;;) {
670
694
  try {
671
695
  mkdirSync(lock);
@@ -676,10 +700,11 @@ export class JsonStore {
676
700
  // writer may exceed the stale-age heuristic while serializing a large
677
701
  // index; its PID must prevent a second writer from taking over. The
678
702
  // process-instance token (when this platform has one) is what lets a
679
- // successor tell that PID apart from a recycled one.
703
+ // successor tell that PID apart from a recycled one; without one, `at`
704
+ // lets it prove the pid's current process was created after this lock.
680
705
  try {
681
706
  const start = selfStartToken();
682
- writeFileAtomic(join(lock, "owner.tmp.json"), JSON.stringify({ pid: process.pid, host: hostname(), nonce, ...(start !== null ? { start } : {}) }));
707
+ writeFileAtomic(join(lock, "owner.tmp.json"), JSON.stringify({ pid: process.pid, host: hostname(), nonce, at: new Date().toISOString(), ...(start !== null ? { start } : {}) }));
683
708
  }
684
709
  catch (error) {
685
710
  try {
@@ -694,7 +719,7 @@ export class JsonStore {
694
719
  catch (error) {
695
720
  if (error.code !== "EEXIST")
696
721
  throw error;
697
- if (rmwLockIsStale(lock) && takeOverRmwLock(lock))
722
+ if (rmwLockIsStale(lock, startedAt) && takeOverRmwLock(lock, startedAt))
698
723
  continue;
699
724
  // The deadline is checked only AFTER an acquire has been attempted: with
700
725
  // the check at the top of the loop, a successful takeover could still be
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.45.0",
3
+ "version": "1.45.2",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.45.0",
10
+ "version": "1.45.2",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.45.0",
16
+ "version": "1.45.2",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {