@fusengine/harness 0.1.91 → 0.1.93

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.
Files changed (83) hide show
  1. package/README.md +17 -0
  2. package/dist/adapters/claude/index.mjs +1 -1
  3. package/dist/adapters/codex/index.mjs +1 -1
  4. package/dist/adapters/cursor/index.mjs +1 -1
  5. package/dist/adapters/hermes/index.mjs +1 -1
  6. package/dist/adapters/kimi/index.mjs +1 -1
  7. package/dist/apex-target-Xc2M32Pl.mjs +48 -0
  8. package/dist/apex-task-store-Cb65rE5i.mjs +467 -0
  9. package/dist/{claude-Ckv2_TgP.mjs → claude-D62hkUfS.mjs} +2 -84
  10. package/dist/cli/bin.mjs +7 -5
  11. package/dist/cli/index.d.mts +69 -1
  12. package/dist/cli/index.mjs +2 -2
  13. package/dist/config/index.mjs +2 -1
  14. package/dist/{dotenv-BLBkBTww.mjs → dotenv-C1LkcfW-.mjs} +1 -26
  15. package/dist/{handle-C43gA-Pr.mjs → handle-B-g9KL_9.mjs} +4837 -3466
  16. package/dist/{hermes-B9-p_3IF.mjs → hermes-ByopGx6C.mjs} +1 -1
  17. package/dist/index.d.mts +1 -1
  18. package/dist/index.mjs +3 -2
  19. package/dist/{kimi-G2wcSh5-.mjs → kimi-C-Oia9q-.mjs} +1 -1
  20. package/dist/{normalize-BjG6unTj.mjs → normalize-Dy8g9Ybl.mjs} +83 -3
  21. package/dist/policy/index.mjs +1 -1
  22. package/dist/prd-PvK7PKWS.mjs +494 -0
  23. package/dist/runtime/index.d.mts +13 -1
  24. package/dist/runtime/index.mjs +1 -1
  25. package/dist/runtime-io-DuumUeE6.mjs +84 -0
  26. package/dist/{session-state-D5gLr66m.d.mts → session-state-COg7Ej_2.d.mts} +32 -1
  27. package/dist/{skill-path-DVML3zfp.mjs → skill-path-Cz8WFaGu.mjs} +1 -1
  28. package/dist/{store-5-ZPKb0u.mjs → store-BVY6gIYM.mjs} +75 -3
  29. package/dist/tracking/index.d.mts +2 -2
  30. package/dist/tracking/index.mjs +2 -2
  31. package/dist/ttl-Dgwg_QAv.mjs +26 -0
  32. package/dist/{validate-KjZ1X9tH.mjs → validate-Dcjl0LUS.mjs} +4 -49
  33. package/package.json +1 -1
  34. package/src/adapters/cursor/context-budget.ts +144 -0
  35. package/src/adapters/cursor/context-limit.ts +115 -0
  36. package/src/adapters/cursor/context.ts +21 -2
  37. package/src/adapters/cursor/interfaces/context-budget.ts +25 -0
  38. package/src/adapters/cursor/native-response.ts +10 -129
  39. package/src/adapters/cursor/native-schemas.ts +161 -0
  40. package/src/adapters/cursor/normalize.ts +65 -0
  41. package/src/adapters/cursor/plugin-root.ts +103 -0
  42. package/src/adapters/cursor/respond.ts +94 -47
  43. package/src/cli/bin.ts +3 -0
  44. package/src/cli/index.ts +1 -0
  45. package/src/cli/prd/compact.ts +53 -0
  46. package/src/cli/prd/format.ts +23 -0
  47. package/src/cli/prd/index.ts +23 -0
  48. package/src/cli/prd/resolve.ts +88 -0
  49. package/src/cli/prd/shared.ts +80 -0
  50. package/src/cli/prd/status.ts +87 -0
  51. package/src/cli/prd/validate.ts +90 -0
  52. package/src/policy/prd/index.ts +36 -0
  53. package/src/policy/prd/interfaces/types.ts +103 -0
  54. package/src/policy/prd/prd-compact.ts +28 -0
  55. package/src/policy/prd/prd-context.ts +131 -0
  56. package/src/policy/prd/prd-crosscheck.ts +76 -0
  57. package/src/policy/prd/prd-enabled.ts +39 -0
  58. package/src/policy/prd/prd-io.ts +89 -0
  59. package/src/policy/prd/prd-ownership.ts +99 -0
  60. package/src/policy/prd/prd-paths.ts +88 -0
  61. package/src/policy/prd/prd-schema.ts +151 -0
  62. package/src/runtime/handle-post.ts +2 -0
  63. package/src/runtime/handle-pre.ts +11 -0
  64. package/src/runtime/handle.ts +87 -6
  65. package/src/runtime/lifecycle/aipilot/dispatch-aipilot.ts +7 -1
  66. package/src/runtime/lifecycle/dispatch.ts +18 -5
  67. package/src/runtime/lifecycle/failure-lesson.ts +6 -2
  68. package/src/runtime/lifecycle/rules-root.ts +18 -2
  69. package/src/runtime/normalize.ts +4 -0
  70. package/src/runtime/prd/index.ts +8 -0
  71. package/src/runtime/prd/prd-bash-targets.ts +199 -0
  72. package/src/runtime/prd/prd-candidate-files.ts +23 -0
  73. package/src/runtime/prd/prd-canon.ts +50 -0
  74. package/src/runtime/prd/prd-identity.ts +32 -0
  75. package/src/runtime/prd/prd-post-check.ts +73 -0
  76. package/src/runtime/prd/prd-pre-gate.ts +172 -0
  77. package/src/runtime/prd/prd-stop-gate.ts +101 -0
  78. package/src/runtime/prd/prd-subagent-context.ts +69 -0
  79. package/src/runtime/prd/prd-subagent-stop.ts +152 -0
  80. package/src/tracking/session-state.ts +46 -0
  81. package/src/tracking/track-diff.ts +3 -0
  82. package/src/tracking/track-journal.ts +10 -1
  83. package/dist/run-DkrzC0gb.mjs +0 -42
@@ -1,6 +1,9 @@
1
1
  import { formatPrompt, type Prompt } from "../../prompt/types";
2
2
  import { cursorEventContract } from "./events";
3
3
  import { parseNativeCursorStdout } from "./native-response";
4
+ import { capAdditionalContext } from "./context-limit";
5
+ import { capAdditionalContextWithBudget } from "./context-budget";
6
+ import type { CursorBudgetContext } from "./interfaces/context-budget";
4
7
 
5
8
  const AGENT_MESSAGE_EVENTS = new Set([
6
9
  "preToolUse",
@@ -30,39 +33,73 @@ function isRecord(value: unknown): value is Record<string, unknown> {
30
33
  return typeof value === "object" && value !== null && !Array.isArray(value);
31
34
  }
32
35
 
33
- /** Render a portable policy prompt using the native Cursor event contract. */
36
+ /**
37
+ * Render a portable policy prompt using the native Cursor event contract.
38
+ * Switches exhaustively on {@link CursorResponseKind} — the `never` default
39
+ * fails to compile if a new kind is ever added without a matching case.
40
+ * `contract.known === false` is not tested separately: the single
41
+ * `UNKNOWN_EVENT` fallback in events.ts always pairs `known: false` with
42
+ * `response: "neutral"`, so both collapse to the same `"{}"` branch.
43
+ */
34
44
  export function toCursorResponse(prompt: Prompt, eventName: string): string {
35
45
  const contract = cursorEventContract(eventName);
36
46
  const message = formatPrompt(prompt);
37
- if (!contract.known || contract.response === "neutral" || contract.response === "plugin-paths") return "{}";
38
- if (contract.response === "post-context" || contract.response === "session-context") {
39
- return JSON.stringify({ additional_context: message });
47
+ switch (contract.response) {
48
+ case "neutral":
49
+ case "plugin-paths":
50
+ return "{}";
51
+ case "post-context":
52
+ case "session-context":
53
+ return capAdditionalContext(JSON.stringify({ additional_context: message }));
54
+ case "followup":
55
+ return JSON.stringify({ followup_message: message });
56
+ case "compact-notice":
57
+ return JSON.stringify({ user_message: prompt.userMessage ?? message });
58
+ case "submit-control":
59
+ return JSON.stringify({ continue: prompt.kind !== "block", user_message: prompt.userMessage ?? message });
60
+ case "permission": {
61
+ if (prompt.kind === "inform") {
62
+ return JSON.stringify({
63
+ permission: "allow",
64
+ ...permissionMessages(eventName, prompt.userMessage, prompt.reason ? message : undefined),
65
+ });
66
+ }
67
+ const userMessage = prompt.kind === "ask"
68
+ ? `[downgraded from ask — Cursor does not enforce approval for this event]\n${message}`
69
+ : message;
70
+ return JSON.stringify({
71
+ permission: "deny",
72
+ ...permissionMessages(eventName, userMessage, userMessage),
73
+ });
74
+ }
75
+ default: {
76
+ const exhaustive: never = contract.response;
77
+ return exhaustive;
78
+ }
40
79
  }
41
- if (contract.response === "followup") return JSON.stringify({ followup_message: message });
42
- if (contract.response === "compact-notice") return JSON.stringify({ user_message: prompt.userMessage ?? message });
43
- if (contract.response === "submit-control") {
44
- return JSON.stringify({ continue: prompt.kind !== "block", user_message: prompt.userMessage ?? message });
45
- }
46
- if (prompt.kind === "inform") {
47
- return JSON.stringify({
48
- permission: "allow",
49
- ...permissionMessages(eventName, prompt.userMessage, prompt.reason ? message : undefined),
50
- });
51
- }
52
- const userMessage = prompt.kind === "ask"
53
- ? `[downgraded from ask — Cursor does not enforce approval for this event]\n${message}`
54
- : message;
55
- return JSON.stringify({
56
- permission: "deny",
57
- ...permissionMessages(eventName, userMessage, userMessage),
58
- });
59
80
  }
60
81
 
61
- /** Convert a shared lifecycle handler's output to the native Cursor envelope. */
62
- export function toCursorLifecycleResponse(stdout: string, eventName: string): string {
82
+ /**
83
+ * Convert a shared lifecycle handler's output to the native Cursor envelope.
84
+ * The `neutral` and empty-`text` short circuits run before the switch (they
85
+ * apply identically across several {@link CursorResponseKind} values), so
86
+ * only the remaining 7 kinds need a case — `never` below still catches a
87
+ * future kind added without updating this function. This is the single
88
+ * point every Cursor stdout passes through exactly once (see `handle.ts`'s
89
+ * `handleHook`), so `budget` — when supplied — is reserved from and
90
+ * recorded into here, never at the inner `toCursorResponse` pre-cap (that
91
+ * one's output is re-capped here again on the native-passthrough branch
92
+ * below, so budgeting it too would double-count the same contribution).
93
+ * @param stdout - The shared handler's raw stdout for this hook invocation.
94
+ * @param eventName - Cursor's raw `hook_event_name`.
95
+ * @param budget - Shared `additional_context` budget context (see
96
+ * {@link CursorBudgetContext}); `undefined` falls back to the flat
97
+ * per-response 10,000-char cap, unbudgeted.
98
+ */
99
+ export function toCursorLifecycleResponse(stdout: string, eventName: string, budget?: CursorBudgetContext): string {
63
100
  const contract = cursorEventContract(eventName);
64
101
  const native = parseNativeCursorStdout(stdout, eventName);
65
- if (native !== null) return native;
102
+ if (native !== null) return capAdditionalContextWithBudget(native, budget);
66
103
  let text = stdout;
67
104
  let decision: "allow" | "deny" | "ask" | undefined;
68
105
  let userMessage = "";
@@ -97,27 +134,37 @@ export function toCursorLifecycleResponse(stdout: string, eventName: string): st
97
134
  }
98
135
  if (contract.response === "neutral") return "{}";
99
136
  if (!text) return contract.response === "permission" ? '{"permission":"allow"}' : "{}";
100
- if (contract.response === "session-context" || contract.response === "post-context") {
101
- return JSON.stringify({ additional_context: text });
102
- }
103
- if (contract.response === "permission") {
104
- const permission = decision === "deny" || decision === "ask" ? "deny" : "allow";
105
- const denied = permission === "deny";
106
- // Cursor subagentStart can gate creation but has no model-context channel.
107
- // Drop shared context and its "injected" notice on allow: preserving either
108
- // would claim delivery the native event contract cannot perform.
109
- if (eventName === "subagentStart" && !denied) return '{"permission":"allow"}';
110
- return JSON.stringify({
111
- permission,
112
- ...permissionMessages(
113
- eventName,
114
- userMessage || (denied ? decisionMessage || agentMessage : ""),
115
- agentMessage || (denied ? decisionMessage || userMessage : structured ? "" : text),
116
- ),
117
- });
137
+ switch (contract.response) {
138
+ case "session-context":
139
+ case "post-context":
140
+ return capAdditionalContextWithBudget(JSON.stringify({ additional_context: text }), budget);
141
+ case "permission": {
142
+ const permission = decision === "deny" || decision === "ask" ? "deny" : "allow";
143
+ const denied = permission === "deny";
144
+ // Cursor subagentStart can gate creation but has no model-context channel.
145
+ // Drop shared context and its "injected" notice on allow: preserving either
146
+ // would claim delivery the native event contract cannot perform.
147
+ if (eventName === "subagentStart" && !denied) return '{"permission":"allow"}';
148
+ return JSON.stringify({
149
+ permission,
150
+ ...permissionMessages(
151
+ eventName,
152
+ userMessage || (denied ? decisionMessage || agentMessage : ""),
153
+ agentMessage || (denied ? decisionMessage || userMessage : structured ? "" : text),
154
+ ),
155
+ });
156
+ }
157
+ case "followup":
158
+ return JSON.stringify({ followup_message: text });
159
+ case "compact-notice":
160
+ return JSON.stringify({ user_message: text });
161
+ case "submit-control":
162
+ return JSON.stringify({ continue: true, user_message: text });
163
+ case "plugin-paths":
164
+ return "{}";
165
+ default: {
166
+ const exhaustive: never = contract.response;
167
+ return exhaustive;
168
+ }
118
169
  }
119
- if (contract.response === "followup") return JSON.stringify({ followup_message: text });
120
- if (contract.response === "compact-notice") return JSON.stringify({ user_message: text });
121
- if (contract.response === "submit-control") return JSON.stringify({ continue: true, user_message: text });
122
- return "{}";
123
170
  }
package/src/cli/bin.ts CHANGED
@@ -23,6 +23,7 @@ import { runDoctor, runningVersion, versionBanner } from "./doctor";
23
23
  import { parseScope } from "./scope";
24
24
  import { isMalformedCursorStdin, isOversize, oversizeStdout, readStdin, traceHook } from "./hook-io";
25
25
  import { maybePlaySound } from "./hook-sound";
26
+ import { runPrd } from "./prd";
26
27
 
27
28
  const cmd = process.argv[2];
28
29
 
@@ -77,6 +78,8 @@ if (cmd === "--version" || cmd === "-v") {
77
78
  const dir = process.argv[3] ?? process.cwd();
78
79
  process.stdout.write(JSON.stringify(runSecurityScan(dir), null, 2) + "\n");
79
80
  process.exit(0);
81
+ } else if (cmd === "prd") {
82
+ process.exit(await runPrd(process.argv.slice(3), process.cwd(), process.env));
80
83
  } else if (cmd === "codex-rules") {
81
84
  const outIdx = process.argv.indexOf("--out");
82
85
  const outPath = outIdx !== -1 ? process.argv[outIdx + 1] : undefined;
package/src/cli/index.ts CHANGED
@@ -1 +1,2 @@
1
1
  export * from "./run";
2
+ export * from "./prd";
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `harness prd compact <task> [--id] [--root]` — collapses every fully
3
+ * validated agent entry of a task-PRD to its compacted shape. Requires
4
+ * `FUSE_PRD=1`; refuses (exit 1) when any sub-task is not yet `validated`.
5
+ */
6
+ import { writeTaskFile } from "../../policy/prd/prd-io";
7
+ import { compactTaskFile } from "../../policy/prd/prd-compact";
8
+ import { isCompacted, subTasksOf } from "../../policy/prd/prd-schema";
9
+ import { requireFusePrd, resolveTaskFile, withPrdLock } from "./shared";
10
+
11
+ /**
12
+ * Run `harness prd compact`. Exit 0 on success (including a no-op compact),
13
+ * exit 1 when `FUSE_PRD` is unset, a sub-task is unvalidated, or the lock is
14
+ * held, exit 2 on usage/lookup errors.
15
+ */
16
+ export async function runPrdCompact(argv: string[], cwd: string, env: NodeJS.ProcessEnv = process.env): Promise<number> {
17
+ const resolved = requireFusePrd(argv, cwd, env, "compact");
18
+ if (!resolved.ok) {
19
+ process.stderr.write(resolved.message + "\n");
20
+ return resolved.code;
21
+ }
22
+ const { root, homeSeg } = resolved;
23
+
24
+ const tf = await resolveTaskFile(argv, root, homeSeg, "usage: harness prd compact <task>");
25
+ if (!tf.ok) {
26
+ process.stderr.write(tf.message + "\n");
27
+ return tf.code;
28
+ }
29
+ const { routerEntry, taskFile } = tf;
30
+
31
+ for (const [agent, entry] of Object.entries(taskFile)) {
32
+ if (isCompacted(entry)) continue;
33
+ for (const [sub, status] of Object.entries(subTasksOf(entry))) {
34
+ if (status.status !== "validated") {
35
+ process.stderr.write(`prd compact: sub-task "${sub}" of agent "${agent}" is not validated\n`);
36
+ return 1;
37
+ }
38
+ }
39
+ }
40
+
41
+ const result = await withPrdLock(root, homeSeg, async () => {
42
+ const now = new Date().toISOString();
43
+ const { file, compacted } = compactTaskFile(taskFile, now);
44
+ await writeTaskFile(root, homeSeg, routerEntry.prd, file);
45
+ return compacted;
46
+ });
47
+ if (!result.ok) {
48
+ process.stderr.write(`prd compact: ${result.message}\n`);
49
+ return 1;
50
+ }
51
+ process.stdout.write(result.value.length > 0 ? `compacted: ${result.value.join(", ")}\n` : "nothing to compact\n");
52
+ return 0;
53
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Rendering helpers for `harness prd` output: an aligned ASCII table for the
3
+ * human-readable path, and pretty JSON for `--json`.
4
+ */
5
+
6
+ /** Pad `s` with trailing spaces to `width`. */
7
+ function pad(s: string, width: number): string {
8
+ return s.length >= width ? s : s + " ".repeat(width - s.length);
9
+ }
10
+
11
+ /** Render an aligned ASCII table from a header row + data rows. */
12
+ export function renderTable(header: string[], rows: string[][]): string {
13
+ const all: string[][] = [header, ...rows];
14
+ const widths = header.map((_, col) => Math.max(...all.map((r) => (r[col] ?? "").length)));
15
+ return all
16
+ .map((r) => r.map((cell, col) => pad(cell ?? "", widths[col] ?? 0)).join(" ").trimEnd())
17
+ .join("\n");
18
+ }
19
+
20
+ /** Render `data` as pretty-printed JSON with a trailing newline. */
21
+ export function renderJson(data: unknown): string {
22
+ return JSON.stringify(data, null, 2) + "\n";
23
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `harness prd <status|validate|compact>` — CLI entry point for the PRD
3
+ * (task/agent ownership coordination) module.
4
+ */
5
+ export * from "./resolve";
6
+ export * from "./format";
7
+ export { runPrdStatus } from "./status";
8
+ export { runPrdValidate } from "./validate";
9
+ export { runPrdCompact } from "./compact";
10
+
11
+ import { runPrdStatus } from "./status";
12
+ import { runPrdValidate } from "./validate";
13
+ import { runPrdCompact } from "./compact";
14
+
15
+ /** Dispatch `harness prd <sub> ...rest` to the matching `run*` function. */
16
+ export async function runPrd(argv: string[], cwd: string, env: NodeJS.ProcessEnv = process.env): Promise<number> {
17
+ const [sub, ...rest] = argv;
18
+ if (sub === "status") return runPrdStatus(rest, cwd, env);
19
+ if (sub === "validate") return runPrdValidate(rest, cwd, env);
20
+ if (sub === "compact") return runPrdCompact(rest, cwd, env);
21
+ process.stderr.write(`harness prd: unknown sub-command "${sub ?? ""}" (expected status|validate|compact)\n`);
22
+ return 2;
23
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Argument parsing + harness resolution for `harness prd <cmd>`. Resolves
3
+ * `--root` (default `cwd`) and `--id`/`homeSeg` (default: the sole
4
+ * `HOME_DIR` (`config/dotenv.ts`) segment under which
5
+ * `<root>/<seg>/apex/prd.json` exists — ambiguous or absent is a caller
6
+ * error, never guessed).
7
+ */
8
+ import { existsSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { HOME_DIR, loadDotenv } from "../../config/dotenv";
12
+ import { harnessHomeSegment } from "../../policy/apex-target";
13
+ import type { HarnessId } from "../../detect/interfaces/types";
14
+
15
+ const VALUE_FLAGS: ReadonlySet<string> = new Set(["--id", "--root"]);
16
+ const BOOLEAN_FLAGS: ReadonlySet<string> = new Set(["--json"]);
17
+
18
+ /** Resolved `--root`/`--id` pair for a `prd` sub-command. */
19
+ export interface PrdResolved {
20
+ root: string;
21
+ homeSeg: string;
22
+ id: HarnessId;
23
+ }
24
+
25
+ /** Result of {@link resolvePrdArgs}: resolved args, or a usage error (exit 2). */
26
+ export type PrdResolveResult = { ok: true; value: PrdResolved } | { ok: false; message: string };
27
+
28
+ /** Read `--<name> <value>` from argv, or `undefined` when absent. */
29
+ export function readFlag(argv: string[], name: string): string | undefined {
30
+ const idx = argv.indexOf(name);
31
+ return idx !== -1 ? argv[idx + 1] : undefined;
32
+ }
33
+
34
+ /** True when `--json` is present in argv. */
35
+ export function hasJsonFlag(argv: string[]): boolean {
36
+ return argv.includes("--json");
37
+ }
38
+
39
+ /** Non-flag tokens in argv, skipping known flags and their values. */
40
+ export function positionalArgs(argv: string[]): string[] {
41
+ const out: string[] = [];
42
+ for (let i = 0; i < argv.length; i++) {
43
+ const tok = argv[i];
44
+ if (tok === undefined) continue;
45
+ if (VALUE_FLAGS.has(tok)) { i++; continue; }
46
+ if (BOOLEAN_FLAGS.has(tok)) continue;
47
+ out.push(tok);
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /**
53
+ * Resolve `{root, homeSeg, id}` for a `prd` sub-command. `--id` wins
54
+ * outright; otherwise auto-detects the sole `HOME_DIR` segment under which
55
+ * `<root>/<seg>/apex/prd.json` exists.
56
+ */
57
+ export function resolvePrdArgs(argv: string[], cwd: string): PrdResolveResult {
58
+ const root = readFlag(argv, "--root") ?? cwd;
59
+ const idFlag = readFlag(argv, "--id");
60
+ if (idFlag) {
61
+ return { ok: true, value: { root, homeSeg: harnessHomeSegment(idFlag), id: idFlag as HarnessId } };
62
+ }
63
+
64
+ const matches: Array<{ id: HarnessId; seg: string }> = [];
65
+ for (const [id, seg] of Object.entries(HOME_DIR) as Array<[HarnessId, string]>) {
66
+ if (existsSync(join(root, seg, "apex", "prd.json"))) matches.push({ id, seg });
67
+ }
68
+ if (matches.length === 0) {
69
+ return { ok: false, message: `no PRD router found under ${root}/<home>/apex/prd.json — pass --id <harness>` };
70
+ }
71
+ if (matches.length > 1) {
72
+ return { ok: false, message: `ambiguous harness: PRD routers found for ${matches.map((m) => m.id).join(", ")} — pass --id` };
73
+ }
74
+ const only = matches[0] as { id: HarnessId; seg: string };
75
+ return { ok: true, value: { root, homeSeg: only.seg, id: only.id } };
76
+ }
77
+
78
+ /**
79
+ * Shared first step of every `prd` sub-command: {@link resolvePrdArgs}, then
80
+ * (on success) `loadDotenv` the resolved harness's home `.env` + `<root>/.env`
81
+ * as a side effect on `env` — same env-loading contract as the `hook` branch
82
+ * (`bin.ts`'s `loadDotenv(id)` call).
83
+ */
84
+ export function resolveAndLoadEnv(argv: string[], cwd: string, env: NodeJS.ProcessEnv = process.env): PrdResolveResult {
85
+ const resolved = resolvePrdArgs(argv, cwd);
86
+ if (resolved.ok) loadDotenv(resolved.value.id, env, homedir(), resolved.value.root);
87
+ return resolved;
88
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Shared plumbing for `status`/`validate`/`compact`: root+env resolution,
3
+ * the `FUSE_PRD` write gate, task-PRD lookup, and the `.lock`-guarded write.
4
+ * All return a result instead of calling `process.exit` themselves —
5
+ * callers own the exit code (extracted to kill a jscpd-measured clone
6
+ * across the three `run*` functions).
7
+ */
8
+ import { join } from "node:path";
9
+ import { prdDir } from "../../policy/prd/prd-paths";
10
+ import { readRouter, readTaskFile } from "../../policy/prd/prd-io";
11
+ import { isPrdFlagSet } from "../../policy/prd/prd-enabled";
12
+ import type { PrdRouter, PrdRouterEntry, PrdTaskFile } from "../../policy/prd/interfaces/types";
13
+ import { acquireLock } from "../../runtime/lifecycle/aipilot/apex-task-store";
14
+ import { positionalArgs, resolveAndLoadEnv } from "./resolve";
15
+
16
+ /** `{root, homeSeg}` on success, or an exit code + message on failure. */
17
+ export type PrdStepResult = { ok: true; root: string; homeSeg: string } | { ok: false; code: number; message: string };
18
+
19
+ /** {@link resolveAndLoadEnv}, narrowed to `{root, homeSeg}` — the common first step of every sub-command. */
20
+ export function resolveRoot(argv: string[], cwd: string, env: NodeJS.ProcessEnv): PrdStepResult {
21
+ const resolved = resolveAndLoadEnv(argv, cwd, env);
22
+ if (!resolved.ok) return { ok: false, code: 2, message: resolved.message };
23
+ return { ok: true, root: resolved.value.root, homeSeg: resolved.value.homeSeg };
24
+ }
25
+
26
+ /** {@link resolveRoot} + the `FUSE_PRD=1` write gate shared by `validate`/`compact`. */
27
+ export function requireFusePrd(argv: string[], cwd: string, env: NodeJS.ProcessEnv, sub: string): PrdStepResult {
28
+ const resolved = resolveRoot(argv, cwd, env);
29
+ if (!resolved.ok) return resolved;
30
+ if (!isPrdFlagSet(env)) {
31
+ return { ok: false, code: 1, message: `prd ${sub} requires FUSE_PRD=1 in ${resolved.homeSeg}/.env or the project .env` };
32
+ }
33
+ return resolved;
34
+ }
35
+
36
+ /** Result of {@link loadTaskFileFor}: the resolved router/task-PRD, or a usage error. */
37
+ export type PrdTaskLookup =
38
+ | { ok: true; router: PrdRouter; routerEntry: PrdRouterEntry; taskFile: PrdTaskFile }
39
+ | { ok: false; code: 2; message: string };
40
+
41
+ /** Reads the router + the named task's task-PRD file. Never calls `process.exit`. */
42
+ export async function loadTaskFileFor(root: string, homeSeg: string, task: string): Promise<PrdTaskLookup> {
43
+ const router = await readRouter(root, homeSeg);
44
+ const routerEntry = router?.[task];
45
+ if (!router || !routerEntry) return { ok: false, code: 2, message: `no such task "${task}" in PRD router` };
46
+
47
+ const taskFile = await readTaskFile(root, homeSeg, routerEntry.prd);
48
+ if (!taskFile) return { ok: false, code: 2, message: `task PRD not found or malformed: ${routerEntry.prd}` };
49
+
50
+ return { ok: true, router, routerEntry, taskFile };
51
+ }
52
+
53
+ /** Result of {@link resolveTaskFile}: the `<task>` positional plus its looked-up files, or a usage/lookup error. */
54
+ export type PrdTaskArgLookup =
55
+ | { ok: true; task: string; agentArg: string | undefined; router: PrdRouter; routerEntry: PrdRouterEntry; taskFile: PrdTaskFile }
56
+ | { ok: false; code: 2; message: string };
57
+
58
+ /** Extracts `<task> [agent]` from argv, then {@link loadTaskFileFor} — the shared `validate`/`compact` entry lookup. */
59
+ export async function resolveTaskFile(argv: string[], root: string, homeSeg: string, usage: string): Promise<PrdTaskArgLookup> {
60
+ const [task, agentArg] = positionalArgs(argv);
61
+ if (!task) return { ok: false, code: 2, message: usage };
62
+ const lookup = await loadTaskFileFor(root, homeSeg, task);
63
+ if (!lookup.ok) return lookup;
64
+ return { task, agentArg, ...lookup };
65
+ }
66
+
67
+ /** Result of {@link withPrdLock}. */
68
+ export type PrdLockResult<T> = { ok: true; value: T } | { ok: false; message: string };
69
+
70
+ /** Runs `fn` under the PRD `.lock` directory-lock. Never calls `process.exit`. */
71
+ export async function withPrdLock<T>(root: string, homeSeg: string, fn: () => Promise<T>): Promise<PrdLockResult<T>> {
72
+ const lockDir = join(prdDir(root, homeSeg), ".lock");
73
+ const release = await acquireLock(lockDir);
74
+ if (!release) return { ok: false, message: `lock held at ${lockDir}` };
75
+ try {
76
+ return { ok: true, value: await fn() };
77
+ } finally {
78
+ await release();
79
+ }
80
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `harness prd status [--json] [--id <harness>] [--root <dir>]` — read-only
3
+ * PRD snapshot. Never requires `FUSE_PRD`. Exit 0 on success, exit 1 when no
4
+ * router is found, exit 2 on an unresolved/ambiguous `--id`.
5
+ */
6
+ import { prdRouterPath } from "../../policy/prd/prd-paths";
7
+ import { readAgentReport, readAllTaskFiles, readRouter } from "../../policy/prd/prd-io";
8
+ import { crossCheckTask } from "../../policy/prd/prd-crosscheck";
9
+ import { isCompacted, subTasksOf } from "../../policy/prd/prd-schema";
10
+ import type { PrdAgentReportFile, PrdTaskFile } from "../../policy/prd/interfaces/types";
11
+ import { hasJsonFlag } from "./resolve";
12
+ import { renderJson, renderTable } from "./format";
13
+ import { resolveRoot } from "./shared";
14
+
15
+ const HEADER: string[] = ["Task", "Router status", "Agents", "Sub-tasks done/total", "Violations"];
16
+
17
+ /**
18
+ * Sub-task readiness counts for the "done/total" column: a sub-task counts as
19
+ * done once it is `validated` OR its owning agent's own report already shows
20
+ * `"done"` for it (readiness view — a report can land well before the
21
+ * coordinator runs `prd validate`). A compacted (fully-validated) entry
22
+ * counts as one done/one total unit — its per-sub-task detail is gone.
23
+ */
24
+ function subTaskCounts(
25
+ taskFile: PrdTaskFile,
26
+ reports: Record<string, PrdAgentReportFile | null>,
27
+ task: string,
28
+ ): { done: number; total: number } {
29
+ let done = 0;
30
+ let total = 0;
31
+ for (const [agent, entry] of Object.entries(taskFile)) {
32
+ if (isCompacted(entry)) {
33
+ done++;
34
+ total++;
35
+ continue;
36
+ }
37
+ const report = reports[agent];
38
+ for (const [sub, subTask] of Object.entries(subTasksOf(entry))) {
39
+ total++;
40
+ if (subTask.status === "validated" || report?.[task]?.[sub]?.status === "done") done++;
41
+ }
42
+ }
43
+ return { done, total };
44
+ }
45
+
46
+ /**
47
+ * Run `harness prd status`. Prints an aligned table (or `--json` dump of
48
+ * `{router, taskFiles, reports}`) and returns the process exit code.
49
+ */
50
+ export async function runPrdStatus(argv: string[], cwd: string, env: NodeJS.ProcessEnv = process.env): Promise<number> {
51
+ const resolved = resolveRoot(argv, cwd, env);
52
+ if (!resolved.ok) {
53
+ process.stderr.write(resolved.message + "\n");
54
+ return resolved.code;
55
+ }
56
+ const { root, homeSeg } = resolved;
57
+
58
+ const router = await readRouter(root, homeSeg);
59
+ if (!router) {
60
+ process.stderr.write(`no PRD router at ${prdRouterPath(root, homeSeg)}\n`);
61
+ return 1;
62
+ }
63
+
64
+ const taskFiles = await readAllTaskFiles(root, homeSeg, router);
65
+ const agentNames = new Set<string>();
66
+ for (const tf of Object.values(taskFiles)) {
67
+ if (tf) for (const agent of Object.keys(tf)) agentNames.add(agent);
68
+ }
69
+ const reports: Record<string, PrdAgentReportFile | null> = {};
70
+ for (const agent of agentNames) reports[agent] = await readAgentReport(root, homeSeg, agent);
71
+
72
+ if (hasJsonFlag(argv)) {
73
+ process.stdout.write(renderJson({ router, taskFiles, reports }));
74
+ return 0;
75
+ }
76
+
77
+ const rows: string[][] = [];
78
+ for (const [task, entry] of Object.entries(router)) {
79
+ const tf = taskFiles[task];
80
+ const agentCount = tf ? Object.keys(tf).length : 0;
81
+ const { done, total } = tf ? subTaskCounts(tf, reports, task) : { done: 0, total: 0 };
82
+ const violations = tf ? crossCheckTask(tf, reports as Record<string, PrdAgentReportFile>, task) : [];
83
+ rows.push([task, entry.status, String(agentCount), `${done}/${total}`, String(violations.length)]);
84
+ }
85
+ process.stdout.write(renderTable(HEADER, rows) + "\n");
86
+ return 0;
87
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * `harness prd validate <task> [agent] [--id] [--root]` — cross-checks a
3
+ * task-PRD against the named agent(s)' own report(s); on success flips the
4
+ * matching sub-tasks to `validated` and promotes the router entry when every
5
+ * agent is fully validated. Requires `FUSE_PRD=1`.
6
+ */
7
+ import { readAgentReport, writeRouter, writeTaskFile } from "../../policy/prd/prd-io";
8
+ import { incompleteSubTasks } from "../../policy/prd/prd-crosscheck";
9
+ import { isCompacted, subTasksOf, withRouterStatus, withSubTaskValidated } from "../../policy/prd/prd-schema";
10
+ import type { PrdAgentReportFile, PrdCrossCheckViolation, PrdTaskFile } from "../../policy/prd/interfaces/types";
11
+ import { requireFusePrd, resolveTaskFile, withPrdLock } from "./shared";
12
+
13
+ function allValidated(taskFile: PrdTaskFile): boolean {
14
+ return Object.values(taskFile).every(
15
+ (entry) => isCompacted(entry) || Object.values(subTasksOf(entry)).every((s) => s.status === "validated"),
16
+ );
17
+ }
18
+
19
+ /**
20
+ * Sub-tasks of `agents` with no matching `done` entry in their owning
21
+ * agent's own report — the readiness gate `validate` enforces before
22
+ * flipping anything. Reuses `incompleteSubTasks` (distinct from
23
+ * `crossCheckTask`, which only flags an ALREADY-`validated` sub-task lacking
24
+ * a report; here nothing has been validated yet).
25
+ */
26
+ function findUnreadySubTasks(
27
+ taskFile: PrdTaskFile,
28
+ reports: Record<string, PrdAgentReportFile | null>,
29
+ task: string,
30
+ agents: string[],
31
+ ): PrdCrossCheckViolation[] {
32
+ const out: PrdCrossCheckViolation[] = [];
33
+ for (const agent of agents) {
34
+ const missing = incompleteSubTasks(taskFile, agent, task, reports[agent] ?? null);
35
+ for (const sub of missing) out.push({ task, agent, sub, reason: `no "done" report from "${agent}" for sub-task "${sub}"` });
36
+ }
37
+ return out;
38
+ }
39
+
40
+ /**
41
+ * Run `harness prd validate`. Exit 0 on success, exit 1 on cross-check
42
+ * violations or a missing `FUSE_PRD=1`/held lock, exit 2 on usage/lookup errors.
43
+ */
44
+ export async function runPrdValidate(argv: string[], cwd: string, env: NodeJS.ProcessEnv = process.env): Promise<number> {
45
+ const resolved = requireFusePrd(argv, cwd, env, "validate");
46
+ if (!resolved.ok) {
47
+ process.stderr.write(resolved.message + "\n");
48
+ return resolved.code;
49
+ }
50
+ const { root, homeSeg } = resolved;
51
+
52
+ const tf = await resolveTaskFile(argv, root, homeSeg, "usage: harness prd validate <task> [agent]");
53
+ if (!tf.ok) {
54
+ process.stderr.write(tf.message + "\n");
55
+ return tf.code;
56
+ }
57
+ const { task, agentArg, router, routerEntry, taskFile } = tf;
58
+ if (agentArg && !(agentArg in taskFile)) {
59
+ process.stderr.write(`no such agent "${agentArg}" in task PRD for "${task}"\n`);
60
+ return 2;
61
+ }
62
+
63
+ const agents = agentArg ? [agentArg] : Object.keys(taskFile);
64
+ const reports: Record<string, PrdAgentReportFile | null> = {};
65
+ for (const agent of Object.keys(taskFile)) reports[agent] = await readAgentReport(root, homeSeg, agent);
66
+
67
+ const violations = findUnreadySubTasks(taskFile, reports, task, agents);
68
+ if (violations.length > 0) {
69
+ process.stderr.write(`prd validate: ${violations.length} violation(s)\n`);
70
+ for (const v of violations) process.stderr.write(` - ${v.task}/${v.agent}/${v.sub}: ${v.reason}\n`);
71
+ return 1;
72
+ }
73
+
74
+ const result = await withPrdLock(root, homeSeg, async () => {
75
+ const now = new Date().toISOString();
76
+ let updated = taskFile;
77
+ for (const agent of agents) {
78
+ const entry = updated[agent];
79
+ if (!entry || isCompacted(entry)) continue;
80
+ for (const sub of Object.keys(subTasksOf(entry))) updated = withSubTaskValidated(updated, agent, sub, now);
81
+ }
82
+ await writeTaskFile(root, homeSeg, routerEntry.prd, updated);
83
+ if (allValidated(updated)) await writeRouter(root, homeSeg, withRouterStatus(router, task, "validated", now));
84
+ });
85
+ if (!result.ok) {
86
+ process.stderr.write(`prd validate: ${result.message}\n`);
87
+ return 1;
88
+ }
89
+ return 0;
90
+ }