ultimate-pi 0.20.0 → 0.22.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.
Files changed (130) hide show
  1. package/.agents/skills/harness-decisions/SKILL.md +68 -2
  2. package/.agents/skills/harness-git-commit/SKILL.md +72 -0
  3. package/.agents/skills/harness-governor/SKILL.md +2 -2
  4. package/.agents/skills/harness-ls-lint-setup/SKILL.md +59 -0
  5. package/.agents/skills/harness-plan/SKILL.md +13 -11
  6. package/.agents/skills/harness-review/SKILL.md +1 -1
  7. package/.agents/skills/harness-sentrux-repair/SKILL.md +48 -0
  8. package/.agents/skills/sentrux/SKILL.md +4 -2
  9. package/.agents/skills/wiki-save/SKILL.md +1 -1
  10. package/.pi/PACKAGING.md +6 -0
  11. package/.pi/SYSTEM.md +21 -3
  12. package/.pi/agents/harness/ls-lint-steward.md +49 -0
  13. package/.pi/agents/harness/planning/decompose.md +4 -4
  14. package/.pi/agents/harness/reviewing/evaluator.md +1 -1
  15. package/.pi/agents/harness/running/executor.md +1 -1
  16. package/.pi/agents/harness/sentrux-repair-advisor.md +50 -0
  17. package/.pi/agents/pi-pi/prompt-expert.md +17 -2
  18. package/.pi/auto-commit.json +9 -2
  19. package/.pi/extensions/debate-orchestrator.ts +3 -0
  20. package/.pi/extensions/harness-anchored-edit.ts +7 -9
  21. package/.pi/extensions/harness-ask-user.ts +13 -34
  22. package/.pi/extensions/harness-debate-tools.ts +43 -4
  23. package/.pi/extensions/harness-live-widget.ts +28 -19
  24. package/.pi/extensions/harness-run-context.ts +278 -115
  25. package/.pi/extensions/harness-web-tools.ts +598 -471
  26. package/.pi/extensions/ls-lint-rules-sync.ts +103 -0
  27. package/.pi/extensions/observation-bus.ts +4 -0
  28. package/.pi/extensions/policy-gate.ts +270 -229
  29. package/.pi/extensions/sentrux-rules-sync.ts +2 -0
  30. package/.pi/extensions/soundboard.ts +48 -48
  31. package/.pi/harness/README.md +4 -0
  32. package/.pi/harness/agents.manifest.json +15 -7
  33. package/.pi/harness/agents.policy.yaml +49 -82
  34. package/.pi/harness/docs/adrs/0052-ls-lint-naming-lifecycle.md +45 -0
  35. package/.pi/harness/docs/adrs/0052-sentrux-structured-repair.md +38 -0
  36. package/.pi/harness/docs/adrs/0053-plan-task-clarification-gate.md +39 -0
  37. package/.pi/harness/docs/adrs/0054-harness-native-ask-user.md +40 -0
  38. package/.pi/harness/docs/adrs/0055-auto-commit-coauthor-lifecycle.md +40 -0
  39. package/.pi/harness/docs/adrs/README.md +5 -0
  40. package/.pi/harness/docs/practice-map.md +10 -5
  41. package/.pi/harness/evals/smoke/ls-lint-stub.json +10 -0
  42. package/.pi/harness/evolution/self-healing-rules.json +16 -0
  43. package/.pi/harness/ls-lint/naming.manifest.json +128 -0
  44. package/.pi/harness/sentrux/architecture.manifest.json +1 -1
  45. package/.pi/harness/specs/auto-commit.schema.json +63 -0
  46. package/.pi/harness/specs/ls-lint-manifest-proposal.schema.json +80 -0
  47. package/.pi/harness/specs/ls-lint-signal.schema.json +47 -0
  48. package/.pi/harness/specs/naming-manifest.schema.json +54 -0
  49. package/.pi/harness/specs/plan-task-clarification.schema.json +88 -0
  50. package/.pi/harness/specs/sentrux-diagnostics.schema.json +173 -0
  51. package/.pi/harness/specs/sentrux-repair-plan.schema.json +133 -0
  52. package/.pi/harness/specs/sentrux-report.schema.json +119 -0
  53. package/.pi/harness/specs/sentrux-signal.schema.json +34 -1
  54. package/.pi/lib/agents-policy.d.mts +26 -51
  55. package/.pi/lib/agents-policy.mjs +41 -28
  56. package/.pi/lib/agt/build-evaluation-context.ts +136 -64
  57. package/.pi/lib/ask-user/constants.mjs +3 -0
  58. package/.pi/lib/ask-user/constants.ts +4 -0
  59. package/.pi/lib/ask-user/contracts/glimpse-parse.ts +56 -0
  60. package/.pi/lib/ask-user/contracts/glimpse-payload-build.ts +58 -0
  61. package/.pi/lib/ask-user/contracts/glimpse-payload.ts +38 -0
  62. package/.pi/lib/ask-user/core/questionnaire.ts +74 -0
  63. package/.pi/lib/ask-user/dialog.ts +2 -314
  64. package/.pi/lib/ask-user/fallback.ts +2 -78
  65. package/.pi/lib/ask-user/format.ts +85 -0
  66. package/.pi/lib/ask-user/glimpseui.d.ts +10 -0
  67. package/.pi/lib/ask-user/index.ts +114 -0
  68. package/.pi/lib/ask-user/merge-task-clarification.ts +98 -0
  69. package/.pi/lib/ask-user/policy.mjs +43 -0
  70. package/.pi/lib/ask-user/policy.ts +104 -0
  71. package/.pi/lib/ask-user/presenters/glimpse.ts +130 -0
  72. package/.pi/lib/ask-user/presenters/headless.ts +131 -0
  73. package/.pi/lib/ask-user/presenters/select.ts +60 -0
  74. package/.pi/lib/ask-user/presenters/tui.ts +373 -0
  75. package/.pi/lib/ask-user/presenters/types.ts +13 -0
  76. package/.pi/lib/ask-user/render.ts +40 -9
  77. package/.pi/lib/ask-user/schema.ts +66 -13
  78. package/.pi/lib/ask-user/types.ts +60 -3
  79. package/.pi/lib/ask-user/validate-core.mjs +193 -7
  80. package/.pi/lib/ask-user/validate.ts +53 -34
  81. package/.pi/lib/harness-anchored-edit/package.json +3 -0
  82. package/.pi/lib/harness-artifact-gate.ts +75 -21
  83. package/.pi/lib/harness-auto-commit-config.mjs +321 -0
  84. package/.pi/lib/harness-lens/clients/lsp/client.ts +62 -39
  85. package/.pi/lib/harness-lens/clients/tool-policy.ts +73 -181
  86. package/.pi/lib/harness-lens/index.ts +241 -108
  87. package/.pi/lib/harness-lens/tools/lsp-navigation.ts +10 -8
  88. package/.pi/lib/harness-repair-brief.ts +84 -25
  89. package/.pi/lib/harness-run-context.ts +42 -52
  90. package/.pi/lib/harness-sentrux-parse.mjs +272 -0
  91. package/.pi/lib/harness-sentrux-root.mjs +78 -0
  92. package/.pi/lib/harness-slash-completions.ts +116 -0
  93. package/.pi/lib/harness-spawn-topology.ts +121 -87
  94. package/.pi/lib/harness-subagent-submit-registry.ts +10 -0
  95. package/.pi/lib/harness-subagents-bridge.ts +4 -1
  96. package/.pi/lib/harness-ui-state.ts +95 -48
  97. package/.pi/lib/plan-approval/dialog.ts +5 -0
  98. package/.pi/lib/plan-approval/validate.ts +1 -1
  99. package/.pi/lib/plan-approval-readiness.ts +32 -0
  100. package/.pi/lib/plan-debate-gate.ts +154 -114
  101. package/.pi/lib/plan-task-clarification.ts +158 -0
  102. package/.pi/prompts/harness-auto.md +2 -2
  103. package/.pi/prompts/harness-ls-lint-steward.md +43 -0
  104. package/.pi/prompts/harness-plan.md +58 -8
  105. package/.pi/prompts/harness-review.md +40 -6
  106. package/.pi/prompts/harness-run.md +33 -11
  107. package/.pi/prompts/harness-setup.md +72 -3
  108. package/.pi/prompts/harness-steer.md +2 -1
  109. package/.pi/prompts/wiki-save.md +5 -4
  110. package/.pi/scripts/README.md +8 -0
  111. package/.pi/scripts/generate-agents-policy-yaml.mjs +14 -2
  112. package/.pi/scripts/harness-auto-commit-bootstrap.mjs +96 -0
  113. package/.pi/scripts/harness-cli-verify.sh +47 -0
  114. package/.pi/scripts/harness-git-churn.mjs +77 -0
  115. package/.pi/scripts/harness-git-commit.mjs +173 -0
  116. package/.pi/scripts/harness-ls-lint-bootstrap.mjs +142 -0
  117. package/.pi/scripts/harness-ls-lint-cli.mjs +184 -0
  118. package/.pi/scripts/harness-seed-project-contracts.mjs +47 -0
  119. package/.pi/scripts/harness-sentrux-diagnostics.mjs +230 -0
  120. package/.pi/scripts/harness-sentrux-report.mjs +256 -0
  121. package/.pi/scripts/harness-verify.mjs +288 -125
  122. package/.pi/scripts/ls-lint-rules-sync.mjs +265 -0
  123. package/.pi/scripts/run-tests.mjs +1 -0
  124. package/.pi/settings.example.json +1 -0
  125. package/.sentrux/rules.toml +1 -1
  126. package/AGENTS.md +1 -0
  127. package/CHANGELOG.md +25 -0
  128. package/README.md +13 -4
  129. package/package.json +5 -1
  130. package/vendor/pi-vcc/src/hooks/before-compact.ts +86 -60
@@ -723,6 +723,15 @@ function buildNavigationOutput(args: {
723
723
  return { output, actionStats, isEmpty, resultCount };
724
724
  }
725
725
 
726
+ function isDirectoryPath(filePath: string): boolean {
727
+ if (!filePath) return false;
728
+ try {
729
+ return nodeFs.statSync(filePath).isDirectory();
730
+ } catch {
731
+ return false;
732
+ }
733
+ }
734
+
726
735
  export function createLspNavigationTool(
727
736
  getFlag: (name: string) => boolean | string | undefined,
728
737
  ) {
@@ -1021,14 +1030,7 @@ export function createLspNavigationTool(
1021
1030
  : path.resolve(ctx.cwd || ".", rawPath)
1022
1031
  : "";
1023
1032
 
1024
- let filePathIsDirectory = false;
1025
- if (filePath) {
1026
- try {
1027
- filePathIsDirectory = nodeFs.statSync(filePath).isDirectory();
1028
- } catch {
1029
- // non-existent path — existing error paths handle this
1030
- }
1031
- }
1033
+ const filePathIsDirectory = isDirectoryPath(filePath);
1032
1034
 
1033
1035
  const lspService = getLSPService();
1034
1036
  if (operation === "workspaceDiagnostics") {
@@ -64,27 +64,23 @@ export async function synthesizeRepairBrief(
64
64
  const planRel =
65
65
  input.planPacketPath?.replace(/\\/g, "/") ?? "plan-packet.yaml";
66
66
  const plan = await readArtifactYaml(runRoot, planRel, "plan-packet");
67
+ const sentruxRepair = await readArtifactYaml(
68
+ runRoot,
69
+ "artifacts/sentrux-repair-plan.yaml",
70
+ "sentrux-repair-plan",
71
+ );
67
72
 
68
73
  const remediation =
69
74
  (typeof review?.remediation_class === "string" &&
70
75
  review.remediation_class) ||
71
76
  "implementation_gap";
72
77
 
73
- const sourceArtifacts: Record<string, string> = {
74
- "review-outcome":
75
- input.reviewOutcomePath ?? "artifacts/review-outcome.yaml",
76
- };
77
- if (evalDoc) {
78
- sourceArtifacts["eval-verdict"] =
79
- input.evalVerdictPath ?? "artifacts/eval-verdict.yaml";
80
- }
81
- if (adversary) {
82
- sourceArtifacts["adversary-report"] =
83
- input.adversaryReportPath ?? "artifacts/adversary-report.yaml";
84
- }
85
- if (plan) {
86
- sourceArtifacts["plan-packet"] = planRel;
87
- }
78
+ const sourceArtifacts = buildSourceArtifacts(input, planRel, {
79
+ evalDoc,
80
+ adversary,
81
+ plan,
82
+ sentruxRepair,
83
+ });
88
84
 
89
85
  const failedIds = [
90
86
  ...stringList(review?.failed_acceptance_check_ids),
@@ -93,7 +89,7 @@ export async function synthesizeRepairBrief(
93
89
  ];
94
90
  const uniqueFailed = [...new Set(failedIds)];
95
91
 
96
- const fixDirectives: string[] = [];
92
+ const fixDirectives: string[] = sentruxFixDirectives(sentruxRepair);
97
93
  for (const key of [
98
94
  "fix_directives",
99
95
  "repair_directives",
@@ -117,15 +113,7 @@ export async function synthesizeRepairBrief(
117
113
  );
118
114
  }
119
115
 
120
- const execPlan = asRecord(plan?.execution_plan);
121
- const priorityLakeIds = stringList(execPlan?.critical_path_lake_ids);
122
- if (priorityLakeIds.length === 0) {
123
- const lakes = Array.isArray(execPlan?.lakes) ? execPlan.lakes : [];
124
- for (const lake of lakes) {
125
- const l = asRecord(lake);
126
- if (l && typeof l.id === "string") priorityLakeIds.push(l.id);
127
- }
128
- }
116
+ const priorityLakeIds = collectPriorityLakeIds(plan);
129
117
 
130
118
  const brief: Record<string, unknown> = {
131
119
  schema_version: REPAIR_BRIEF_SCHEMA,
@@ -143,3 +131,74 @@ export async function synthesizeRepairBrief(
143
131
  }
144
132
  return brief;
145
133
  }
134
+
135
+ function buildSourceArtifacts(
136
+ input: SynthesizeRepairBriefInput,
137
+ planRel: string,
138
+ docs: {
139
+ evalDoc: Record<string, unknown> | null;
140
+ adversary: Record<string, unknown> | null;
141
+ plan: Record<string, unknown> | null;
142
+ sentruxRepair: Record<string, unknown> | null;
143
+ },
144
+ ): Record<string, string> {
145
+ const sourceArtifacts: Record<string, string> = {
146
+ "review-outcome":
147
+ input.reviewOutcomePath ?? "artifacts/review-outcome.yaml",
148
+ };
149
+ if (docs.evalDoc)
150
+ sourceArtifacts["eval-verdict"] =
151
+ input.evalVerdictPath ?? "artifacts/eval-verdict.yaml";
152
+ if (docs.adversary)
153
+ sourceArtifacts["adversary-report"] =
154
+ input.adversaryReportPath ?? "artifacts/adversary-report.yaml";
155
+ if (docs.plan) sourceArtifacts["plan-packet"] = planRel;
156
+ if (docs.sentruxRepair)
157
+ sourceArtifacts["sentrux-repair-plan"] =
158
+ "artifacts/sentrux-repair-plan.yaml";
159
+ return sourceArtifacts;
160
+ }
161
+
162
+ function sentruxFixDirectives(
163
+ sentruxRepair: Record<string, unknown> | null,
164
+ ): string[] {
165
+ if (!sentruxRepair) return [];
166
+ const out: string[] = [];
167
+ const actions = Array.isArray(sentruxRepair.actions)
168
+ ? sentruxRepair.actions
169
+ : [];
170
+ for (const raw of actions) {
171
+ const action = asRecord(raw);
172
+ if (!action) continue;
173
+ const id = typeof action.id === "string" ? action.id : "action";
174
+ const target = typeof action.target === "string" ? action.target : "";
175
+ const instruction =
176
+ typeof action.instruction === "string" ? action.instruction : "";
177
+ if (instruction)
178
+ out.push(`[sentrux:${id}] ${target}: ${instruction}`.trim());
179
+ }
180
+ if (
181
+ typeof sentruxRepair.summary === "string" &&
182
+ sentruxRepair.summary.trim()
183
+ ) {
184
+ out.unshift(`[sentrux] ${sentruxRepair.summary.trim()}`);
185
+ }
186
+ for (const v of stringList(sentruxRepair.verification)) {
187
+ out.push(`[sentrux:verify] ${v}`);
188
+ }
189
+ return out;
190
+ }
191
+
192
+ function collectPriorityLakeIds(
193
+ plan: Record<string, unknown> | null,
194
+ ): string[] {
195
+ const execPlan = asRecord(plan?.execution_plan);
196
+ const ids = stringList(execPlan?.critical_path_lake_ids);
197
+ if (ids.length > 0) return ids;
198
+ const lakes = Array.isArray(execPlan?.lakes) ? execPlan.lakes : [];
199
+ for (const lake of lakes) {
200
+ const record = asRecord(lake);
201
+ if (record && typeof record.id === "string") ids.push(record.id);
202
+ }
203
+ return ids;
204
+ }
@@ -8,8 +8,15 @@
8
8
 
9
9
  import { mkdir, readFile, realpath, writeFile } from "node:fs/promises";
10
10
  import { isAbsolute, join, relative, resolve } from "node:path";
11
+ import {
12
+ isPlanApprovalAskUser,
13
+ PLAN_APPROVE_OPTION,
14
+ PLAN_CANCEL_OPTION,
15
+ } from "./ask-user/policy.js";
11
16
  import { readYamlFile, writeYamlFile } from "./harness-yaml.js";
12
17
 
18
+ export { isPlanApprovalAskUser } from "./ask-user/policy.js";
19
+
13
20
  export type HarnessPhase =
14
21
  | "plan"
15
22
  | "execute"
@@ -171,11 +178,6 @@ export function steerMaxAttemptsFromEnv(): number {
171
178
 
172
179
  const MUTATING_FILE_TOOLS = new Set(["write", "edit"]);
173
180
 
174
- const PLAN_APPROVE_OPTION =
175
- /^(approve(d)?(\s+plan)?|yes,?\s+proceed|looks\s+good)$/i;
176
- const PLAN_CANCEL_OPTION =
177
- /^(cancel(led)?|revise|request\s+changes|needs?\s+clarification)$/i;
178
-
179
181
  export interface PlanUserApproval {
180
182
  plan_id: string | null;
181
183
  approved_at: string;
@@ -702,28 +704,6 @@ export function hasApprovedPlanSignalFromUserPrompt(prompt: string): boolean {
702
704
  return false;
703
705
  }
704
706
 
705
- /** Detect parent-session ask_user calls that duplicate planner plan approval. */
706
- export function isPlanApprovalAskUser(input: {
707
- question?: string;
708
- options?: unknown[];
709
- }): boolean {
710
- const q = String(input.question ?? "").trim();
711
- const opts = Array.isArray(input.options) ? input.options : [];
712
- const titles = opts.map((o) => {
713
- if (typeof o === "string") return o.trim();
714
- if (o && typeof o === "object" && "title" in o) {
715
- return String((o as { title?: string }).title ?? "").trim();
716
- }
717
- return "";
718
- });
719
- const hasPlanOptions =
720
- titles.some(
721
- (t) => PLAN_APPROVE_OPTION.test(t) || PLAN_CANCEL_OPTION.test(t),
722
- ) || PLAN_APPROVE_OPTION.test(q);
723
- if (!hasPlanOptions) return false;
724
- return /plan|approve/i.test(q);
725
- }
726
-
727
707
  export function appendPlanApprovalIfNew(
728
708
  appendEntry: (customType: string, data: unknown) => void,
729
709
  parentEntries: unknown[],
@@ -1667,6 +1647,34 @@ export async function readReviewOutcomeFromRun(
1667
1647
  }
1668
1648
  }
1669
1649
 
1650
+ function nextStepForEvaluateLikePhase(input: {
1651
+ adversaryComplete?: boolean;
1652
+ remediation: string;
1653
+ evalStatus: string;
1654
+ steerAttempt: number;
1655
+ steerMax: number;
1656
+ }): string {
1657
+ if (input.remediation === "pass" || input.evalStatus === "pass") {
1658
+ if (input.adversaryComplete) return "/harness-policy-status";
1659
+ return "/harness-review";
1660
+ }
1661
+ if (input.remediation === "rollback") return "/harness-incident";
1662
+ if (input.remediation === "plan_gap") return "/harness-plan (mode: revise)";
1663
+ if (
1664
+ input.remediation === "implementation_gap" ||
1665
+ (input.remediation === "inconclusive" && input.evalStatus === "fail")
1666
+ ) {
1667
+ if (input.steerAttempt < input.steerMax) return "/harness-steer";
1668
+ return "/harness-plan (mode: revise) or /harness-abort";
1669
+ }
1670
+ if (input.evalStatus === "fail") {
1671
+ if (input.steerAttempt < input.steerMax) return "/harness-steer";
1672
+ return "/harness-plan (mode: revise) or /harness-incident";
1673
+ }
1674
+ if (input.adversaryComplete) return "/harness-policy-status";
1675
+ return "/harness-review";
1676
+ }
1677
+
1670
1678
  export function nextStepAfterOutcome(input: {
1671
1679
  phase: HarnessPhase;
1672
1680
  planStatus?: string | null;
@@ -1730,31 +1738,13 @@ export function nextStepAfterOutcome(input: {
1730
1738
  }
1731
1739
 
1732
1740
  if (input.phase === "evaluate" || input.phase === "adversary") {
1733
- if (remediation === "pass" || evalSt === "pass") {
1734
- if (input.adversaryComplete) return "/harness-policy-status";
1735
- return "/harness-review";
1736
- }
1737
- if (remediation === "rollback") {
1738
- return "/harness-incident";
1739
- }
1740
- if (remediation === "plan_gap") {
1741
- return "/harness-plan (mode: revise)";
1742
- }
1743
- if (
1744
- remediation === "implementation_gap" ||
1745
- (remediation === "inconclusive" && evalSt === "fail")
1746
- ) {
1747
- if (steerAttempt < steerMax) {
1748
- return "/harness-steer";
1749
- }
1750
- return "/harness-plan (mode: revise) or /harness-abort";
1751
- }
1752
- if (evalSt === "fail") {
1753
- if (steerAttempt < steerMax) return "/harness-steer";
1754
- return "/harness-plan (mode: revise) or /harness-incident";
1755
- }
1756
- if (input.adversaryComplete) return "/harness-policy-status";
1757
- return "/harness-review";
1741
+ return nextStepForEvaluateLikePhase({
1742
+ adversaryComplete: input.adversaryComplete,
1743
+ remediation,
1744
+ evalStatus: evalSt,
1745
+ steerAttempt,
1746
+ steerMax,
1747
+ });
1758
1748
  }
1759
1749
 
1760
1750
  if (input.phase === "merge") return "/harness-policy-status";
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Parse sentrux check / gate CLI stdout (and optional upstream JSON).
3
+ */
4
+
5
+ import { createHash } from "node:crypto";
6
+
7
+ export const PARSER_VERSION = "1.0.0";
8
+
9
+ const PROGRESS_LINE =
10
+ /^\[(?:scan|build_project_map|resolve|resolve_imports|build_graphs)\]/;
11
+
12
+ const VIOLATION_HEAD =
13
+ /^([✗⚠])\s+\[(\w+)\]\s+([\w_]+):\s+(.+)$/;
14
+ const QUALITY_LINE = /^Quality:\s*(\d+)\s*$/;
15
+ const RULES_CHECKED = /^sentrux check —\s*(\d+)\s+rules checked/;
16
+ const GATE_QUALITY =
17
+ /^Quality:\s*(\d+)\s*->\s*(\d+)\s*$/;
18
+ const GATE_METRIC = /^(\w[\w\s]*?):\s*(.+?)\s*→\s*(.+?)\s*$/;
19
+ const GATE_DEGRADED_LINE = /^✗\s+DEGRADED\s*$/i;
20
+ const GATE_PASS_LINE = /^✓\s+No degradation detected/i;
21
+ const GATE_VIOLATION = /^\s+✗\s+(.+)$/;
22
+
23
+ /** Strip scan progress noise; keep user-facing lines. */
24
+ export function filterSentruxOutputLines(text) {
25
+ return text
26
+ .split(/\r?\n/)
27
+ .filter((line) => line.trim() && !PROGRESS_LINE.test(line.trim()))
28
+ .filter((line) => !line.startsWith("Scanning "));
29
+ }
30
+
31
+ export function sha256(text) {
32
+ return createHash("sha256").update(text, "utf8").digest("hex");
33
+ }
34
+
35
+ /**
36
+ * @param {string} text — filtered check stdout
37
+ */
38
+ export function parseCheckOutput(text) {
39
+ const lines = filterSentruxOutputLines(text);
40
+ const result = {
41
+ parse_ok: true,
42
+ parse_errors: [],
43
+ rules_checked: null,
44
+ quality_signal: null,
45
+ check_pass: true,
46
+ violations: [],
47
+ };
48
+
49
+ for (const line of lines) {
50
+ const rulesM = line.match(RULES_CHECKED);
51
+ if (rulesM) {
52
+ result.rules_checked = Number.parseInt(rulesM[1], 10);
53
+ continue;
54
+ }
55
+ const qM = line.match(QUALITY_LINE);
56
+ if (qM) {
57
+ result.quality_signal = Number.parseInt(qM[1], 10);
58
+ continue;
59
+ }
60
+ if (line.includes("violation(s) found")) {
61
+ result.check_pass = false;
62
+ }
63
+ if (line.includes("All rules pass")) {
64
+ result.check_pass = true;
65
+ }
66
+ }
67
+
68
+ const rawViolations = [];
69
+ let current = null;
70
+ for (const line of lines) {
71
+ const head = line.match(VIOLATION_HEAD);
72
+ if (head) {
73
+ if (current) rawViolations.push(current);
74
+ current = {
75
+ severity: head[2].toLowerCase(),
76
+ rule: head[3],
77
+ message: head[4].trim(),
78
+ files: [],
79
+ };
80
+ if (head[1] === "✗") result.check_pass = false;
81
+ continue;
82
+ }
83
+ if (current && /^\s{4}\S/.test(line)) {
84
+ current.files.push(line.trim());
85
+ }
86
+ }
87
+ if (current) rawViolations.push(current);
88
+
89
+ result.violations = dedupeViolations(rawViolations);
90
+ return result;
91
+ }
92
+
93
+ const BOUNDARY_PAIR = new Set(["layer_direction", "boundary"]);
94
+
95
+ function filesKey(v) {
96
+ return [...v.files].sort().join("|");
97
+ }
98
+
99
+ function violationKey(v) {
100
+ return `${v.rule}::${filesKey(v)}`;
101
+ }
102
+
103
+ function dedupeViolations(raw) {
104
+ const byKey = new Map();
105
+ for (const v of raw) {
106
+ let key = violationKey(v);
107
+ if (BOUNDARY_PAIR.has(v.rule)) {
108
+ const fk = filesKey(v);
109
+ for (const [k, existing] of byKey) {
110
+ if (
111
+ BOUNDARY_PAIR.has(existing.rule) &&
112
+ filesKey(existing) === fk
113
+ ) {
114
+ key = k;
115
+ break;
116
+ }
117
+ }
118
+ }
119
+ const existing = byKey.get(key);
120
+ if (!existing) {
121
+ byKey.set(key, { ...v, related_rules: [] });
122
+ continue;
123
+ }
124
+ if (existing.rule !== v.rule && !existing.related_rules.includes(v.rule)) {
125
+ existing.related_rules.push(v.rule);
126
+ }
127
+ if (v.severity === "error") existing.severity = "error";
128
+ }
129
+ return [...byKey.values()];
130
+ }
131
+
132
+ /**
133
+ * @param {string} text — filtered gate stdout
134
+ */
135
+ export function parseGateOutput(text) {
136
+ const lines = filterSentruxOutputLines(text);
137
+ const result = {
138
+ parse_ok: true,
139
+ parse_errors: [],
140
+ status: "pass",
141
+ quality_before: null,
142
+ quality_after: null,
143
+ metrics: [],
144
+ degraded_reasons: [],
145
+ };
146
+
147
+ for (const line of lines) {
148
+ const qM = line.match(GATE_QUALITY);
149
+ if (qM) {
150
+ result.quality_before = Number.parseInt(qM[1], 10);
151
+ result.quality_after = Number.parseInt(qM[2], 10);
152
+ continue;
153
+ }
154
+ const mM = line.match(GATE_METRIC);
155
+ if (mM) {
156
+ result.metrics.push({
157
+ name: mM[1].trim().toLowerCase().replace(/\s+/g, "_"),
158
+ before: mM[2].trim(),
159
+ after: mM[3].trim(),
160
+ });
161
+ continue;
162
+ }
163
+ if (GATE_DEGRADED_LINE.test(line)) {
164
+ result.status = "degraded";
165
+ continue;
166
+ }
167
+ if (GATE_PASS_LINE.test(line)) {
168
+ result.status = "pass";
169
+ continue;
170
+ }
171
+ const vM = line.match(GATE_VIOLATION);
172
+ if (vM) {
173
+ result.degraded_reasons.push(vM[1].trim());
174
+ }
175
+ }
176
+
177
+ if (
178
+ result.quality_before != null &&
179
+ result.quality_after != null &&
180
+ result.quality_after < result.quality_before - 200
181
+ ) {
182
+ if (!result.degraded_reasons.some((r) => /quality/i.test(r))) {
183
+ result.degraded_reasons.push(
184
+ `Quality signal dropped: ${result.quality_before} -> ${result.quality_after}`,
185
+ );
186
+ }
187
+ }
188
+
189
+ return result;
190
+ }
191
+
192
+ /** Map violation rules → inferred bottleneck root-cause bucket. */
193
+ export function inferBottleneck(violations, gate) {
194
+ const rules = new Set(violations.map((v) => v.rule));
195
+ if (rules.has("boundary") || rules.has("layer_direction")) {
196
+ return { bottleneck: "modularity", bottleneck_inferred: true };
197
+ }
198
+ if (rules.has("max_cc") || rules.has("max_fn_lines")) {
199
+ return { bottleneck: "equality", bottleneck_inferred: true };
200
+ }
201
+ if (rules.has("max_cycles")) {
202
+ return { bottleneck: "acyclicity", bottleneck_inferred: true };
203
+ }
204
+ if (gate?.degraded_reasons?.some((r) => /coupling/i.test(r))) {
205
+ return { bottleneck: "modularity", bottleneck_inferred: true };
206
+ }
207
+ if (gate?.degraded_reasons?.some((r) => /complex function/i.test(r))) {
208
+ return { bottleneck: "equality", bottleneck_inferred: true };
209
+ }
210
+ return { bottleneck: "modularity", bottleneck_inferred: true };
211
+ }
212
+
213
+ /** Parse `path:func (cc=N)` from max_cc violation file lines. */
214
+ export function parseComplexFunctionEntries(violations) {
215
+ const out = [];
216
+ for (const v of violations) {
217
+ if (v.rule !== "max_cc") continue;
218
+ for (const f of v.files) {
219
+ const m = f.match(/^(.+?):([^(\s]+)\s*\(cc=(\d+)\)\s*$/);
220
+ if (m) {
221
+ out.push({
222
+ file: m[1],
223
+ func: m[2],
224
+ cc: Number.parseInt(m[3], 10),
225
+ });
226
+ }
227
+ }
228
+ }
229
+ return out;
230
+ }
231
+
232
+ /** Parse god file lines from no_god_files violations. */
233
+ export function parseGodFileEntries(violations) {
234
+ const out = [];
235
+ for (const v of violations) {
236
+ if (v.rule !== "no_god_files") continue;
237
+ for (const f of v.files) {
238
+ const m = f.match(/^(.+?)\s*\(fan-out=(\d+)\)/);
239
+ if (m) {
240
+ out.push({ path: m[1], fan_out: Number.parseInt(m[2], 10) });
241
+ } else {
242
+ out.push({ path: f, fan_out: null });
243
+ }
244
+ }
245
+ }
246
+ return out;
247
+ }
248
+
249
+ /**
250
+ * Try upstream `sentrux check --format json` payload (future / optional).
251
+ * @param {unknown} json
252
+ */
253
+ export function normalizeUpstreamCheckJson(json) {
254
+ if (!json || typeof json !== "object") return null;
255
+ const doc = /** @type {Record<string, unknown>} */ (json);
256
+ if (doc.format !== "json" && !doc.violations && !doc.diagnostics) {
257
+ return null;
258
+ }
259
+ return {
260
+ source: "upstream",
261
+ check_pass: doc.check_pass !== false && !(doc.violation_count > 0),
262
+ quality_signal:
263
+ typeof doc.quality_signal === "number" ? doc.quality_signal : null,
264
+ rules_checked:
265
+ typeof doc.rules_checked === "number" ? doc.rules_checked : null,
266
+ violations: Array.isArray(doc.violations) ? doc.violations : [],
267
+ bottleneck: typeof doc.bottleneck === "string" ? doc.bottleneck : null,
268
+ root_causes: doc.root_causes ?? null,
269
+ diagnostics: doc.diagnostics ?? null,
270
+ bottleneck_inferred: false,
271
+ };
272
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Resolve harness project root for Sentrux (.sentrux/rules.toml or architecture manifest).
3
+ */
4
+
5
+ import { access } from "node:fs/promises";
6
+ import { constants } from "node:fs";
7
+ import { dirname, isAbsolute, join, resolve } from "node:path";
8
+
9
+ export const SENTRUX_ROOT_MARKERS = [
10
+ join(".sentrux", "rules.toml"),
11
+ join(".pi", "harness", "sentrux", "architecture.manifest.json"),
12
+ ];
13
+
14
+ export async function fileExists(path) {
15
+ try {
16
+ await access(path, constants.R_OK);
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ export async function hasSentruxRootMarker(dir) {
24
+ for (const marker of SENTRUX_ROOT_MARKERS) {
25
+ if (await fileExists(join(dir, marker))) return true;
26
+ }
27
+ return false;
28
+ }
29
+
30
+ export async function findSentruxProjectRoot(startDir) {
31
+ let dir = resolve(startDir || process.cwd());
32
+ while (true) {
33
+ if (await hasSentruxRootMarker(dir)) return dir;
34
+ const parent = dirname(dir);
35
+ if (parent === dir) return null;
36
+ dir = parent;
37
+ }
38
+ }
39
+
40
+ export function takeRootArg(args) {
41
+ const next = [];
42
+ let explicitRoot = process.env.HARNESS_PROJECT_ROOT || "";
43
+ for (let i = 0; i < args.length; i++) {
44
+ const arg = args[i];
45
+ if (arg === "--root") {
46
+ explicitRoot = args[i + 1] || "";
47
+ i++;
48
+ continue;
49
+ }
50
+ if (arg.startsWith("--root=")) {
51
+ explicitRoot = arg.slice("--root=".length);
52
+ continue;
53
+ }
54
+ next.push(arg);
55
+ }
56
+ return { args: next, explicitRoot };
57
+ }
58
+
59
+ export async function resolveSentruxProjectRoot(explicitRoot) {
60
+ if (explicitRoot) {
61
+ const root = isAbsolute(explicitRoot)
62
+ ? resolve(explicitRoot)
63
+ : resolve(process.cwd(), explicitRoot);
64
+ if (!(await hasSentruxRootMarker(root))) {
65
+ throw new Error(
66
+ `${root} has no .sentrux/rules.toml or .pi/harness/sentrux/architecture.manifest.json`,
67
+ );
68
+ }
69
+ return root;
70
+ }
71
+ const root = await findSentruxProjectRoot(process.cwd());
72
+ if (!root) {
73
+ throw new Error(
74
+ "could not find a harness project root above the current directory",
75
+ );
76
+ }
77
+ return root;
78
+ }