@fusengine/harness 0.1.93 → 0.1.94

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/bin.mjs CHANGED
@@ -4,9 +4,9 @@ import { r as resolveTtlSec } from "../ttl-Dgwg_QAv.mjs";
4
4
  import { r as loadDotenv } from "../dotenv-C1LkcfW-.mjs";
5
5
  import { t as detectHarness } from "../harness-BMuLJ9lm.mjs";
6
6
  import { t as claudeHome } from "../home-state-oUGFB4ds.mjs";
7
- import { f as checkStaged, m as stagedFiles, p as stagedContent, t as runPrd } from "../prd-PvK7PKWS.mjs";
7
+ import { f as checkStaged, m as stagedFiles, p as stagedContent, t as runPrd } from "../prd-CixxWJIR.mjs";
8
8
  import { n as writeInitFile, t as initFor } from "../run-GHAMqcOn.mjs";
9
- import { $ as runDoctor, Bt as todayUtc, I as respond, Q as notify, et as runningVersion, t as handleHook, tt as versionBanner } from "../handle-B-g9KL_9.mjs";
9
+ import { $ as runDoctor, Bt as todayUtc, I as respond, Q as notify, et as runningVersion, t as handleHook, tt as versionBanner } from "../handle-CDgBbPRz.mjs";
10
10
  import { r as cursorEventContract } from "../normalize-Dy8g9Ybl.mjs";
11
11
  import { delimiter, join } from "node:path";
12
12
  import { existsSync, mkdirSync, readFileSync, readSync, readdirSync, writeFileSync } from "node:fs";
@@ -1,2 +1,2 @@
1
- import { a as renderJson, c as positionalArgs, d as resolvePrdArgs, f as checkStaged, i as runPrdStatus, l as readFlag, m as stagedFiles, n as runPrdCompact, o as renderTable, p as stagedContent, r as runPrdValidate, s as hasJsonFlag, t as runPrd, u as resolveAndLoadEnv } from "../prd-PvK7PKWS.mjs";
1
+ import { a as renderJson, c as positionalArgs, d as resolvePrdArgs, f as checkStaged, i as runPrdStatus, l as readFlag, m as stagedFiles, n as runPrdCompact, o as renderTable, p as stagedContent, r as runPrdValidate, s as hasJsonFlag, t as runPrd, u as resolveAndLoadEnv } from "../prd-CixxWJIR.mjs";
2
2
  export { checkStaged, hasJsonFlag, positionalArgs, readFlag, renderJson, renderTable, resolveAndLoadEnv, resolvePrdArgs, runPrd, runPrdCompact, runPrdStatus, runPrdValidate, stagedContent, stagedFiles };
@@ -15,7 +15,7 @@ import { t as formatPrompt } from "./types-ernB1Dy3.mjs";
15
15
  import { a as nowStamp, l as throttleMs, n as readRoots, o as readState, s as setStateField, t as addRoot } from "./registry-IhHk2KlT.mjs";
16
16
  import { d as loadIndex, i as cacheLookupMeta, n as webfetchCacheWrite, o as cacheLookupSubstringMeta, t as mcpCacheWrite, u as extractText } from "./mcp-store-BkBDmuxN.mjs";
17
17
  import { t as loadRefs } from "./loader-AGz4nK7d.mjs";
18
- import { E as isPrdScopedPath, S as subTasksOf, T as classifyPrdPath, _ as readTaskFileSync, a as isPrdEnabled, b as filesOf, c as crossCheckTask, d as readAgentReport, f as readAgentReportSync, g as readTaskFile, h as readRouterSync, i as taskStart, l as hasAnyViolations, m as readRouter, n as taskComplete, p as readAllTaskFiles, r as taskCreate, s as prdProjectRoot, t as acquireLock, u as incompleteSubTasks } from "./apex-task-store-Cb65rE5i.mjs";
18
+ import { C as isCompacted, D as classifyPrdPath, O as isPrdScopedPath, S as filesOf, _ as readRouterSync, a as taskCreate, d as hasAnyViolations, f as incompleteSubTasks, g as readRouter, h as readAllTaskFiles, i as taskComplete, l as prdProjectRoot, m as readAgentReportSync, o as taskStart, p as readAgentReport, r as acquireLock, s as isPrdEnabled, t as canPromoteRouterEntry, u as crossCheckTask, v as readTaskFile, w as subTasksOf, y as readTaskFileSync } from "./prd-compact-BE45t8UR.mjs";
19
19
  import { a as sleep, i as readText$1, n as pathExists, o as spawnCapture, s as writeText, t as collectFiles } from "./runtime-io-DuumUeE6.mjs";
20
20
  import { C as recordPrdViolation, D as trivialCount, E as recordTrivialEdit, O as apexAuthorizationGate, S as recordPrdStopBlocked, T as recordTarget, _ as prdAlreadyBlocked, a as journalLogPath, b as recordDoc, c as appendEvent, d as signTrack, f as verifyTrack, g as emptyTrack, h as agentsFresh, i as withTrack, l as withTrackLockSync, o as readTrackSync, p as writeLastNonce, r as trackJournalEnabled, s as diffTrackEvents, t as loadTrack, u as LOCK_FAILED, v as recordAgent, w as recordRefRead, x as recordPrdOwner, y as recordBrainstormRequired } from "./store-BVY6gIYM.mjs";
21
21
  import { i as parseApplyPatch, n as canonicalizeMcpToolName, r as canonicalizeCodexShellTool, t as isBypassPermissions } from "./permission-mode-B-SFyR0X.mjs";
@@ -7853,6 +7853,65 @@ function persistStopBlocked(trackFilePath, track, key, now) {
7853
7853
  writeLastNonce(envelope.nonce);
7854
7854
  });
7855
7855
  }
7856
+ /** Task names whose task-PRD is fully validated (every agent, every sub-task) but not yet compacted (`prd-compact.ts`'s own per-agent test, applied task-wide). */
7857
+ function compactableTasks(router, taskFiles) {
7858
+ const ready = [];
7859
+ for (const task of Object.keys(router)) {
7860
+ const taskFile = taskFiles[task];
7861
+ if (!taskFile || canPromoteRouterEntry(taskFile)) continue;
7862
+ if (Object.values(taskFile).every((entry) => {
7863
+ if (isCompacted(entry)) return true;
7864
+ const subs = Object.values(subTasksOf(entry));
7865
+ return subs.length > 0 && subs.every((s) => s.status === "validated");
7866
+ })) ready.push(task);
7867
+ }
7868
+ return ready;
7869
+ }
7870
+ /** Names the compactable task(s) and the exact `harness prd compact` invocation (verified against `cli/prd/compact.ts`'s own usage string). */
7871
+ function compactHintText(tasks) {
7872
+ if (tasks.length === 1) return `PRD task "${tasks[0]}" is fully validated and ready to compact. Run \`harness prd compact ${tasks[0]}\`.`;
7873
+ return `PRD tasks fully validated and ready to compact: ${tasks.join(", ")}. Run \`harness prd compact <task>\` for each.`;
7874
+ }
7875
+ /**
7876
+ * Non-blocking "ready to compact" hint. Target-agnostic by design: an id
7877
+ * allowlist here would silently go stale the day a target's real Stop wiring
7878
+ * changes, so this mirrors the SAME per-target form choice as the block path
7879
+ * directly below instead of hardcoding one target — kimi/gemini-cli/cline
7880
+ * get their own native `respond()` inform envelope, everyone else gets the
7881
+ * `Stop` `additionalContext` channel. Whether this function is ever REACHED
7882
+ * for a given id is a wiring question, not a code question: measured live
7883
+ * against the real hooks.json this project ships (claude-plugins/
7884
+ * codex-plugins/kimi-code-plugins) plus each target's own hook docs — only
7885
+ * Codex's Stop wiring is a plain `hook codex core` with no short-circuit;
7886
+ * claude-code's and Kimi's own Stop hook always carries `--sound stop`,
7887
+ * which exits in `maybePlaySound()` (`src/cli/hook-sound.ts`) before stdin
7888
+ * is ever read, and hermes/gemini-cli/cline have no event literally named
7889
+ * `Stop` in their own documented hook taxonomy at all — see prd.md's Known
7890
+ * limitations for the full measurement. One-shot per session, on a marker
7891
+ * key DISTINCT from the block-once key so neither shadows the other.
7892
+ * @param id - Harness target id.
7893
+ * @param payload - The raw Stop hook payload.
7894
+ * @param router - The parsed router.
7895
+ * @param taskFiles - Every router task's parsed task-PRD (or `null`).
7896
+ * @param track - The current session track.
7897
+ * @param trackFilePath - The session track file path.
7898
+ * @param now - Event clock.
7899
+ * @returns The native non-blocking stdout (once per session), else `""`.
7900
+ */
7901
+ function compactReminder(id, payload, router, taskFiles, track, trackFilePath, now) {
7902
+ const ready = compactableTasks(router, taskFiles);
7903
+ if (ready.length === 0) return "";
7904
+ const key = `${typeof payload.session_id === "string" ? payload.session_id : "unknown"}:StopCompactHint`;
7905
+ if (prdAlreadyBlocked(track, key)) return "";
7906
+ persistStopBlocked(trackFilePath, track, key, now);
7907
+ const text = compactHintText(ready);
7908
+ if (id === "kimi" || id === "gemini-cli" || id === "cline") return respond(id, {
7909
+ kind: "inform",
7910
+ title: "PRD compact",
7911
+ reason: text
7912
+ }, "Stop");
7913
+ return contextResponse("Stop", text);
7914
+ }
7856
7915
  /**
7857
7916
  * Run the PRD lead-Stop block-once gate.
7858
7917
  * @param payload - The raw Stop hook payload.
@@ -7860,7 +7919,8 @@ function persistStopBlocked(trackFilePath, track, key, now) {
7860
7919
  * @param id - Harness target id.
7861
7920
  * @param trackFilePath - The session track file path.
7862
7921
  * @param now - Event clock.
7863
- * @returns The native block stdout (once per session), else `""`.
7922
+ * @returns The native block stdout (once per session); else the compact
7923
+ * hint (once per session, whichever ids' real Stop wiring reaches it); else `""`.
7864
7924
  */
7865
7925
  function prdStopGate(payload, cwd, id, trackFilePath, now) {
7866
7926
  if (!isPrdEnabled(cwd, id)) return "";
@@ -7871,7 +7931,8 @@ function prdStopGate(payload, cwd, id, trackFilePath, now) {
7871
7931
  if (!router) return "";
7872
7932
  const track = readTrackSync(trackFilePath, trackJournalEnabled());
7873
7933
  if (!((track.prdViolations?.length ?? 0) > 0)) {
7874
- if (!hasAnyViolations(router, readAllTaskFilesSync(root, homeSeg, router))) return "";
7934
+ const taskFiles = readAllTaskFilesSync(root, homeSeg, router);
7935
+ if (!hasAnyViolations(router, taskFiles)) return compactReminder(id, payload, router, taskFiles, track, trackFilePath, now);
7875
7936
  }
7876
7937
  const key = `${typeof payload.session_id === "string" ? payload.session_id : "unknown"}:Stop`;
7877
7938
  if (prdAlreadyBlocked(track, key)) return "";
@@ -3,7 +3,7 @@ import { t as isCodeFile } from "./project-root-3kk7gCOp.mjs";
3
3
  import { t as evaluate } from "./evaluate-CMiIqHeH.mjs";
4
4
  import { r as harnessHomeSegment } from "./apex-target-Xc2M32Pl.mjs";
5
5
  import { t as formatPrompt } from "./types-ernB1Dy3.mjs";
6
- import { C as withRouterStatus, D as prdDir, O as prdRouterPath, S as subTasksOf, c as crossCheckTask, d as readAgentReport, g as readTaskFile, m as readRouter, o as isPrdFlagSet, p as readAllTaskFiles, t as acquireLock, u as incompleteSubTasks, v as writeRouter, w as withSubTaskValidated, x as isCompacted, y as writeTaskFile } from "./apex-task-store-Cb65rE5i.mjs";
6
+ import { A as prdRouterPath, C as isCompacted, E as withSubTaskValidated, T as withRouterStatus, b as writeRouter, c as isPrdFlagSet, f as incompleteSubTasks, g as readRouter, h as readAllTaskFiles, k as prdDir, n as compactTaskFile, p as readAgentReport, r as acquireLock, u as crossCheckTask, v as readTaskFile, w as subTasksOf, x as writeTaskFile } from "./prd-compact-BE45t8UR.mjs";
7
7
  import { join } from "node:path";
8
8
  import { existsSync } from "node:fs";
9
9
  import { homedir } from "node:os";
@@ -407,34 +407,6 @@ async function runPrdValidate(argv, cwd, env = process.env) {
407
407
  return 0;
408
408
  }
409
409
  //#endregion
410
- //#region src/policy/prd/prd-compact.ts
411
- /** Collapses fully-validated task-PRD agent entries to their compact shape. Pure — no fs. */
412
- /** Collapses one agent entry to the compacted shape ONLY when every sub-task is `validated`; else returns it unchanged. */
413
- function compactAgentEntry(e, at) {
414
- if (isCompacted(e)) return e;
415
- const subTasks = Object.values(subTasksOf(e));
416
- if (subTasks.length === 0 || !subTasks.every((s) => s.status === "validated")) return e;
417
- return {
418
- status: "validated",
419
- files: e.files,
420
- "validated-at": at
421
- };
422
- }
423
- /** Compacts every eligible agent entry in a task-PRD; reports which agent names got collapsed. */
424
- function compactTaskFile(taskFile, at) {
425
- const compacted = [];
426
- const file = {};
427
- for (const [agent, entry] of Object.entries(taskFile)) {
428
- const next = compactAgentEntry(entry, at);
429
- if (next !== entry) compacted.push(agent);
430
- file[agent] = next;
431
- }
432
- return {
433
- file,
434
- compacted
435
- };
436
- }
437
- //#endregion
438
410
  //#region src/cli/prd/compact.ts
439
411
  /**
440
412
  * `harness prd compact <task> [--id] [--root]` — collapses every fully
@@ -464,4 +464,36 @@ async function taskComplete(file, id) {
464
464
  await writeJsonFile(file, data);
465
465
  }
466
466
  //#endregion
467
- export { withRouterStatus as C, prdDir as D, isPrdScopedPath as E, prdRouterPath as O, subTasksOf as S, classifyPrdPath as T, readTaskFileSync as _, isPrdEnabled as a, filesOf as b, crossCheckTask as c, readAgentReport as d, readAgentReportSync as f, readTaskFile as g, readRouterSync as h, taskStart as i, hasAnyViolations as l, readRouter as m, taskComplete as n, isPrdFlagSet as o, readAllTaskFiles as p, taskCreate as r, prdProjectRoot as s, acquireLock as t, incompleteSubTasks as u, writeRouter as v, withSubTaskValidated as w, isCompacted as x, writeTaskFile as y };
467
+ //#region src/policy/prd/prd-compact.ts
468
+ /** Collapses fully-validated task-PRD agent entries to their compact shape. Pure — no fs. */
469
+ /** Collapses one agent entry to the compacted shape ONLY when every sub-task is `validated`; else returns it unchanged. */
470
+ function compactAgentEntry(e, at) {
471
+ if (isCompacted(e)) return e;
472
+ const subTasks = Object.values(subTasksOf(e));
473
+ if (subTasks.length === 0 || !subTasks.every((s) => s.status === "validated")) return e;
474
+ return {
475
+ status: "validated",
476
+ files: e.files,
477
+ "validated-at": at
478
+ };
479
+ }
480
+ /** Compacts every eligible agent entry in a task-PRD; reports which agent names got collapsed. */
481
+ function compactTaskFile(taskFile, at) {
482
+ const compacted = [];
483
+ const file = {};
484
+ for (const [agent, entry] of Object.entries(taskFile)) {
485
+ const next = compactAgentEntry(entry, at);
486
+ if (next !== entry) compacted.push(agent);
487
+ file[agent] = next;
488
+ }
489
+ return {
490
+ file,
491
+ compacted
492
+ };
493
+ }
494
+ /** True when every agent in the task-PRD is already compacted+validated. */
495
+ function canPromoteRouterEntry(taskFile) {
496
+ return Object.values(taskFile).every((entry) => isCompacted(entry));
497
+ }
498
+ //#endregion
499
+ export { prdRouterPath as A, isCompacted as C, classifyPrdPath as D, withSubTaskValidated as E, isPrdScopedPath as O, filesOf as S, withRouterStatus as T, readRouterSync as _, taskCreate as a, writeRouter as b, isPrdFlagSet as c, hasAnyViolations as d, incompleteSubTasks as f, readRouter as g, readAllTaskFiles as h, taskComplete as i, prdDir as k, prdProjectRoot as l, readAgentReportSync as m, compactTaskFile as n, taskStart as o, readAgentReport as p, acquireLock as r, isPrdEnabled as s, canPromoteRouterEntry as t, crossCheckTask as u, readTaskFile as v, subTasksOf as w, writeTaskFile as x, readTaskFileSync as y };
@@ -1,6 +1,6 @@
1
1
  import { r as projectLayout } from "../layout-KWoE_Mqn.mjs";
2
2
  import { a as sanitizeSessionId, c as sessionsDir, i as loadSessionState, n as fuseHarnessHome, o as saveSessionState, r as fusengineCache, s as sessionStatePath, t as claudeHome } from "../home-state-oUGFB4ds.mjs";
3
- import { A as gate, At as taskContext, B as writePluginMap, Bt as todayUtc, C as trackEnrichment, Ct as removeOldFiles, D as DEFAULT_WINDOW_MS, Dt as projectContext, E as dispatchAipilot, Et as gitContext, F as extractSymbols, Ft as isoUtc, G as mergeLines, H as isProject, I as respond, It as loadSecurityState, J as listChildren, K as countFiles, L as dispatchLessons, Lt as saveSecurityState, M as preCommitGate, Mt as projectHash, N as detectDuplication, Nt as trackFile, O as REQUIRED_AGENTS, Ot as claudeMdKey, P as dryGate, Pt as normalizeEvent, R as cartoSessionStart, Rt as securityStateDir, S as trackSkillRead, St as purgeTtlTree, T as aipilotPostToolUse, Tt as devContext, U as writeTree, V as generateProjectMap, W as loadEnriched, X as lessonsFileFor, Y as lessonsArchiveFileFor, Z as lessonsStateFileFor, _ as securityAdvisory, _t as injectRules, a as mcpPostStore, at as cleanupSession, b as trackWatchResearch, bt as sessionStartCore, c as WEBFETCH_TTL_MS, ct as validateTeammateOutput, d as queryOf, dt as validateTailwind, f as lifecycleStdout, ft as validateSolidGate, g as dispatchMemory, gt as solidDetectStart, h as seoPostToolUseResponse, ht as detectSolidProfile, i as recordActivity, it as validateRulesLoaded, j as gateCommandCandidates, jt as defaultStateDir, k as TRIVIAL_BUDGET, kt as promptSubmitContext, l as cacheQueryOf, lt as trackAgentMemory, m as seoPostToolUse, mt as countLoc, n as activityFor, nt as postEditTypescript, o as mcpPreIntercept, ot as saveApexState, p as postEditContext, pt as checkFileSize, q as getFileDesc, r as handlePre, rt as trackSessionChanges, s as MCP_TTL_MS, st as logToolFailure, t as handleHook, u as isMcpTool, ut as subagentCacheContext, v as securityAdvisoryForPatch, vt as readRules, w as dispatchLifecycle, wt as trimLogFile, x as trackMcpResearch, xt as pruneEmptyDirs, y as postTrackingSideEffects, yt as runSessionStartCleanups, z as generateEcosystemMap, zt as securityStatePath } from "../handle-B-g9KL_9.mjs";
3
+ import { A as gate, At as taskContext, B as writePluginMap, Bt as todayUtc, C as trackEnrichment, Ct as removeOldFiles, D as DEFAULT_WINDOW_MS, Dt as projectContext, E as dispatchAipilot, Et as gitContext, F as extractSymbols, Ft as isoUtc, G as mergeLines, H as isProject, I as respond, It as loadSecurityState, J as listChildren, K as countFiles, L as dispatchLessons, Lt as saveSecurityState, M as preCommitGate, Mt as projectHash, N as detectDuplication, Nt as trackFile, O as REQUIRED_AGENTS, Ot as claudeMdKey, P as dryGate, Pt as normalizeEvent, R as cartoSessionStart, Rt as securityStateDir, S as trackSkillRead, St as purgeTtlTree, T as aipilotPostToolUse, Tt as devContext, U as writeTree, V as generateProjectMap, W as loadEnriched, X as lessonsFileFor, Y as lessonsArchiveFileFor, Z as lessonsStateFileFor, _ as securityAdvisory, _t as injectRules, a as mcpPostStore, at as cleanupSession, b as trackWatchResearch, bt as sessionStartCore, c as WEBFETCH_TTL_MS, ct as validateTeammateOutput, d as queryOf, dt as validateTailwind, f as lifecycleStdout, ft as validateSolidGate, g as dispatchMemory, gt as solidDetectStart, h as seoPostToolUseResponse, ht as detectSolidProfile, i as recordActivity, it as validateRulesLoaded, j as gateCommandCandidates, jt as defaultStateDir, k as TRIVIAL_BUDGET, kt as promptSubmitContext, l as cacheQueryOf, lt as trackAgentMemory, m as seoPostToolUse, mt as countLoc, n as activityFor, nt as postEditTypescript, o as mcpPreIntercept, ot as saveApexState, p as postEditContext, pt as checkFileSize, q as getFileDesc, r as handlePre, rt as trackSessionChanges, s as MCP_TTL_MS, st as logToolFailure, t as handleHook, u as isMcpTool, ut as subagentCacheContext, v as securityAdvisoryForPatch, vt as readRules, w as dispatchLifecycle, wt as trimLogFile, x as trackMcpResearch, xt as pruneEmptyDirs, y as postTrackingSideEffects, yt as runSessionStartCleanups, z as generateEcosystemMap, zt as securityStatePath } from "../handle-CDgBbPRz.mjs";
4
4
  //#region src/runtime/storage.ts
5
5
  /**
6
6
  * The project's single state dir (`<root>/.harness`) — neutral + harness-agnostic,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fusengine/harness",
3
- "version": "0.1.93",
3
+ "version": "0.1.94",
4
4
  "description": "Harness-agnostic toolkit for AI coding agents: runtime harness detection (Claude Code, Codex, Cursor, Cline, Gemini, Aider...), pure policy core (env config, project/framework detection, SOLID/file-size limits, APEX freshness, guard patterns, portable prompts), cache, project memory, ref routing, state/locks, statusline, per-harness adapters (Claude/Cursor/Cline/Gemini) and a cli-mode harness-check binary. Bun-native, with a built dist for Node + bundlers.",
5
5
  "type": "module",
6
6
  "module": "src/index.ts",
@@ -9,8 +9,8 @@
9
9
  import { dirname } from "node:path";
10
10
  import { harnessHomeSegment } from "../../policy/apex-target";
11
11
  import {
12
- hasAnyViolations, isPrdEnabled, prdProjectRoot, readRouterSync, readTaskFileSync,
13
- type PrdRouter, type PrdTaskFile,
12
+ canPromoteRouterEntry, hasAnyViolations, isCompacted, isPrdEnabled, prdProjectRoot,
13
+ readRouterSync, readTaskFileSync, subTasksOf, type PrdRouter, type PrdTaskFile,
14
14
  } from "../../policy/prd";
15
15
  import { journalLogPath, readTrackSync } from "../../tracking/track-compact";
16
16
  import { trackJournalEnabled } from "../../tracking/store";
@@ -20,7 +20,7 @@ import { prdAlreadyBlocked, recordPrdStopBlocked, type SessionTrack } from "../.
20
20
  import { signTrack, writeLastNonce } from "../../tracking/integrity";
21
21
  import { withTrackLockSync } from "../../tracking/track-lock-sync";
22
22
  import { atomicWrite } from "../../util/json-io";
23
- import { blockResponse } from "../../adapters/claude";
23
+ import { blockResponse, contextResponse } from "../../adapters/claude";
24
24
  import { respond } from "../respond";
25
25
 
26
26
  const REASON = "PRD cross-check found unresolved violation(s): a task/sub-task is marked validated without a matching done report. Run `harness prd status` for details.";
@@ -57,6 +57,71 @@ function persistStopBlocked(trackFilePath: string, track: SessionTrack, key: str
57
57
  });
58
58
  }
59
59
 
60
+ /** Task names whose task-PRD is fully validated (every agent, every sub-task) but not yet compacted (`prd-compact.ts`'s own per-agent test, applied task-wide). */
61
+ function compactableTasks(router: PrdRouter, taskFiles: Record<string, PrdTaskFile | null>): string[] {
62
+ const ready: string[] = [];
63
+ for (const task of Object.keys(router)) {
64
+ const taskFile = taskFiles[task];
65
+ if (!taskFile || canPromoteRouterEntry(taskFile)) continue; // missing/unparseable, or already fully compacted
66
+ const fullyValidated = Object.values(taskFile).every((entry) => {
67
+ if (isCompacted(entry)) return true;
68
+ const subs = Object.values(subTasksOf(entry));
69
+ return subs.length > 0 && subs.every((s) => s.status === "validated");
70
+ });
71
+ if (fullyValidated) ready.push(task);
72
+ }
73
+ return ready;
74
+ }
75
+
76
+ /** Names the compactable task(s) and the exact `harness prd compact` invocation (verified against `cli/prd/compact.ts`'s own usage string). */
77
+ function compactHintText(tasks: string[]): string {
78
+ if (tasks.length === 1) return `PRD task "${tasks[0]}" is fully validated and ready to compact. Run \`harness prd compact ${tasks[0]}\`.`;
79
+ return `PRD tasks fully validated and ready to compact: ${tasks.join(", ")}. Run \`harness prd compact <task>\` for each.`;
80
+ }
81
+
82
+ /**
83
+ * Non-blocking "ready to compact" hint. Target-agnostic by design: an id
84
+ * allowlist here would silently go stale the day a target's real Stop wiring
85
+ * changes, so this mirrors the SAME per-target form choice as the block path
86
+ * directly below instead of hardcoding one target — kimi/gemini-cli/cline
87
+ * get their own native `respond()` inform envelope, everyone else gets the
88
+ * `Stop` `additionalContext` channel. Whether this function is ever REACHED
89
+ * for a given id is a wiring question, not a code question: measured live
90
+ * against the real hooks.json this project ships (claude-plugins/
91
+ * codex-plugins/kimi-code-plugins) plus each target's own hook docs — only
92
+ * Codex's Stop wiring is a plain `hook codex core` with no short-circuit;
93
+ * claude-code's and Kimi's own Stop hook always carries `--sound stop`,
94
+ * which exits in `maybePlaySound()` (`src/cli/hook-sound.ts`) before stdin
95
+ * is ever read, and hermes/gemini-cli/cline have no event literally named
96
+ * `Stop` in their own documented hook taxonomy at all — see prd.md's Known
97
+ * limitations for the full measurement. One-shot per session, on a marker
98
+ * key DISTINCT from the block-once key so neither shadows the other.
99
+ * @param id - Harness target id.
100
+ * @param payload - The raw Stop hook payload.
101
+ * @param router - The parsed router.
102
+ * @param taskFiles - Every router task's parsed task-PRD (or `null`).
103
+ * @param track - The current session track.
104
+ * @param trackFilePath - The session track file path.
105
+ * @param now - Event clock.
106
+ * @returns The native non-blocking stdout (once per session), else `""`.
107
+ */
108
+ function compactReminder(
109
+ id: string, payload: Record<string, unknown>, router: PrdRouter,
110
+ taskFiles: Record<string, PrdTaskFile | null>, track: SessionTrack, trackFilePath: string, now: number,
111
+ ): string {
112
+ const ready = compactableTasks(router, taskFiles);
113
+ if (ready.length === 0) return "";
114
+ const sessionId = typeof payload.session_id === "string" ? payload.session_id : "unknown";
115
+ const key = `${sessionId}:StopCompactHint`;
116
+ if (prdAlreadyBlocked(track, key)) return "";
117
+ persistStopBlocked(trackFilePath, track, key, now);
118
+ const text = compactHintText(ready);
119
+ if (id === "kimi" || id === "gemini-cli" || id === "cline") {
120
+ return respond(id, { kind: "inform", title: "PRD compact", reason: text }, "Stop");
121
+ }
122
+ return contextResponse("Stop", text);
123
+ }
124
+
60
125
  /**
61
126
  * Run the PRD lead-Stop block-once gate.
62
127
  * @param payload - The raw Stop hook payload.
@@ -64,7 +129,8 @@ function persistStopBlocked(trackFilePath: string, track: SessionTrack, key: str
64
129
  * @param id - Harness target id.
65
130
  * @param trackFilePath - The session track file path.
66
131
  * @param now - Event clock.
67
- * @returns The native block stdout (once per session), else `""`.
132
+ * @returns The native block stdout (once per session); else the compact
133
+ * hint (once per session, whichever ids' real Stop wiring reaches it); else `""`.
68
134
  */
69
135
  export function prdStopGate(payload: Record<string, unknown>, cwd: string, id: string, trackFilePath: string, now: number): string {
70
136
  if (!isPrdEnabled(cwd, id)) return "";
@@ -82,7 +148,7 @@ export function prdStopGate(payload: Record<string, unknown>, cwd: string, id: s
82
148
  const trackHasViolations = (track.prdViolations?.length ?? 0) > 0;
83
149
  if (!trackHasViolations) {
84
150
  const taskFiles = readAllTaskFilesSync(root, homeSeg, router);
85
- if (!hasAnyViolations(router, taskFiles)) return "";
151
+ if (!hasAnyViolations(router, taskFiles)) return compactReminder(id, payload, router, taskFiles, track, trackFilePath, now);
86
152
  }
87
153
 
88
154
  const sessionId = typeof payload.session_id === "string" ? payload.session_id : "unknown";