@davesheffer/hunch 1.44.0 → 1.45.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 CHANGED
@@ -127,7 +127,7 @@ hunch task list
127
127
  hunch report <task-id> --html
128
128
  ```
129
129
 
130
- A delivered lesson, an agent's claim, and a passing test are different evidence. Hunch keeps them separate. Automatic presentation depends on the host following the task lifecycle; missing evidence stays unverified. [Read the reporting guide](docs/task-reports.md).
130
+ A delivered lesson, an agent's claim, and a passing test are different evidence. Hunch keeps them separate. Automatic presentation depends on the host following the task lifecycle; missing evidence stays unverified.
131
131
 
132
132
  ## Share one living repository memory with your team
133
133
 
@@ -152,19 +152,19 @@ Hunch ships `hunch serve`: a self-hosted HTTP service for organization, team, us
152
152
 
153
153
  Open `/operator` on your server to inspect current records, completed work and commitments in a read-only browser view. Writer-supplied citations can point to an exact summary field or text passage and its recorded sources. They show traceability; they do not prove that a source supports a claim.
154
154
 
155
- Agents can use the same contract through MCP, `hunch state read|write|records|subscribe`, the `@davesheffer/hunch/state` TypeScript client, or the [Python client](docs/python-state-client.md). The Python package is built and tested from this repository; it is not yet published to PyPI. [Scoped conventions](docs/scoped-conventions.md) let a person record sourced user, team or organization preferences. Those preferences remain advisory and do not silently become blocking rules.
155
+ Agents can use the same contract through MCP, `hunch state read|write|records|subscribe`, the `@davesheffer/hunch/state` TypeScript client, or the Python client. The Python package is built and tested from this repository; it is not yet published to PyPI. Scoped conventions let a person record sourced user, team or organization preferences. Those preferences remain advisory and do not silently become blocking rules.
156
156
 
157
157
  Records can describe decisions, action outcomes, commitments, entities, relationships, and summaries that name their dependencies. Actions retain their status, including unknown or unverified outcomes. Repeated writes have stable identities, conflicting current decisions are refused, and confirmed human records receive protections against agent overwrites. These are defined checks on structured records; Hunch cannot establish every fact in the outside world on its own.
158
158
 
159
159
  This is what **deterministic state** means here: explicit rules govern the stored record, rather than having each agent reconstruct it from scratch. Git holds the durable data; SQLite is a rebuildable index. The server binds to loopback and requires deployment and agent integration by its operator. Hunch does not provide a managed CRM or email connector service.
160
160
 
161
- [Set up and understand the state server](docs/deterministic-state.md) · [State contract and client reference](docs/nuryel-state-contract.md) · [Upgrade to 1.33](docs/upgrade-1.33.md)
161
+ See the [full documentation](https://www.hunchmemory.com/docs).
162
162
 
163
163
  ### The vision, and what is still being tested
164
164
 
165
165
  The vision is continuity across people, tools, and agents: an operations agent records a customer issue, a coding agent finds the relevant decision and fixes the cause, and the operations agent closes the commitment using evidence of the fix.
166
166
 
167
- The underlying memory and state tools ship today. The broader claim—that different agents reliably use that shared state and avoid contradictory work—is still being measured in the Sofia pilot. Simulated results and a limited live pilot are not proof of that outcome across organizations. The [roadmap](ROADMAP.md) tracks the remaining acceptance gates.
167
+ The underlying memory and state tools ship today. The broader claim—that different agents reliably use that shared state and avoid contradictory work—is still being measured.
168
168
 
169
169
  **Hunch remains the product name.** The `nuryel.state/1` protocol and `nuryel_*` tool names are existing technical identifiers; a possible future rename is undecided.
170
170
 
@@ -178,7 +178,7 @@ hunch dna context
178
178
  hunch dna diff <older-ref> <newer-ref>
179
179
  ```
180
180
 
181
- Profiles retain their revision, sources, confidence, and freshness. [Project DNA contract](docs/project-dna.md) · [Broader DNA vision](docs/project-dna-engine.md)
181
+ Profiles retain their revision, sources, confidence, and freshness.
182
182
 
183
183
  ## Your data and your authority
184
184
 
@@ -192,18 +192,8 @@ Profiles retain their revision, sources, confidence, and freshness. [Project DNA
192
192
 
193
193
  - [Full documentation](https://www.hunchmemory.com/docs)
194
194
  - [Copy-paste cookbook](https://www.hunchmemory.com/cookbook)
195
- - [Task contribution reports](docs/task-reports.md)
196
- - [Deterministic organizational state](docs/deterministic-state.md)
197
- - [Project DNA](docs/project-dna.md)
198
- - [Native change proof](docs/change-proof.md)
199
- - [Engineering Landscape](docs/engineering-landscape.md)
200
- - [Review memory](docs/review-memory.md)
201
- - [Agent-origin handling](docs/agent-origin.md)
202
- - [Autonomy ladder](docs/autonomy-ladder.md)
203
- - [Autonomous development](https://github.com/davesheffer/hunch-private/blob/main/projects/hunch/docs/autonomous-development.md)
204
- - [Changelog](CHANGELOG.md) · [Roadmap](ROADMAP.md)
195
+ - [Changelog](CHANGELOG.md)
205
196
  - [VS Code extension](vscode-extension/README.md)
206
- - [Architecture benchmark](bench/architectural-conformance.md)
207
197
  - [Contributing](CONTRIBUTING.md)
208
198
 
209
199
  Apache-2.0
package/dist/cli/index.js CHANGED
@@ -60,7 +60,7 @@ import { installPostCommitHook, installPreCommitHook, installPostMergeHook, inst
60
60
  import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
61
61
  import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor } from "../integrations/sync.js";
62
62
  import { installMergeDriver } from "../integrations/mergeDriver.js";
63
- import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS, upgradeManagedGitignore, describeGitignoreUpgrade } from "../integrations/gitignore.js";
63
+ import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS, upgradeManagedGitignore, describeGitignoreUpgrade, memoryIgnoreRule, describeMemoryIgnoreRule } from "../integrations/gitignore.js";
64
64
  import { writeCiWorkflow } from "../integrations/ciAction.js";
65
65
  import { applyUninstall, formatUninstallPlan, isOptedOut, OPT_OUT_FILE, optOutMessage, planUninstall } from "../integrations/uninstall.js";
66
66
  import { updateClaudeMd, renderHunchSection } from "../integrations/claudemd.js";
@@ -69,7 +69,7 @@ import { measureFootprint } from "../core/footprint.js";
69
69
  import { classifyGroundingBlock, describeGroundingFreshness } from "../core/groundingLag.js";
70
70
  import { mergeGroundingFile } from "../core/groundingMerge.js";
71
71
  import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
72
- import { scaffoldProviders, regenerateGrounding, refreshExistingGrounding, refreshCommittableGrounding, GROUNDING_DOC_PATHS } from "../integrations/providers.js";
72
+ import { scaffoldProviders, detectAssistants, PROVIDER_ASSISTANTS, regenerateGrounding, refreshExistingGrounding, refreshCommittableGrounding, GROUNDING_DOC_PATHS } from "../integrations/providers.js";
73
73
  import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
74
74
  import { formatSearchHit, formatStructure } from "../core/format.js";
75
75
  import { isStateKind, renderStateLine, stateSupplements } from "../core/stateDelivery.js";
@@ -94,7 +94,7 @@ import { isHumanConfirmed } from "../core/strictgate.js";
94
94
  import { appendEvent, readEvents } from "../core/events.js";
95
95
  import { computeStats, formatStats } from "../core/stats.js";
96
96
  import { clearTaskSelection, injectionMode, loadTaskSelection, resetSessionInjections, saveTaskSelection, taskSelectionEnabled } from "../core/hookcache.js";
97
- import { isBareFollowUp, isFilterableSelectionId, liveSelectionRecords, selectForTask } from "../core/taskSelection.js";
97
+ import { isBareFollowUp, isFilterableSelectionId, liveSelectionRecords, promptPaths, selectForTask } from "../core/taskSelection.js";
98
98
  import { recordServed, servedSummary } from "../core/served.js";
99
99
  import { readTaskReport, recordTaskDelivery, reportActivity, reportHash, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
100
100
  import { snapshotDeliveredRecords } from "../core/taskReportEvidence.js";
@@ -102,7 +102,7 @@ import { renderRecalledLine } from "../core/taskReportRender.js";
102
102
  import { closeHookTask, hookReportTaskId, isNotificationPrompt, nativeHookCwd, settleHookSession, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
103
103
  import { persistTaskRecord } from "../core/taskRecord.js";
104
104
  import { recordHookObservation } from "../core/hookObservations.js";
105
- import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
105
+ import { contextHookOutput, deliversPromptEvent, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
106
106
  import { PIPELINE_LOOP, armExecutionObligations, beforeEditProbeVerdict, compileExecutableProbes, environmentExecutableProbes, environmentExecutionObligations, executionObligationBrief, isProductPath, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, proofCheckpoint, savePipelineState, stopVerdict, onLessonsDelivered, lessonReminder, unverifiedNag, } from "../core/pipeline.js";
107
107
  import { draftDuplicateOf, isAcceptedDuplicateAnchor } from "../core/dupdetect.js";
108
108
  import { planAutoReview, planMutations } from "../core/autoreview.js";
@@ -118,7 +118,7 @@ import { countersignConstraint, countersignDecision } from "../core/countersign.
118
118
  import { applyImportedAdrReview, carryImportedAdrReview, importedAdrReviewHash, importedAdrSourceHash, isImportedAdrDecision, pendingImportedAdrReviews } from "../core/importReview.js";
119
119
  import { exportMadrCorpus, isRegenerableMadr } from "../integrations/madrExport.js";
120
120
  import { buildMadrManifest, writeMadrManifest, refreshMadrCorpus } from "../integrations/madrManifest.js";
121
- import { pendingEscalations, policyEscalations, commitRepairEscalations, actionableEscalations, escalationHeadline } from "../core/escalations.js";
121
+ import { pendingEscalations, policyEscalations, commitRepairEscalations, actionableEscalations, escalationHeadline, relevantEscalations } from "../core/escalations.js";
122
122
  import { premiseEscalations } from "../core/premises.js";
123
123
  import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
124
124
  import { pathMatchesGlob } from "../core/glob.js";
@@ -354,6 +354,8 @@ program
354
354
  .option("--private-sync", "post-commit synthesis writes captured decisions into the overlay repo (HUNCH_PRIVATE_DIR), never the public store")
355
355
  .option("--shared-sync", "alias of --private-sync (for teams using one shared overlay repo for any code repo)")
356
356
  .option("--no-auto-commit", "DON'T auto-commit captures (default: ON in every mode — the overlay repo is committed+pushed; the public .hunch/ is committed only and rides your next push)")
357
+ .option("--all-assistants", "configure every supported assistant, not only those found on this machine or in this repository")
358
+ .option("--details", "print every setup step and the per-assistant integration status")
357
359
  .action((opts) => {
358
360
  // Validate --firmness up front, before any side effects (indexing, git hooks,
359
361
  // .mcp.json) or opening the store — a bad value must not leave a half-init.
@@ -367,32 +369,51 @@ program
367
369
  // teammate/agent resolves the same single source of truth with zero manual setup.
368
370
  const { store, teamWired } = openTeamStore(root);
369
371
  const inv = resolveInvocation();
370
- console.log(`🧠 Initializing Hunch at ${root}`);
372
+ // Step lines are collected: the default output is a short summary with one next
373
+ // step; `--details` prints every step as written. Warnings always print.
374
+ const lines = [];
375
+ const say = (text) => { lines.push({ text, warn: text.includes("⚠") }); };
376
+ // A hook warning is followed by the snippet to add by hand: keep the group together.
377
+ const sayHook = (group, installed) => {
378
+ const warn = group.some((l) => l.includes("⚠"));
379
+ for (const text of group)
380
+ lines.push({ text, warn });
381
+ if (!warn)
382
+ summary.hooks.push(installed);
383
+ };
384
+ const summary = { index: "", hooks: [], assistants: ["Claude Code"], skipped: [] };
385
+ say(`🧠 Initializing Hunch at ${root}`);
371
386
  store.json.ensureDirs(); // stamps the manifest at the current version when fresh
372
- console.log(` ✓ .hunch/ scaffolded (schema v${readManifest(paths).schema_version})`);
387
+ say(` ✓ .hunch/ scaffolded (schema v${readManifest(paths).schema_version})`);
373
388
  if (teamWired)
374
- console.log(` ✓ connected to the team's shared memory store (from .hunch/team.json) → ${teamWired}`);
389
+ say(` ✓ connected to the team's shared memory store (from .hunch/team.json) → ${teamWired}`);
375
390
  // Exclude the derived SQLite index BEFORE it's written, so the working tree
376
391
  // never goes dirty on the MCP server's index writes (which blocks branch
377
392
  // switches). The .hunch/*.json graph stays tracked.
378
393
  const gi = ensureGitignore(root);
379
394
  if (gi.action !== "unchanged")
380
- console.log(` ✓ .gitignore ${gi.action} (Hunch runtime index excluded)`);
395
+ say(` ✓ .gitignore ${gi.action} (Hunch runtime index excluded)`);
381
396
  // A repo migrated to a private overlay by an older release: bring its private-only
382
397
  // block up to date (and stop publishing newly ignored memory dirs).
383
398
  for (const line of describeGitignoreUpgrade(upgradeManagedGitignore(root)))
384
- console.log(` ✓ ${line}`);
399
+ say(` ✓ ${line}`);
400
+ if (!store.privateDir) {
401
+ const ignored = memoryIgnoreRule(root);
402
+ if (ignored)
403
+ say(` ⚠ ${describeMemoryIgnoreRule(ignored)}`);
404
+ }
385
405
  if (opts.index !== false) {
386
406
  if (isGitRepo(root) && revExists("HEAD", root)) {
387
407
  const res = indexRepo(store, root, { source: { kind: "commit", ref: "HEAD" } });
388
408
  store.reindex();
389
- console.log(` ✓ indexed committed HEAD (${res.files} files) → ${res.symbols} symbols, ${res.edges} edges, ${res.components} components`);
409
+ say(` ✓ indexed committed HEAD (${res.files} files) → ${res.symbols} symbols, ${res.edges} edges, ${res.components} components`);
410
+ summary.index = `indexed committed HEAD (${res.files} files, ${res.symbols} symbols)`;
390
411
  for (const item of res.coverage) {
391
412
  const reasons = Object.entries(item.reasons).map(([reason, count]) => `${reason}:${count}`).join(", ");
392
- console.log(` coverage ${item.language}: ${item.eligible} eligible · ${item.parsed} parsed · ${item.skipped} skipped${reasons ? ` (${reasons})` : ""}`);
413
+ say(` coverage ${item.language}: ${item.eligible} eligible · ${item.parsed} parsed · ${item.skipped} skipped${reasons ? ` (${reasons})` : ""}`);
393
414
  }
394
415
  if (res.skipped)
395
- console.log(` ⚠ ${res.skipped} file(s) could not be parsed (skipped)`);
416
+ say(` ⚠ ${res.skipped} file(s) could not be parsed (skipped)`);
396
417
  }
397
418
  else {
398
419
  // A checkout-derived graph can be swept into a later memory commit by
@@ -402,7 +423,7 @@ program
402
423
  store.json.replaceAll("edges", mergeScannedEdges(store.json.loadAll("edges"), []));
403
424
  store.json.replaceAll("components", []);
404
425
  store.reindex();
405
- console.log(" ⚠ skipped code graph: no committed HEAD — commit code, then run `hunch index`");
426
+ say(" ⚠ skipped code graph: no committed HEAD — commit code, then run `hunch index`");
406
427
  }
407
428
  }
408
429
  // Auto-commit is ON by default in every mode; --no-auto-commit persists the opt-out in
@@ -424,11 +445,11 @@ program
424
445
  }
425
446
  }
426
447
  if (existing === null) {
427
- console.warn(` ⚠ refusing to rewrite ${localFile}: it is not a JSON object and may hold your private-overlay pointer. Fix or remove it, then re-run \`hunch init --no-auto-commit\`.`);
448
+ say(` ⚠ refusing to rewrite ${localFile}: it is not a JSON object and may hold your private-overlay pointer. Fix or remove it, then re-run \`hunch init --no-auto-commit\`.`);
428
449
  }
429
450
  else {
430
451
  writeFileAtomic(localFile, JSON.stringify({ ...existing, autoCommit: false }, null, 2) + "\n");
431
- console.log(" ✓ local auto-commit preference OFF (an explicit hook --commit flag still takes precedence)");
452
+ say(" ✓ local auto-commit preference OFF (an explicit hook --commit flag still takes precedence)");
432
453
  }
433
454
  }
434
455
  // Collected so the linked-worktree note below can say what actually happened
@@ -446,21 +467,18 @@ program
446
467
  }
447
468
  const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: hookCommit, localOnly: syncToOverlay });
448
469
  installs.push(h);
449
- for (const line of formatHookInstall(root, "post-commit hook", h, ` (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${hookCommit ? " — auto-commit on" : ""}`))
450
- console.log(line);
470
+ sayHook(formatHookInstall(root, "post-commit hook", h, ` (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${hookCommit ? " — auto-commit on" : ""}`), hookCommit ? "post-commit (auto-commit on)" : "post-commit");
451
471
  if (opts.autoCommit === false && hookReport(root).postCommit.flags?.includes("--commit")) {
452
- console.log(" ⚠ the shared post-commit hook still auto-commits. To change it, re-run `hunch init --no-auto-commit` using a global install or the main checkout's Hunch.");
472
+ say(" ⚠ the shared post-commit hook still auto-commits. To change it, re-run `hunch init --no-auto-commit` using a global install or the main checkout's Hunch.");
453
473
  }
454
474
  const pm = installPostMergeHook(root, inv.shell);
455
475
  installs.push(pm);
456
- for (const line of formatHookInstall(root, "post-merge hook", pm, " (squash-merge provenance repair + re-syncs grounding docs after a merge that brought memory in)"))
457
- console.log(line);
476
+ sayHook(formatHookInstall(root, "post-merge hook", pm, " (squash-merge provenance repair + re-syncs grounding docs after a merge that brought memory in)"), "post-merge");
458
477
  const pc = installPostCheckoutHook(root, inv.shell);
459
478
  installs.push(pc);
460
- for (const line of formatHookInstall(root, "post-checkout hook", pc, " (workspace ledger: records this machine's branches + worktrees on checkout)"))
461
- console.log(line);
479
+ sayHook(formatHookInstall(root, "post-checkout hook", pc, " (workspace ledger: records this machine's branches + worktrees on checkout)"), "post-checkout");
462
480
  const m = installMergeDriver(root, inv.shell);
463
- console.log(` ✓ team merge driver ${m.action}`);
481
+ say(` ✓ team merge driver ${m.action}`);
464
482
  // Auto-install the pre-commit guard by default (advisory: flags invariants
465
483
  // touched directly OR via blast radius, never blocks). Opt out with
466
484
  // --no-enforce; --enforce-strict makes blocking near/direct hits fail the commit.
@@ -468,12 +486,11 @@ program
468
486
  const strict = !!opts.enforceStrict;
469
487
  const p = installPreCommitHook(root, inv.shell, strict);
470
488
  installs.push(p);
471
- for (const line of formatHookInstall(root, "pre-commit constraint guard", p, ` (${strict ? "strict — fails only on direct, high-confidence, non-stale blocking invariants" : "advisory — flags invariants in scope or blast radius"})`))
472
- console.log(line);
489
+ sayHook(formatHookInstall(root, "pre-commit constraint guard", p, ` (${strict ? "strict — fails only on direct, high-confidence, non-stale blocking invariants" : "advisory — flags invariants in scope or blast radius"})`), `pre-commit guard (${strict ? "strict" : "advisory"})`);
473
490
  }
474
491
  }
475
492
  else {
476
- console.log(" ⚠ not a git repo — skipped hooks (run `git init` to enable the learning loop)");
493
+ say(" ⚠ not a git repo — skipped hooks (run `git init` to enable the learning loop)");
477
494
  }
478
495
  // .mcp.json is the CANONICAL registration: Claude Code resolves it by file path,
479
496
  // so it's immune to the Windows ~/.claude.json drive-letter case-split that a
@@ -481,18 +498,18 @@ program
481
498
  // existing file is refused (con_8460b6770f) — warn and continue, never clobber.
482
499
  try {
483
500
  const mcp = writeMcpJson(root, inv.mcp);
484
- console.log(` ✓ wrote ${rel(root, mcp)} (registers the Hunch MCP server — canonical, path-keyed; prefer over a global \`claude mcp add\`)`);
501
+ say(` ✓ wrote ${rel(root, mcp)} (registers the Hunch MCP server — canonical, path-keyed; prefer over a global \`claude mcp add\`)`);
485
502
  }
486
503
  catch (e) {
487
- console.log(` ⚠ skipped .mcp.json: ${e.message}`);
504
+ say(` ⚠ skipped .mcp.json: ${e.message}`);
488
505
  }
489
506
  const cmds = writeSlashCommands(root);
490
- console.log(` ✓ wrote ${cmds.written.length} slash commands (/hunch-why, /hunch-fix, /hunch-fragile, /capture, /heal, /audit)`);
507
+ say(` ✓ wrote ${cmds.written.length} slash commands (/hunch-why, /hunch-fix, /hunch-fragile, /capture, /heal, /audit)`);
491
508
  for (const s of cmds.skipped) {
492
- console.log(` ⚠ kept your existing ${rel(root, s)} (no hunch:generated marker — delete the file and re-run init to adopt Hunch's version)`);
509
+ say(` ⚠ kept your existing ${rel(root, s)} (no hunch:generated marker — delete the file and re-run init to adopt Hunch's version)`);
493
510
  }
494
511
  const cmd = updateClaudeMd(root, store);
495
- console.log(` ✓ updated ${rel(root, cmd)} with ambient Hunch context`);
512
+ say(` ✓ updated ${rel(root, cmd)} with ambient Hunch context`);
496
513
  // Firmness: stamp .hunch/config.json (default advisory) so `hunch hook` reads a
497
514
  // level even before the user runs `hunch firmness` (--firmness validated above).
498
515
  const firmness = writeConfig(paths, opts.firmness ? { firmness: opts.firmness } : {}).firmness;
@@ -500,18 +517,25 @@ program
500
517
  // adapter reads firmness at run time, so changing it needs no config rewrite.
501
518
  if (opts.agentHooks !== false) {
502
519
  const a = installClaudeHooks(root, `${inv.agentHookShell} hook`);
503
- console.log(` ✓ Claude Code agent hooks ${a.action} (firmness: ${firmness} — change with \`hunch firmness <level>\`)`);
520
+ say(` ✓ Claude Code agent hooks ${a.action} (firmness: ${firmness} — change with \`hunch firmness <level>\`)`);
504
521
  }
505
522
  // Multi-assistant compatibility: MCP + grounding + lifecycle adapters share
506
523
  // the same .hunch/ graph across Cursor / VS Code / Windsurf / Antigravity.
507
524
  if (opts.providers !== false) {
508
- const ps = scaffoldProviders(root, inv.mcp, store, { agentHooks: opts.agentHooks !== false });
525
+ const detected = detectAssistants(root);
526
+ const only = opts.allAssistants || detected.size === 0 ? undefined : detected;
527
+ const ps = scaffoldProviders(root, inv.mcp, store, { agentHooks: opts.agentHooks !== false, only });
509
528
  const ok = ps.filter((p) => !p.error);
529
+ summary.assistants.push(...ok.map((p) => p.assistant === "Any (AGENTS.md)" ? "any tool that reads AGENTS.md" : p.assistant));
530
+ if (only)
531
+ summary.skipped = PROVIDER_ASSISTANTS.filter((a) => !only.has(a));
532
+ if (summary.skipped.length)
533
+ say(` ✓ not configured (not found on this machine or in this repository): ${summary.skipped.join(", ")} — add them with \`hunch init --all-assistants\``);
510
534
  const total = ok.reduce((a, p) => a + p.files.length, 0);
511
- console.log(` ✓ wrote ${total} multi-assistant config file(s) → ${ok.map((p) => p.assistant).join(", ")}`);
535
+ say(` ✓ wrote ${total} multi-assistant config file(s) → ${ok.map((p) => p.assistant).join(", ")}`);
512
536
  for (const p of ps)
513
537
  if (p.error)
514
- console.log(` ⚠ skipped ${p.assistant}: ${p.error}`);
538
+ say(` ⚠ skipped ${p.assistant}: ${p.error}`);
515
539
  }
516
540
  // Windows self-heal: if an earlier global `claude mcp add` left a drive-letter
517
541
  // case-split in ~/.claude.json, merge it so hunch resolves under either casing.
@@ -521,16 +545,36 @@ program
521
545
  // worktree of this repo auto-discovers it (also backfills pre-0.32 single-worktree setups),
522
546
  // and note when we're initializing inside a linked worktree (memory is shared, not separate).
523
547
  if (ensureSharedOverlayPointer(root, store.privateDir, store.privateAutoCommit, store.mode === "shared" ? "shared" : "private", true)) {
524
- console.log(` ✓ private overlay registered at the git common dir — shared by every worktree of this repo`);
548
+ say(` ✓ private overlay registered at the git common dir — shared by every worktree of this repo`);
525
549
  }
526
550
  for (const line of sharedHooksNote(root, installs))
527
- console.log(line);
551
+ say(line);
528
552
  store.close();
529
- console.log("\n" + formatIntegrationHealth(inspectIntegrations(root)));
530
- console.log("\nTask reporting is configured through Hunch's agent instructions; actual agent activity has not been verified by setup. Reconnect the agent, then work normally. Completed task reports are available with `hunch report`.");
531
- console.log(reportActivity(root));
532
- console.log("Cold start? Seed from history: hunch backfill --since 90d");
533
- console.log("\n⭐ If Hunch earns its keep, a star helps others find it → https://github.com/davesheffer/hunch");
553
+ if (opts.details) {
554
+ for (const l of lines)
555
+ console.log(l.text);
556
+ console.log("\n" + formatIntegrationHealth(inspectIntegrations(root)));
557
+ console.log("\nTask reporting is configured through Hunch's agent instructions; actual agent activity has not been verified by setup. Reconnect the agent, then work normally. Completed task reports are available with `hunch report`.");
558
+ console.log(reportActivity(root));
559
+ console.log("Cold start? Seed from history: hunch backfill --since 90d");
560
+ console.log("\n⭐ If Hunch earns its keep, a star helps others find it → https://github.com/davesheffer/hunch");
561
+ return;
562
+ }
563
+ console.log(`🧠 Hunch is set up in ${root}`);
564
+ console.log(` ✓ memory: .hunch/${teamWired ? ` (connected to your team's shared store)` : ""}`);
565
+ if (summary.index)
566
+ console.log(` ✓ code index: ${summary.index}`);
567
+ if (summary.hooks.length)
568
+ console.log(` ✓ git hooks: ${summary.hooks.join(", ")}`);
569
+ console.log(` ✓ assistants: ${summary.assistants.join(", ")}`);
570
+ if (summary.skipped.length)
571
+ console.log(` not found here, so not configured: ${summary.skipped.join(", ")} (add with --all-assistants)`);
572
+ for (const l of lines)
573
+ if (l.warn)
574
+ console.log(l.text);
575
+ console.log("\nNext: reload your assistant, then ask it why a file you're about to change is built that way.");
576
+ console.log("No memory yet? Draft it from this repository's history: hunch backfill --since 90d");
577
+ console.log("More: hunch init --details (everything that was written) · hunch doctor (check the setup)");
534
578
  });
535
579
  // ---- uninstall ------------------------------------------------------------
536
580
  program
@@ -5190,6 +5234,16 @@ program
5190
5234
  }
5191
5235
  }
5192
5236
  catch { /* the next Stop or prompt retries */ }
5237
+ // Escalations this prompt's task touches (dec_12ba37db1c): asked once per
5238
+ // session, never answered for the human; the rest stay pending in
5239
+ // `hunch escalations`. A broken store must not take the prompt hook down.
5240
+ try {
5241
+ store ??= new HunchStore(paths);
5242
+ const ask = await promptEscalations(root, store, provider, evt);
5243
+ if (ask)
5244
+ parts.push(ask);
5245
+ }
5246
+ catch { /* escalations stay listed by `hunch escalations` */ }
5193
5247
  // Pipeline turn bookkeeping (fresh block budget) + the one nag that must
5194
5248
  // repeat: edits from an earlier turn still unverified.
5195
5249
  if (evt.session_id && pipelineEnabled()) {
@@ -5351,36 +5405,17 @@ program
5351
5405
  // further than a terminal.
5352
5406
  const decisions = s.advisoryRecs("decisions");
5353
5407
  const { pendingReview } = nowData(decisions, 3);
5354
- const escalations = pendingEscalations(decisions);
5355
- escalations.push(...premiseEscalations(decisions, { now: new Date().toISOString(), exists: (p) => existsSync(join(paths.root, p)) }));
5356
- // liveness checked against the full store even in private mode — a
5357
- // private-overlay decision's repair is fully answerable via
5358
- // `hunch repair-provenance` (which reads the full store), so it
5359
- // must not go silently unanswerable just because its title stays
5360
- // out of session transcripts. Only the id and commit shas surface.
5361
- const sessionStartQueue = readActivePendingRepairs(paths.root);
5362
- escalations.push(...commitRepairEscalations(sessionStartQueue, decisions, s.recs("decisions"), withheldRewrites(paths.root, sessionStartQueue)));
5363
- try {
5364
- // Constitution human moments ride the same line; a broken policy store
5365
- // must never take session-start orientation down (fail open). Public
5366
- // store only — session transcripts travel further than a terminal.
5367
- const { ConstitutionService: CS } = await import("../constitution/service.js");
5368
- escalations.push(...policyEscalations(new CS(s, paths.root).list({ publicOnly: true }).map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
5369
- }
5370
- catch { /* constitution unavailable */ }
5371
- // Only ACTIONABLE entries are worth asking inline — a duplicate-id
5372
- // commit-repair follower whose own resolution says "act on a
5373
- // different entry first" isn't a question the assistant can put to
5374
- // the human directly (#61). Filtered BEFORE the bail check below so
5375
- // "is there anything to say" and "what do we say" share one
5376
- // predicate — an escalations list that's entirely non-actionable
5377
- // must bail exactly like an empty one would.
5378
- const actionableEsc = actionableEscalations(escalations);
5408
+ // Escalations are task-scoped (dec_12ba37db1c): session start has no task
5409
+ // yet, so it asks nothing when the provider delivers prompts — the prompt
5410
+ // hook asks about the ones a prompt touches; every entry stays pending in
5411
+ // `hunch escalations`. A provider with no prompt event would never ask,
5412
+ // so its orientation event keeps asking every actionable entry.
5413
+ const actionableEsc = deliversPromptEvent(provider) ? [] : (await actionableEscalationsWithAnchors(paths.root, s)).actionable;
5379
5414
  if (!decisions.length && !actionableEsc.length) {
5380
5415
  // Fresh graph and nothing else to raise: nothing to orient on, but
5381
5416
  // the operating loop still ships. A queued commit-repair escalation
5382
- // (checked against the full store above) is enough reason NOT to
5383
- // bail here even when the visible decisions list is empty.
5417
+ // (checked against the full store) is enough reason NOT to bail here
5418
+ // even when the visible decisions list is empty.
5384
5419
  if (pipelineEnabled())
5385
5420
  emitContext(provider, "SessionStart", [PIPELINE_LOOP, controllerBrief].filter(Boolean).join("\n\n"));
5386
5421
  return;
@@ -7501,6 +7536,9 @@ program
7501
7536
  console.log(team
7502
7537
  ? `overlay: off, but .hunch/team.json advertises the team store (${team.shared_repo}) — ${isTeamStoreTrusted(root, team) ? "run \`hunch init\` to auto-connect" : "if it is your team's store, run \`hunch shared --trust\` to connect"}`
7503
7538
  : dim(`private: off — run \`hunch shared\` (or \`hunch private\`) to use one overlay repo across teammates/worktrees (or set HUNCH_PRIVATE_DIR)`));
7539
+ const ignored = memoryIgnoreRule(root);
7540
+ if (ignored)
7541
+ console.log(`memory: ⚠ ${describeMemoryIgnoreRule(ignored)}`);
7504
7542
  }
7505
7543
  // Worktree posture: linked worktrees share ONE memory via the git common dir. Only
7506
7544
  // surfaced in a linked worktree (no noise in a normal single checkout), so a
@@ -7625,6 +7663,51 @@ function shellWriteGrounding(root, store, provider, evt, written) {
7625
7663
  * A follow-up prompt widens the qualifying set rather than replacing it, so a
7626
7664
  * "go" cannot unselect the task's memory. */
7627
7665
  const TASK_SELECTION_TITLE_CHARS = 80;
7666
+ /** Every actionable escalation plus each entry id's anchor paths (a decision's
7667
+ * related_files, a policy's scope paths/globs). Titles come from the advisory
7668
+ * scope (public-only in private mode: session transcripts travel further than
7669
+ * a terminal); commit-repair liveness and the anchors read the full store, so a
7670
+ * private decision's repair stays answerable by id. */
7671
+ async function actionableEscalationsWithAnchors(root, store) {
7672
+ const decisions = store.advisoryRecs("decisions");
7673
+ const all = store.recs("decisions");
7674
+ const anchors = new Map(all.map((d) => [d.id, d.related_files]));
7675
+ const escalations = pendingEscalations(decisions);
7676
+ escalations.push(...premiseEscalations(decisions, { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
7677
+ const queue = readActivePendingRepairs(root);
7678
+ escalations.push(...commitRepairEscalations(queue, decisions, all, withheldRewrites(root, queue)));
7679
+ try {
7680
+ // Constitution human moments ride the same line; a broken policy store must
7681
+ // never take the hook down (fail open). Public store only.
7682
+ const { ConstitutionService: CS } = await import("../constitution/service.js");
7683
+ const policies = new CS(store, root).list({ publicOnly: true });
7684
+ for (const p of policies)
7685
+ anchors.set(p.id, p.scope.paths);
7686
+ escalations.push(...policyEscalations(policies.map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
7687
+ }
7688
+ catch { /* constitution unavailable */ }
7689
+ // Only ACTIONABLE entries are worth asking inline — a duplicate-id commit-repair
7690
+ // follower whose resolution says "act on a different entry first" isn't a
7691
+ // question the assistant can put to the human directly (#61).
7692
+ return { actionable: actionableEscalations(escalations), anchors };
7693
+ }
7694
+ /** The ASK line for the escalations one prompt touches, or "" (dec_12ba37db1c). */
7695
+ async function promptEscalations(root, store, provider, evt) {
7696
+ if (!evt.prompt || isNotificationPrompt(evt.prompt))
7697
+ return "";
7698
+ const { actionable, anchors } = await actionableEscalationsWithAnchors(root, store);
7699
+ if (!actionable.length)
7700
+ return "";
7701
+ // A qualifying record outside the top-K is still part of this task.
7702
+ const selection = loadTaskSelection(hookReportTaskId(root, provider, evt));
7703
+ const selectedIds = new Set([...(selection?.top ?? []), ...(selection?.qualifying ?? [])]);
7704
+ const relevant = relevantEscalations(actionable, { prompt: evt.prompt, paths: promptPaths(evt.prompt, root), selectedIds }, (id) => anchors.get(id) ?? []);
7705
+ // Each question is asked once per session, however the touched set changes.
7706
+ const unasked = relevant.filter((e) => injectionMode(evt.session_id, `prompt-escalation:${e.kind}:${e.topic}:${e.decisionIds.join(",")}`, e.question) === "full");
7707
+ if (!unasked.length)
7708
+ return "";
7709
+ return `⚖ ${unasked.length} decision(s) this task touches need the human's call — ASK the user inline, never decide for them: ${unasked.map((e) => e.question).join(" · ")}`;
7710
+ }
7628
7711
  function promptTaskSelection(root, store, provider, evt) {
7629
7712
  // A host notification is not a task prompt: the earlier selection stands untouched.
7630
7713
  if (!taskSelectionEnabled() || isNotificationPrompt(evt.prompt))
@@ -7,6 +7,7 @@
7
7
  */
8
8
  export declare const HOOK_PROVIDERS: readonly ["claude", "codex", "vscode", "windsurf", "antigravity", "cursor"];
9
9
  export type HookProvider = (typeof HOOK_PROVIDERS)[number];
10
+ export declare function deliversPromptEvent(provider: HookProvider): boolean;
10
11
  export type HunchHookEvent = "PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "UserPromptSubmit" | "SessionStart" | "SubagentStart" | "PreCompact" | "Stop";
11
12
  /** One file section of a Codex `apply_patch` payload. */
12
13
  export interface HunchPatchFile {
@@ -6,6 +6,17 @@
6
6
  * the same small, fail-open shape regardless of the assistant that emitted it.
7
7
  */
8
8
  export const HOOK_PROVIDERS = ["claude", "codex", "vscode", "windsurf", "antigravity", "cursor"];
9
+ /** Whether a provider's hooks deliver the user's prompt text (a UserPromptSubmit
10
+ * equivalent). Prompt-scoped behavior needs it; a provider without one gets that
11
+ * behavior at its session/orientation event instead. Antigravity's PreInvocation
12
+ * payload carries only conversation/invocation counters, no turn text. A Record,
13
+ * so a new provider must declare its answer. */
14
+ const PROMPT_EVENT = {
15
+ claude: true, codex: true, vscode: true, windsurf: true, cursor: true, antigravity: false,
16
+ };
17
+ export function deliversPromptEvent(provider) {
18
+ return PROMPT_EVENT[provider];
19
+ }
9
20
  function obj(value) {
10
21
  return value && typeof value === "object" && !Array.isArray(value) ? value : null;
11
22
  }
@@ -127,3 +127,23 @@ export interface PolicyLite {
127
127
  * accept/reject) is a human call. Machine conclusions never appear here as
128
128
  * approvals — every entry is a QUESTION with its explicit resolution verb. */
129
129
  export declare function policyEscalations(policies: readonly PolicyLite[]): Escalation[];
130
+ /** What one prompt is about, for task-scoped escalation delivery (dec_12ba37db1c). */
131
+ export interface EscalationScope {
132
+ /** the prompt text as the human wrote it. */
133
+ prompt: string;
134
+ /** repo-relative paths the prompt names (taskSelection.promptPaths). */
135
+ paths: readonly string[];
136
+ /** record ids the prompt's task selection put on top, when there is one. */
137
+ selectedIds?: ReadonlySet<string>;
138
+ }
139
+ /** The escalations one prompt's task touches. A prompt hook asks the human only
140
+ * about these; every other entry stays exactly as pending — still listed by
141
+ * `hunch escalations`, hunch_escalations and hunch_now, never dropped and
142
+ * never answered for the human. Deterministic explicit anchors only: a
143
+ * prompt that asks about escalations themselves (path tokens aside), an entry
144
+ * id or topic the prompt names, an id the task selection chose, or a recorded
145
+ * anchor path the prompt names (a directory covers the files under it; a glob
146
+ * covers the paths it matches). `relatedFiles` maps an entry id to its anchor
147
+ * paths: a decision's related_files, a policy's scope paths/globs. An entry
148
+ * with no anchor the prompt names stays CLI/MCP-only. */
149
+ export declare function relevantEscalations(items: readonly Escalation[], scope: EscalationScope, relatedFiles: (id: string) => readonly string[]): Escalation[];
@@ -1,6 +1,7 @@
1
1
  import { topicCollisions } from "./topics.js";
2
2
  import { liveRewrites, deadRewrites, commitRepairReviewHash, firstFor } from "./commitrepair.js";
3
3
  import { importedAdrReviewHash, importedAdrSourceHash, pendingImportedAdrReviews } from "./importReview.js";
4
+ import { pathMatchesGlob } from "./glob.js";
4
5
  /** Escalations gating surfaces (CI exit codes, "needs your call" counts/lists)
5
6
  * should act on: everything except an entry explicitly marked
6
7
  * actionable:false (#61). The full list — including non-actionable
@@ -259,4 +260,50 @@ export function policyEscalations(policies) {
259
260
  }
260
261
  return out;
261
262
  }
263
+ const trimSlash = (x) => x.replace(/\/+$/, "");
264
+ const samePathOrNested = (x, y) => {
265
+ const a = trimSlash(x), b = trimSlash(y);
266
+ return !!a && !!b && (a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`));
267
+ };
268
+ /** A recorded related path or scope glob vs a path the prompt names. A plain
269
+ * entry relates both ways by directory nesting; a glob entry matches a path it
270
+ * covers, or a directory that holds its literal prefix (`src/` ~ `src/store/**`). */
271
+ const relatedPathHit = (entry, promptPath) => {
272
+ if (!/[*?]/.test(entry))
273
+ return samePathOrNested(entry, promptPath);
274
+ if (pathMatchesGlob(promptPath, entry))
275
+ return true;
276
+ const literal = trimSlash(entry.split("/").filter((_, i, segs) => !segs.slice(0, i + 1).some((s) => /[*?]/.test(s))).join("/"));
277
+ const dir = trimSlash(promptPath);
278
+ return !!literal && !!dir && (literal === dir || literal.startsWith(`${dir}/`));
279
+ };
280
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
281
+ /** The prompt's prose without path-like tokens (a `/` or a file extension), so a
282
+ * file named `escalations.test.ts` is not a question about escalations. */
283
+ const proseOnly = (prompt) => prompt.split(/\s+/).filter((t) => { const w = t.replace(/[)\],.;:!?'"`]+$/, ""); return !w.includes("/") && !/\.[a-z0-9]{1,8}$/i.test(w); }).join(" ");
284
+ /** The escalations one prompt's task touches. A prompt hook asks the human only
285
+ * about these; every other entry stays exactly as pending — still listed by
286
+ * `hunch escalations`, hunch_escalations and hunch_now, never dropped and
287
+ * never answered for the human. Deterministic explicit anchors only: a
288
+ * prompt that asks about escalations themselves (path tokens aside), an entry
289
+ * id or topic the prompt names, an id the task selection chose, or a recorded
290
+ * anchor path the prompt names (a directory covers the files under it; a glob
291
+ * covers the paths it matches). `relatedFiles` maps an entry id to its anchor
292
+ * paths: a decision's related_files, a policy's scope paths/globs. An entry
293
+ * with no anchor the prompt names stays CLI/MCP-only. */
294
+ export function relevantEscalations(items, scope, relatedFiles) {
295
+ const prompt = scope.prompt ?? "";
296
+ if (/(?:^|[^a-z0-9-])escalations?(?:$|[^a-z0-9])/i.test(proseOnly(prompt)))
297
+ return [...items];
298
+ const lower = prompt.toLowerCase();
299
+ const named = (term) => {
300
+ const t = term.trim().toLowerCase();
301
+ if (t.length < 3)
302
+ return false;
303
+ return new RegExp(`(?:^|[^a-z0-9_-])${escapeRegExp(t)}(?:$|[^a-z0-9_-])`).test(lower);
304
+ };
305
+ return items.filter((e) => named(e.topic)
306
+ || e.decisionIds.some((id) => named(id) || scope.selectedIds?.has(id))
307
+ || e.decisionIds.some((id) => relatedFiles(id).some((file) => scope.paths.some((p) => relatedPathHit(file, p)))));
308
+ }
262
309
  //# sourceMappingURL=escalations.js.map
@@ -4,5 +4,5 @@
4
4
  export const HOOK_REMINDER = "Hunch (engineering memory) is available for this repo. Before editing, call " +
5
5
  "hunch_check_constraints(scope) for do-not-break invariants and hunch_why(target) " +
6
6
  "for the rationale; use hunch_get_dependents for blast radius and hunch_bug_lineage " +
7
- "for prior root causes. After a non-trivial choice, record it with hunch_record_decision.";
7
+ "for prior root causes. Record only what the human decides or corrects.";
8
8
  //# sourceMappingURL=hookText.js.map
@@ -98,7 +98,7 @@ export function renderHunchSection(store, root) {
98
98
  lines.push("This repo has **Hunch**, a graph of *why* the code is the way it is. It holds " +
99
99
  `${renderCountsMatch(counts)}. Use the \`hunch_*\` MCP tools by moment:`);
100
100
  lines.push("");
101
- lines.push("- **Start:** reuse the task ID and `task verify` command the prompt hook printed; with none, call `hunch_task(action: \"start\", title)` once. Then `hunch_context(target, task_id)` first. Orient with `hunch_structure`, `hunch_workspaces`, `hunch_runbook(task)`. Ask the user about each `hunch_escalations()` entry; silence is never approval.");
101
+ lines.push("- **Start:** reuse the task ID and `task verify` command the prompt hook printed; with none, call `hunch_task(action: \"start\", title)` once. Then `hunch_context(target, task_id)` first. Orient with `hunch_structure`, `hunch_workspaces`, `hunch_runbook(task)`. When the prompt hook raises an escalation this task touches, ask the user; silence is never approval. `hunch_escalations()` lists the rest.");
102
102
  lines.push("- **Design:** `hunch_why(target)` (includes what was rejected), `hunch_current_decision(topic)`, `hunch_bug_lineage(symptom_or_symbol)`, `hunch_compare(candidates)`, `hunch_query(query)`.");
103
103
  lines.push("- **Edit:** `hunch_check_constraints(scope)`, `hunch_get_dependents(symbol)` / `hunch_blast_radius(target)`, `hunch_findings(scope?)`.");
104
104
  lines.push("- **Merge:** `hunch_conformance()`, `hunch_pr_impact(base?)`, `hunch_merge_verdict`." +
@@ -39,6 +39,22 @@ export interface ManagedGitignoreUpgrade {
39
39
  export declare function upgradeManagedGitignore(root: string): ManagedGitignoreUpgrade;
40
40
  /** Output lines describing what an upgrade changed; empty when nothing did. */
41
41
  export declare function describeGitignoreUpgrade(upgrade: ManagedGitignoreUpgrade): string[];
42
+ export interface MemoryIgnoreRule {
43
+ /** Ignore file that holds the rule, as git reports it (e.g. `.gitignore`). */
44
+ source: string;
45
+ /** 1-based line of the rule in `source`. */
46
+ line: number;
47
+ pattern: string;
48
+ }
49
+ /** The user's ignore rule that keeps engineering memory out of Git, or null.
50
+ * A repository that ignores `.hunch/` (or `.hunch/decisions/`) silently loses every
51
+ * capture: the records are written, never committed, and gone on a fresh clone.
52
+ * Hunch's own private-only block ignores the same paths on purpose (memory lives
53
+ * in an overlay), so a rule inside that block is not reported. Unknown git state
54
+ * yields null: the caller then says nothing rather than guessing. */
55
+ export declare function memoryIgnoreRule(root: string): MemoryIgnoreRule | null;
56
+ /** One line telling the user their memory will not reach Git, and how to fix it. */
57
+ export declare function describeMemoryIgnoreRule(rule: MemoryIgnoreRule): string;
42
58
  /** `.gitignore` text with both managed Hunch blocks removed (markers inclusive),
43
59
  * every other byte kept, for `hunch uninstall --purge` (#449). `damaged` when a
44
60
  * marker line has no matching end line: the block is never guessed at. */
@@ -12,6 +12,7 @@
12
12
  * block written by an older release gains entries added later) and is otherwise
13
13
  * byte-identical; everything outside the markers is left untouched.
14
14
  */
15
+ import { spawnSync } from "node:child_process";
15
16
  import { readFileSync, existsSync, lstatSync, realpathSync } from "node:fs";
16
17
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
17
18
  import { writeFileAtomic } from "../core/io.js";
@@ -231,6 +232,35 @@ export function describeGitignoreUpgrade(upgrade) {
231
232
  }
232
233
  return out;
233
234
  }
235
+ /** The user's ignore rule that keeps engineering memory out of Git, or null.
236
+ * A repository that ignores `.hunch/` (or `.hunch/decisions/`) silently loses every
237
+ * capture: the records are written, never committed, and gone on a fresh clone.
238
+ * Hunch's own private-only block ignores the same paths on purpose (memory lives
239
+ * in an overlay), so a rule inside that block is not reported. Unknown git state
240
+ * yields null: the caller then says nothing rather than guessing. */
241
+ export function memoryIgnoreRule(root) {
242
+ const probe = ".hunch/decisions/dec_0000000000.json";
243
+ const r = spawnSync("git", ["check-ignore", "-v", "--no-index", "--", probe], { cwd: root, encoding: "utf8", windowsHide: true });
244
+ if (r.error || r.status !== 0)
245
+ return null;
246
+ const m = /^(.*):(\d+):(.*)\t/.exec(r.stdout ?? "");
247
+ if (!m)
248
+ return null;
249
+ const rule = { source: m[1], line: Number(m[2]), pattern: m[3] };
250
+ if (rule.pattern.startsWith("!"))
251
+ return null;
252
+ const file = resolve(root, rule.source);
253
+ if (existsSync(file)) {
254
+ const span = findBlock(readFileSync(file, "utf8").split("\n"), MEM_MARK, MEM_END);
255
+ if (span && rule.line - 1 > span.start && rule.line - 1 < span.end)
256
+ return null;
257
+ }
258
+ return rule;
259
+ }
260
+ /** One line telling the user their memory will not reach Git, and how to fix it. */
261
+ export function describeMemoryIgnoreRule(rule) {
262
+ return `${rule.source} line ${rule.line} (\`${rule.pattern}\`) ignores .hunch/, so captured memory is never committed or shared and is lost on a fresh clone. Remove that line, or keep memory in a private repository with \`hunch private --repo <url>\`.`;
263
+ }
234
264
  /** `.gitignore` text with both managed Hunch blocks removed (markers inclusive),
235
265
  * every other byte kept, for `hunch uninstall --purge` (#449). `damaged` when a
236
266
  * marker line has no matching end line: the block is never guessed at. */
@@ -115,7 +115,16 @@ export interface ProviderScaffold {
115
115
  * per-assistant summary for `hunch init` to print. Each assistant is isolated:
116
116
  * a writer that refuses to clobber a malformed file degrades to a warning rather
117
117
  * than aborting the rest. Claude Code is handled separately by scaffold.ts. */
118
+ /** The assistants `scaffoldProviders` can configure, besides the always-written AGENTS.md. */
119
+ export declare const PROVIDER_ASSISTANTS: readonly ["Cursor", "VS Code (Copilot)", "Codex CLI", "Windsurf", "Google Antigravity"];
120
+ export type ProviderAssistant = typeof PROVIDER_ASSISTANTS[number];
121
+ /** Assistants with a footprint here: a config already in the repository (a teammate
122
+ * set it up), a settings folder in the user's home, or a command on PATH. `hunch init`
123
+ * configures only these, so a repository is not filled with settings for tools nobody
124
+ * on it uses; when none is found it configures every assistant, as before. */
125
+ export declare function detectAssistants(root: string, home?: string, env?: NodeJS.ProcessEnv): Set<ProviderAssistant>;
118
126
  export declare function scaffoldProviders(root: string, inv: Invocation, store: HunchStore, options?: {
119
127
  agentHooks?: boolean;
120
128
  home?: string;
129
+ only?: ReadonlySet<string>;
121
130
  }): ProviderScaffold[];
@@ -20,7 +20,7 @@
20
20
  import { readFileSync, existsSync, mkdirSync } from "node:fs";
21
21
  import { writeFileAtomic } from "../core/io.js";
22
22
  import { homedir } from "node:os";
23
- import { join, dirname } from "node:path";
23
+ import { join, dirname, delimiter } from "node:path";
24
24
  import { isHunchHookCommand } from "./hookmatch.js";
25
25
  import { renderHunchSection, stripManagedSection, upsertSection, updateClaudeMd, preserveNewerTemplate } from "./claudemd.js";
26
26
  import { headFileContent, isGitCleanPath } from "../extractors/git.js";
@@ -560,6 +560,32 @@ function runProvider(writers) {
560
560
  * per-assistant summary for `hunch init` to print. Each assistant is isolated:
561
561
  * a writer that refuses to clobber a malformed file degrades to a warning rather
562
562
  * than aborting the rest. Claude Code is handled separately by scaffold.ts. */
563
+ /** The assistants `scaffoldProviders` can configure, besides the always-written AGENTS.md. */
564
+ export const PROVIDER_ASSISTANTS = ["Cursor", "VS Code (Copilot)", "Codex CLI", "Windsurf", "Google Antigravity"];
565
+ function onPath(names, env) {
566
+ const dirs = (env.PATH ?? env.Path ?? "").split(delimiter).filter(Boolean);
567
+ const exts = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").concat("") : [""];
568
+ return dirs.some((dir) => names.some((name) => exts.some((ext) => existsSync(join(dir, name + ext)))));
569
+ }
570
+ /** Assistants with a footprint here: a config already in the repository (a teammate
571
+ * set it up), a settings folder in the user's home, or a command on PATH. `hunch init`
572
+ * configures only these, so a repository is not filled with settings for tools nobody
573
+ * on it uses; when none is found it configures every assistant, as before. */
574
+ export function detectAssistants(root, home = homedir(), env = process.env) {
575
+ const has = (...paths) => paths.some((p) => existsSync(p));
576
+ const found = new Set();
577
+ if (has(join(root, ".cursor"), join(home, ".cursor")) || onPath(["cursor", "cursor-agent"], env))
578
+ found.add("Cursor");
579
+ if (has(join(root, ".vscode"), join(root, ".github", "copilot-instructions.md"), join(home, ".vscode")) || onPath(["code", "code-insiders"], env))
580
+ found.add("VS Code (Copilot)");
581
+ if (has(join(root, ".codex"), join(home, ".codex")) || onPath(["codex"], env))
582
+ found.add("Codex CLI");
583
+ if (has(join(root, ".windsurf"), join(home, ".codeium", "windsurf")) || onPath(["windsurf"], env))
584
+ found.add("Windsurf");
585
+ if (has(join(root, ".agents", "mcp_config.json"), join(home, ".gemini")) || onPath(["antigravity"], env))
586
+ found.add("Google Antigravity");
587
+ return found;
588
+ }
563
589
  export function scaffoldProviders(root, inv, store, options = {}) {
564
590
  const hooks = options.agentHooks !== false;
565
591
  const home = options.home;
@@ -584,6 +610,9 @@ export function scaffoldProviders(root, inv, store, options = {}) {
584
610
  }],
585
611
  ["Any (AGENTS.md)", () => runProvider([() => writeAgentsMd(root, store)])],
586
612
  ];
587
- return tasks.map(([assistant, run]) => ({ assistant, ...run() }));
613
+ const only = options.only;
614
+ return tasks
615
+ .filter(([assistant]) => !only || only.has(assistant) || assistant === "Any (AGENTS.md)")
616
+ .map(([assistant, run]) => ({ assistant, ...run() }));
588
617
  }
589
618
  //# sourceMappingURL=providers.js.map
@@ -62,7 +62,7 @@ Follow the Hunch-grounded workflow (DESIGN §5) — do NOT skip the memory looku
62
62
  2. Identify the suspect symbol/file, then \`hunch_get_dependents(<symbol>)\` to learn the blast radius.
63
63
  3. \`hunch_check_constraints(<scope>)\` — list invariants you must preserve.
64
64
  4. Propose a fix that honors past root causes AND constraints. Apply it and run the tests.
65
- 5. If the fix encodes a non-trivial choice, \`hunch_record_decision(...)\` so the next session is grounded in it.
65
+ 5. If the human decided or corrected something during the fix, record it (\`hunch_record_decision\` / \`hunch_record_correction\`); the commit itself is captured automatically.
66
66
  `;
67
67
  const FRAGILE_CMD = `---
68
68
  description: Report the most fragile parts of this codebase, with evidence
@@ -0,0 +1,9 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ type JsonObject = Record<string, unknown>;
3
+ /** One tool definition as the host should see it. */
4
+ export declare function leanTool(tool: JsonObject): JsonObject;
5
+ /** Route the SDK's tools/list result through leanTool. Call right after
6
+ * constructing the server: McpServer installs its tools/list handler on the
7
+ * first registerTool, through this public setRequestHandler. */
8
+ export declare function installLeanToolList(server: McpServer): void;
9
+ export {};
@@ -0,0 +1,89 @@
1
+ import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
2
+ const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
3
+ // Keywords whose meaning differs between draft-07 and 2020-12 — the dialect MCP
4
+ // assumes when `$schema` is absent. A schema that uses any keeps its marker.
5
+ // A property merely NAMED like one also keeps it: conservative, never wrong.
6
+ const DIALECT_SENSITIVE = new Set(["definitions", "dependencies", "additionalItems", "$ref", "$id", "$recursiveRef"]);
7
+ function dialectNeutral(node) {
8
+ if (Array.isArray(node))
9
+ return node.every(dialectNeutral);
10
+ if (!isObject(node))
11
+ return true;
12
+ for (const [key, value] of Object.entries(node)) {
13
+ if (DIALECT_SENSITIVE.has(key) || (key === "items" && Array.isArray(value)))
14
+ return false;
15
+ if (!dialectNeutral(value))
16
+ return false;
17
+ }
18
+ return true;
19
+ }
20
+ // Keywords whose value is data, not a subschema: copied as-is.
21
+ const VALUE_KEYWORDS = new Set(["default", "const", "enum", "examples"]);
22
+ /** A copy without zod's safe-integer bounds, which only restate what JSON numbers can hold. */
23
+ function withoutSafeIntegerBounds(node) {
24
+ if (Array.isArray(node))
25
+ return node.map(withoutSafeIntegerBounds);
26
+ if (!isObject(node))
27
+ return node;
28
+ const integer = node.type === "integer";
29
+ const out = {};
30
+ for (const [key, value] of Object.entries(node)) {
31
+ if (integer && ((key === "maximum" && value === Number.MAX_SAFE_INTEGER) || (key === "minimum" && value === Number.MIN_SAFE_INTEGER)))
32
+ continue;
33
+ out[key] = VALUE_KEYWORDS.has(key) ? value : withoutSafeIntegerBounds(value);
34
+ }
35
+ return out;
36
+ }
37
+ // Only these markers are safe to drop on a dialect-neutral schema; any other
38
+ // dialect (draft-04's boolean exclusiveMinimum, say) keeps its marker.
39
+ const DROPPABLE_MARKERS = new Set([
40
+ "http://json-schema.org/draft-07/schema#",
41
+ "https://json-schema.org/draft/2020-12/schema",
42
+ ]);
43
+ function leanSchema(schema) {
44
+ const lean = withoutSafeIntegerBounds(schema);
45
+ if (DROPPABLE_MARKERS.has(schema.$schema) && dialectNeutral(schema))
46
+ delete lean.$schema;
47
+ return lean;
48
+ }
49
+ const isDefaultExecution = (v) => isObject(v) && Object.entries(v).every(([key, value]) => key === "taskSupport" && (value === undefined || value === "forbidden"));
50
+ /** One tool definition as the host should see it. */
51
+ export function leanTool(tool) {
52
+ const out = {};
53
+ for (const [key, value] of Object.entries(tool)) {
54
+ if (key === "execution" && isDefaultExecution(value))
55
+ continue;
56
+ out[key] = (key === "inputSchema" || key === "outputSchema") && isObject(value) ? leanSchema(value) : value;
57
+ }
58
+ return out;
59
+ }
60
+ /** The request method a schema handles — by identity, or by its method literal
61
+ * when a second SDK copy supplied a different schema object. */
62
+ function handlesToolsList(schema) {
63
+ if (schema === ListToolsRequestSchema)
64
+ return true;
65
+ try {
66
+ return schema.shape?.method?.value === "tools/list";
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ /** Route the SDK's tools/list result through leanTool. Call right after
73
+ * constructing the server: McpServer installs its tools/list handler on the
74
+ * first registerTool, through this public setRequestHandler. */
75
+ export function installLeanToolList(server) {
76
+ const protocol = server.server;
77
+ const setRequestHandler = protocol.setRequestHandler.bind(protocol);
78
+ protocol.setRequestHandler = ((schema, handler) => {
79
+ if (!handlesToolsList(schema))
80
+ return setRequestHandler(schema, handler);
81
+ setRequestHandler(schema, async (...args) => {
82
+ const result = await handler(...args);
83
+ if (!isObject(result) || !Array.isArray(result.tools))
84
+ return result;
85
+ return { ...result, tools: result.tools.map((t) => (isObject(t) ? leanTool(t) : t)) };
86
+ });
87
+ });
88
+ }
89
+ //# sourceMappingURL=leanToolList.js.map
@@ -16,6 +16,7 @@ import { z } from "zod";
16
16
  import { hunchPaths, findRoot, toPosixTarget, repoRelativeTarget } from "../core/paths.js";
17
17
  import { matchSymbolsTiered } from "../core/glob.js";
18
18
  import { resolveMcpToolset } from "./toolset.js";
19
+ import { installLeanToolList } from "./leanToolList.js";
19
20
  import { PolicyRepository } from "../constitution/repository.js";
20
21
  import { readConfig } from "../core/config.js";
21
22
  import { canonicalRootPath, resolveActiveRoot } from "./roots.js";
@@ -1129,7 +1130,7 @@ function prepareRoot(root, explicitOverlay, requireIndex) {
1129
1130
  export const MCP_INSTRUCTIONS = [
1130
1131
  "Hunch is this repository's engineering memory: decisions, bug history, invariants, components, with provenance.",
1131
1132
  "Per user task: (1) hunch_task(action:\"start\", title) once — skip it when the host's prompt hook already opened the task and printed its verify command, and reuse that task_id; (2) hunch_context(target, task_id) FIRST, before reading or editing, for the file, symbol, or task phrase; (3) hunch_check_constraints(scope) before editing shared code; (4) hunch_task(action:\"finish\", task_id) before the final response, showing its contribution card — required, with one exception: the host's prompt hook opened the task AND its instruction said that host closes the task AND the task used no Hunch (no hunch_* call on this task_id, no task verify check, no hook context you acted on, nothing to claim). A task you started with hunch_task is always finished by you.",
1132
- "Then by moment: hunch_why(target) for rationale and rejected alternatives, hunch_bug_lineage before fixing a failure, hunch_record_decision after a non-trivial choice, hunch_record_correction when a human corrects you.",
1133
+ "Then by moment: hunch_why(target) for rationale and rejected alternatives, hunch_bug_lineage before fixing a failure, hunch_record_correction when a human corrects you, hunch_record_decision for a decision the human makes; record only what the human decides or corrects.",
1133
1134
  "Hosts without lifecycle hooks (Windsurf, Cursor, or Codex before its hooks are trusted) receive no automatic grounding: call these tools yourself.",
1134
1135
  ].join("\n");
1135
1136
  export function buildServerWithRootControl(initialRoot, options = {}) {
@@ -1207,6 +1208,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1207
1208
  if (toolset.hidden.length)
1208
1209
  process.stderr.write(`[hunch-mcp] tool groups: ${toolset.groups.length ? toolset.groups.join(", ") : "core only"} (${toolset.source}); ${toolset.hidden.length} specialist tool(s) hidden — HUNCH_MCP_TOOLS=all or .hunch/config.json mcp_tools to expose\n`);
1209
1210
  const server = new McpServer({ name: "hunch", version: HUNCH_VERSION }, { instructions: MCP_INSTRUCTIONS });
1211
+ installLeanToolList(server);
1210
1212
  let activeRequests = 0;
1211
1213
  let pendingRoot = null;
1212
1214
  let pendingScheduled = false;
@@ -2103,7 +2105,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2103
2105
  // -- hunch_record_decision (write-back) -----------------------------------
2104
2106
  server.registerTool("hunch_record_decision", {
2105
2107
  title: "Record a decision (write-back)",
2106
- description: "Persist a Decision (ADR) with provenance after a non-trivial design choice. private:true keeps it in the HUNCH_PRIVATE_DIR overlay, never committed here. Returns id, home, status. Not for an agent-obeyed rule (hunch_record_correction) or an observation (hunch_record_finding). Errors: 'Refused:' a gate held (don't retry); 'Invalid:' fix arguments; 'Failed to' internal.",
2108
+ description: "Persist a Decision (ADR) with provenance when the human makes or confirms one in conversation; routine choices are captured from commits. private:true keeps it in the HUNCH_PRIVATE_DIR overlay, never committed here. Returns id, home, status. Not for an agent-obeyed rule (hunch_record_correction) or an observation (hunch_record_finding). Errors: 'Refused:' a gate held (don't retry); 'Invalid:' fix arguments; 'Failed to' internal.",
2107
2109
  inputSchema: {
2108
2110
  decision: z.object({
2109
2111
  title: z.string(),
@@ -1,4 +1,5 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { installLeanToolList } from "../mcp/leanToolList.js";
2
3
  // The web-standard transport, not the Node one: the Node wrapper imports @hono/node-server,
3
4
  // which the production dependency audit keeps unreachable (tooling/production-dependency-audit.mjs).
4
5
  // Bridging IncomingMessage → Request → ServerResponse here is a few lines and keeps that boundary.
@@ -51,6 +52,7 @@ const TOOLS = [
51
52
  ];
52
53
  function buildServer(dispatch, problemOf, version) {
53
54
  const server = new McpServer({ name: "hunch-serve", version });
55
+ installLeanToolList(server);
54
56
  for (const tool of TOOLS) {
55
57
  server.registerTool(tool.name, { title: tool.title, description: tool.description, inputSchema: tool.input }, async (input) => {
56
58
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.44.0",
3
+ "version": "1.45.0",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
@@ -44,7 +44,6 @@
44
44
  "contracts/change-proof/*.json",
45
45
  "server.json",
46
46
  "bench/constitution-exp03-v1.json",
47
- "tooling/competitive-watch.mjs",
48
47
  "tooling/md1-benchmark.mjs",
49
48
  "LICENSE",
50
49
  "NOTICE"
@@ -91,12 +90,10 @@
91
90
  "site:proof": "npm run build && node tooling/generate-public-proof.mjs",
92
91
  "site:sitemap": "node tooling/generate-sitemap.mjs",
93
92
  "bench:md1": "node tooling/md1-benchmark.mjs",
94
- "research:competitors": "node tooling/competitive-watch.mjs",
95
- "outreach": "node tooling/outreach-pipeline.mjs",
96
93
  "prepublishOnly": "npm run build"
97
94
  },
98
95
  "dependencies": {
99
- "@modelcontextprotocol/sdk": "^1.31.0",
96
+ "@modelcontextprotocol/sdk": "^1.32.1",
100
97
  "@tree-sitter-grammars/tree-sitter-yaml": "^0.6.1",
101
98
  "commander": "^15.0.0",
102
99
  "cross-spawn": "^7.0.6",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.44.0",
10
+ "version": "1.45.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.44.0",
16
+ "version": "1.45.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {
@@ -18,7 +18,7 @@ import { dirname, join, resolve } from "node:path";
18
18
  import process from "node:process";
19
19
  import { fileURLToPath } from "node:url";
20
20
 
21
- export const BENCHMARK_BASELINE_REF = "610cd2e5673bf3c69ac984b4737e2d4a749ed374";
21
+ export const BENCHMARK_BASELINE_REF = "a1ad6d89631cce7c194f3f679abf7ed4e56d13e3";
22
22
  export const BENCHMARK_SCHEMA = "hunch.md1a.performance.v1";
23
23
 
24
24
  const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -1,142 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- const repos = [
4
- "davesheffer/hunch",
5
- "gitmem-dev/gitmem",
6
- "ismaelkedir/knowit",
7
- "weigibbor/mnemo",
8
- "oldskultxo/aictx",
9
- "riponcm/projectmem",
10
- "Cranot/roam-code",
11
- "blackwell-systems/knowing",
12
- "markmhendrickson/neotoma",
13
- ];
14
-
15
- const distinctivePhrases = [
16
- "Causal Merge Verdict",
17
- "corrections become enforced",
18
- "content-matched constraints",
19
- "deterministic Change Gate for AI-assisted codebases",
20
- ];
21
-
22
- let token = process.env.GITHUB_TOKEN?.trim();
23
- // A rejected token must DEGRADE the run, never kill it: ambient CI/proxy tokens
24
- // are often invalid for api.github.com, and the metadata half of this watch
25
- // works unauthenticated. Flipped on the first 401 so every later call skips
26
- // the bad credential instead of failing eight times.
27
- let tokenRejected = false;
28
-
29
- function headers() {
30
- return {
31
- Accept: "application/vnd.github+json",
32
- "User-Agent": "hunch-competitive-watch",
33
- "X-GitHub-Api-Version": "2022-11-28",
34
- ...(token && !tokenRejected ? { Authorization: `Bearer ${token}` } : {}),
35
- };
36
- }
37
-
38
- async function github(path) {
39
- let response = await fetch(`https://api.github.com${path}`, { headers: headers() });
40
- if (response.status === 401 && token && !tokenRejected) {
41
- tokenRejected = true;
42
- process.stderr.write("competitive-watch: GITHUB_TOKEN rejected (401) — continuing unauthenticated; phrase search will be skipped.\n");
43
- response = await fetch(`https://api.github.com${path}`, { headers: headers() });
44
- }
45
- if (!response.ok) {
46
- const detail = (await response.text()).slice(0, 300).replaceAll("\n", " ");
47
- throw new Error(`GitHub ${response.status} for ${path}: ${detail}`);
48
- }
49
- return response.json();
50
- }
51
-
52
- async function repoSnapshot(repo) {
53
- // One vanished/renamed repo is itself a signal worth a row — never a reason
54
- // to lose the other seven.
55
- try {
56
- const data = await github(`/repos/${repo}`);
57
- return {
58
- repo,
59
- created: data.created_at,
60
- pushed: data.pushed_at,
61
- stars: data.stargazers_count,
62
- forks: data.forks_count,
63
- issues: data.open_issues_count,
64
- url: data.html_url,
65
- };
66
- } catch (error) {
67
- return { repo, error: error instanceof Error ? error.message : String(error) };
68
- }
69
- }
70
-
71
- async function phraseSnapshot(phrase) {
72
- const query = encodeURIComponent(`"${phrase}"`);
73
- const data = await github(`/search/code?q=${query}&per_page=100`);
74
- const external = data.items
75
- .filter((item) => !item.repository.full_name.startsWith("davesheffer/"))
76
- .map((item) => ({
77
- repo: item.repository.full_name,
78
- path: item.path,
79
- url: item.html_url,
80
- }));
81
- return { phrase, total: data.total_count, external };
82
- }
83
-
84
- function render(snapshot, phrases) {
85
- const lines = [
86
- `# Competitive watch — ${new Date().toISOString()}`,
87
- "",
88
- "## Public repository signals",
89
- "",
90
- "| Repository | Created | Last push | Stars | Forks | Open issues |",
91
- "| --- | --- | --- | ---: | ---: | ---: |",
92
- ];
93
-
94
- for (const item of snapshot) {
95
- if (item.error) {
96
- lines.push(`| ${item.repo} | — | — | — | — | — |`);
97
- continue;
98
- }
99
- lines.push(
100
- `| [${item.repo}](${item.url}) | ${item.created.slice(0, 10)} | ${item.pushed.slice(0, 10)} | ${item.stars} | ${item.forks} | ${item.issues} |`,
101
- );
102
- }
103
- const failed = snapshot.filter((item) => item.error);
104
- if (failed.length) {
105
- lines.push("");
106
- for (const item of failed) lines.push(`- ⚠ ${item.repo}: ${item.error}`);
107
- }
108
-
109
- lines.push("", "## Distinctive phrase search", "");
110
- if (!token) {
111
- lines.push("Skipped: set `GITHUB_TOKEN` to enable authenticated GitHub code search.");
112
- } else if (tokenRejected) {
113
- lines.push("Skipped: `GITHUB_TOKEN` was rejected by api.github.com (401) — repo metadata above was collected unauthenticated. Provide a valid token (e.g. `GITHUB_TOKEN=\"$(gh auth token)\"`) to run the phrase-copy check.");
114
- } else {
115
- for (const result of phrases) {
116
- lines.push(`- **${result.phrase}** — ${result.external.length} external indexed match(es)`);
117
- for (const match of result.external) {
118
- lines.push(` - [${match.repo} · ${match.path}](${match.url})`);
119
- }
120
- }
121
- }
122
-
123
- lines.push(
124
- "",
125
- "> Signals are leads, not copying findings. Re-check chronology and substantial similarity before drawing a conclusion.",
126
- );
127
- return `${lines.join("\n")}\n`;
128
- }
129
-
130
- try {
131
- const snapshot = [];
132
- // Sequential on purpose: the first call settles token validity before the
133
- // rest, and unauthenticated rate limits are too small to burst against.
134
- for (const repo of repos) snapshot.push(await repoSnapshot(repo));
135
- const phrases = token && !tokenRejected
136
- ? await Promise.all(distinctivePhrases.map(phraseSnapshot))
137
- : [];
138
- process.stdout.write(render(snapshot, phrases));
139
- } catch (error) {
140
- process.stderr.write(`competitive-watch: ${error instanceof Error ? error.message : String(error)}\n`);
141
- process.exitCode = 1;
142
- }