ultimate-pi 0.19.1 → 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 (147) 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 +43 -2
  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 +139 -0
  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 +47 -81
  34. package/.pi/harness/docs/adrs/0051-hash-anchored-executor-edits.md +41 -0
  35. package/.pi/harness/docs/adrs/0052-ls-lint-naming-lifecycle.md +45 -0
  36. package/.pi/harness/docs/adrs/0052-sentrux-structured-repair.md +38 -0
  37. package/.pi/harness/docs/adrs/0053-plan-task-clarification-gate.md +39 -0
  38. package/.pi/harness/docs/adrs/0054-harness-native-ask-user.md +40 -0
  39. package/.pi/harness/docs/adrs/0055-auto-commit-coauthor-lifecycle.md +40 -0
  40. package/.pi/harness/docs/adrs/README.md +7 -0
  41. package/.pi/harness/docs/practice-map.md +21 -5
  42. package/.pi/harness/evals/smoke/ls-lint-stub.json +10 -0
  43. package/.pi/harness/evolution/self-healing-rules.json +16 -0
  44. package/.pi/harness/ls-lint/naming.manifest.json +128 -0
  45. package/.pi/harness/sentrux/architecture.manifest.json +1 -1
  46. package/.pi/harness/specs/auto-commit.schema.json +63 -0
  47. package/.pi/harness/specs/ls-lint-manifest-proposal.schema.json +80 -0
  48. package/.pi/harness/specs/ls-lint-signal.schema.json +47 -0
  49. package/.pi/harness/specs/naming-manifest.schema.json +54 -0
  50. package/.pi/harness/specs/plan-task-clarification.schema.json +88 -0
  51. package/.pi/harness/specs/sentrux-diagnostics.schema.json +173 -0
  52. package/.pi/harness/specs/sentrux-repair-plan.schema.json +133 -0
  53. package/.pi/harness/specs/sentrux-report.schema.json +119 -0
  54. package/.pi/harness/specs/sentrux-signal.schema.json +34 -1
  55. package/.pi/lib/agents-policy.d.mts +26 -47
  56. package/.pi/lib/agents-policy.mjs +84 -29
  57. package/.pi/lib/agents-policy.ts +1 -0
  58. package/.pi/lib/agt/build-evaluation-context.ts +136 -64
  59. package/.pi/lib/ask-user/constants.mjs +3 -0
  60. package/.pi/lib/ask-user/constants.ts +4 -0
  61. package/.pi/lib/ask-user/contracts/glimpse-parse.ts +56 -0
  62. package/.pi/lib/ask-user/contracts/glimpse-payload-build.ts +58 -0
  63. package/.pi/lib/ask-user/contracts/glimpse-payload.ts +38 -0
  64. package/.pi/lib/ask-user/core/questionnaire.ts +74 -0
  65. package/.pi/lib/ask-user/dialog.ts +2 -314
  66. package/.pi/lib/ask-user/fallback.ts +2 -78
  67. package/.pi/lib/ask-user/format.ts +85 -0
  68. package/.pi/lib/ask-user/glimpseui.d.ts +10 -0
  69. package/.pi/lib/ask-user/index.ts +114 -0
  70. package/.pi/lib/ask-user/merge-task-clarification.ts +98 -0
  71. package/.pi/lib/ask-user/policy.mjs +43 -0
  72. package/.pi/lib/ask-user/policy.ts +104 -0
  73. package/.pi/lib/ask-user/presenters/glimpse.ts +130 -0
  74. package/.pi/lib/ask-user/presenters/headless.ts +131 -0
  75. package/.pi/lib/ask-user/presenters/select.ts +60 -0
  76. package/.pi/lib/ask-user/presenters/tui.ts +373 -0
  77. package/.pi/lib/ask-user/presenters/types.ts +13 -0
  78. package/.pi/lib/ask-user/render.ts +40 -9
  79. package/.pi/lib/ask-user/schema.ts +66 -13
  80. package/.pi/lib/ask-user/types.ts +60 -3
  81. package/.pi/lib/ask-user/validate-core.mjs +193 -7
  82. package/.pi/lib/ask-user/validate.ts +53 -34
  83. package/.pi/lib/harness-anchored-edit/.hash_anchors +1721 -0
  84. package/.pi/lib/harness-anchored-edit/anchor-state.ts +320 -0
  85. package/.pi/lib/harness-anchored-edit/apply-anchored-edits.ts +161 -0
  86. package/.pi/lib/harness-anchored-edit/edit-executor.ts +146 -0
  87. package/.pi/lib/harness-anchored-edit/index.ts +9 -0
  88. package/.pi/lib/harness-anchored-edit/line-protocol.ts +38 -0
  89. package/.pi/lib/harness-anchored-edit/package.json +3 -0
  90. package/.pi/lib/harness-anchored-edit/settings.ts +1 -0
  91. package/.pi/lib/harness-anchored-edit/task-id.ts +8 -0
  92. package/.pi/lib/harness-anchored-edit/types.ts +19 -0
  93. package/.pi/lib/harness-artifact-gate.ts +75 -21
  94. package/.pi/lib/harness-auto-commit-config.mjs +321 -0
  95. package/.pi/lib/harness-lens/clients/anchored-edit-autopatch.ts +158 -0
  96. package/.pi/lib/harness-lens/clients/lsp/client.ts +62 -39
  97. package/.pi/lib/harness-lens/clients/tool-policy.ts +73 -181
  98. package/.pi/lib/harness-lens/index.ts +246 -96
  99. package/.pi/lib/harness-lens/tools/lsp-navigation.ts +10 -8
  100. package/.pi/lib/harness-repair-brief.ts +84 -25
  101. package/.pi/lib/harness-run-context.ts +42 -52
  102. package/.pi/lib/harness-sentrux-parse.mjs +272 -0
  103. package/.pi/lib/harness-sentrux-root.mjs +78 -0
  104. package/.pi/lib/harness-slash-completions.ts +116 -0
  105. package/.pi/lib/harness-spawn-topology.ts +121 -87
  106. package/.pi/lib/harness-subagent-submit-registry.ts +10 -0
  107. package/.pi/lib/harness-subagents-bridge.ts +11 -6
  108. package/.pi/lib/harness-ui-state.ts +95 -48
  109. package/.pi/lib/plan-approval/dialog.ts +5 -0
  110. package/.pi/lib/plan-approval/validate.ts +1 -1
  111. package/.pi/lib/plan-approval-readiness.ts +32 -0
  112. package/.pi/lib/plan-debate-gate.ts +154 -114
  113. package/.pi/lib/plan-task-clarification.ts +158 -0
  114. package/.pi/prompts/harness-auto.md +2 -2
  115. package/.pi/prompts/harness-ls-lint-steward.md +43 -0
  116. package/.pi/prompts/harness-plan.md +58 -8
  117. package/.pi/prompts/harness-review.md +40 -6
  118. package/.pi/prompts/harness-run.md +33 -11
  119. package/.pi/prompts/harness-setup.md +72 -3
  120. package/.pi/prompts/harness-steer.md +3 -2
  121. package/.pi/prompts/wiki-save.md +5 -4
  122. package/.pi/scripts/README.md +8 -0
  123. package/.pi/scripts/generate-agents-policy-yaml.mjs +14 -2
  124. package/.pi/scripts/harness-anchored-edit-smoke.mjs +45 -0
  125. package/.pi/scripts/harness-auto-commit-bootstrap.mjs +96 -0
  126. package/.pi/scripts/harness-cli-verify.sh +47 -0
  127. package/.pi/scripts/harness-git-churn.mjs +77 -0
  128. package/.pi/scripts/harness-git-commit.mjs +173 -0
  129. package/.pi/scripts/harness-ls-lint-bootstrap.mjs +142 -0
  130. package/.pi/scripts/harness-ls-lint-cli.mjs +184 -0
  131. package/.pi/scripts/harness-seed-project-contracts.mjs +47 -0
  132. package/.pi/scripts/harness-sentrux-diagnostics.mjs +230 -0
  133. package/.pi/scripts/harness-sentrux-report.mjs +256 -0
  134. package/.pi/scripts/harness-verify.mjs +347 -117
  135. package/.pi/scripts/ls-lint-rules-sync.mjs +265 -0
  136. package/.pi/scripts/run-tests.mjs +65 -0
  137. package/.pi/settings.example.json +1 -0
  138. package/.sentrux/rules.toml +1 -1
  139. package/AGENTS.md +1 -0
  140. package/CHANGELOG.md +31 -0
  141. package/README.md +13 -4
  142. package/THIRD_PARTY_NOTICES.md +7 -0
  143. package/package.json +8 -3
  144. package/vendor/pi-subagents/src/agents.ts +5 -0
  145. package/vendor/pi-subagents/src/subagents.ts +22 -3
  146. package/vendor/pi-vcc/src/hooks/before-compact.ts +86 -60
  147. package/.pi/scripts/release.sh +0 -338
@@ -0,0 +1,19 @@
1
+ export type AnchoredEditType = "replace" | "insert_after" | "insert_before";
2
+
3
+ export interface AnchoredEdit {
4
+ anchor: string;
5
+ end_anchor?: string;
6
+ edit_type?: AnchoredEditType;
7
+ text: string;
8
+ }
9
+
10
+ export interface ResolvedAnchoredEdit {
11
+ lineIdx: number;
12
+ endIdx: number;
13
+ edit: AnchoredEdit;
14
+ }
15
+
16
+ export interface FailedAnchoredEdit {
17
+ edit: AnchoredEdit;
18
+ error: string;
19
+ }
@@ -7,6 +7,10 @@ import { access, readFile, stat } from "node:fs/promises";
7
7
  import { join } from "node:path";
8
8
  import { parse as parseYaml } from "yaml";
9
9
  import { validateAgainstHarnessSchema } from "./harness-schema-validate.js";
10
+ import {
11
+ TASK_CLARIFICATION_ARTIFACT,
12
+ validateTaskClarificationDoc,
13
+ } from "./plan-task-clarification.js";
10
14
 
11
15
  export interface ArtifactGateResult {
12
16
  ok: boolean;
@@ -19,12 +23,15 @@ const ARTIFACT_SCHEMA: Record<string, string> = {
19
23
  "artifacts/implementation-research.yaml":
20
24
  "plan-implementation-research-brief.schema.json",
21
25
  "artifacts/stack.yaml": "plan-stack-brief.schema.json",
26
+ "artifacts/task-clarification.yaml": "plan-task-clarification.schema.json",
22
27
  "artifacts/planning-context.yaml": "plan-planning-context.schema.json",
23
28
  "artifacts/eval-verdict.yaml": "eval-verdict.schema.json",
24
29
  "artifacts/adversary-report.yaml": "adversary-report.schema.json",
30
+ "artifacts/sentrux-repair-plan.yaml": "sentrux-repair-plan.schema.json",
25
31
  };
26
32
 
27
33
  const PREREQUISITE_ORDER: Record<string, string[]> = {
34
+ "artifacts/planning-context.yaml": [TASK_CLARIFICATION_ARTIFACT],
28
35
  "artifacts/hypothesis.yaml": ["artifacts/decomposition.yaml"],
29
36
  "artifacts/implementation-research.yaml": [
30
37
  "artifacts/decomposition.yaml",
@@ -53,6 +60,63 @@ function artifactStatusBad(doc: Record<string, unknown>): string | null {
53
60
  return null;
54
61
  }
55
62
 
63
+ async function validatePlanningContextArtifact(
64
+ normalized: string,
65
+ doc: Record<string, unknown>,
66
+ ): Promise<string[]> {
67
+ const errors: string[] = [];
68
+ if (normalized !== "artifacts/planning-context.yaml") return errors;
69
+ const statusErr = artifactStatusBad(doc);
70
+ if (statusErr) errors.push(`${normalized}: ${statusErr}`);
71
+ const coverage = doc.coverage as Record<string, unknown> | undefined;
72
+ if (!coverage || typeof coverage !== "object") return errors;
73
+ for (const lane of ["architecture", "structure"] as const) {
74
+ const laneDoc = coverage[lane] as Record<string, unknown> | undefined;
75
+ const laneStatus = String(laneDoc?.status ?? "").toLowerCase();
76
+ if (laneStatus !== "ok" && laneStatus !== "partial") {
77
+ errors.push(
78
+ `${normalized}: coverage.${lane}.status must be ok or partial (got "${laneStatus || "missing"}")`,
79
+ );
80
+ }
81
+ }
82
+ return errors;
83
+ }
84
+
85
+ async function validateArtifactPrerequisites(
86
+ runRoot: string,
87
+ normalized: string,
88
+ prereqs: string[],
89
+ ): Promise<string[]> {
90
+ const errors: string[] = [];
91
+ for (const prereq of prereqs) {
92
+ const prereqPath = join(runRoot, prereq);
93
+ if (!(await fileExists(prereqPath))) {
94
+ errors.push(`${normalized}: prerequisite missing (${prereq})`);
95
+ continue;
96
+ }
97
+ if (prereq !== TASK_CLARIFICATION_ARTIFACT) continue;
98
+ try {
99
+ const raw = await readFile(prereqPath, "utf-8");
100
+ const prereqDoc = parseYaml(raw) as Record<string, unknown>;
101
+ const clar = validateTaskClarificationDoc(prereqDoc, {
102
+ requireReady: true,
103
+ });
104
+ if (!clar.ok) {
105
+ errors.push(
106
+ ...clar.errors.map(
107
+ (e) => `${normalized}: prerequisite ${prereq} — ${e}`,
108
+ ),
109
+ );
110
+ }
111
+ } catch {
112
+ errors.push(
113
+ `${normalized}: prerequisite ${prereq} invalid or unreadable`,
114
+ );
115
+ }
116
+ }
117
+ return errors;
118
+ }
119
+
56
120
  export async function validateHarnessArtifactFile(
57
121
  runRoot: string,
58
122
  relPath: string,
@@ -102,32 +166,22 @@ export async function validateHarnessArtifactFile(
102
166
  }
103
167
  }
104
168
 
105
- if (doc && normalized === "artifacts/planning-context.yaml") {
106
- const statusErr = artifactStatusBad(doc);
107
- if (statusErr) {
108
- errors.push(`${normalized}: ${statusErr}`);
109
- }
110
- const coverage = doc.coverage as Record<string, unknown> | undefined;
111
- if (coverage && typeof coverage === "object") {
112
- for (const lane of ["architecture", "structure"] as const) {
113
- const laneDoc = coverage[lane] as Record<string, unknown> | undefined;
114
- const laneStatus = String(laneDoc?.status ?? "").toLowerCase();
115
- if (laneStatus !== "ok" && laneStatus !== "partial") {
116
- errors.push(
117
- `${normalized}: coverage.${lane}.status must be ok or partial (got "${laneStatus || "missing"}")`,
118
- );
119
- }
120
- }
169
+ if (doc && normalized === TASK_CLARIFICATION_ARTIFACT) {
170
+ const clar = validateTaskClarificationDoc(doc, { requireReady: true });
171
+ if (!clar.ok) {
172
+ errors.push(...clar.errors.map((e) => `${normalized}: ${e}`));
121
173
  }
122
174
  }
123
175
 
124
- const prereqs = PREREQUISITE_ORDER[normalized] ?? [];
125
- for (const prereq of prereqs) {
126
- if (!(await fileExists(join(runRoot, prereq)))) {
127
- errors.push(`${normalized}: prerequisite missing (${prereq})`);
128
- }
176
+ if (doc) {
177
+ errors.push(...(await validatePlanningContextArtifact(normalized, doc)));
129
178
  }
130
179
 
180
+ const prereqs = PREREQUISITE_ORDER[normalized] ?? [];
181
+ errors.push(
182
+ ...(await validateArtifactPrerequisites(runRoot, normalized, prereqs)),
183
+ );
184
+
131
185
  return { ok: errors.length === 0, errors };
132
186
  }
133
187
 
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Load and merge .pi/auto-commit.json (project overrides package).
3
+ * Format commit subjects and append Co-authored-by trailers.
4
+ */
5
+
6
+ import { readFile, access } from "node:fs/promises";
7
+ import { constants } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ const TEMPLATE_PLACEHOLDERS = new Set(["type", "scope", "subject", "login", "email"]);
11
+
12
+ const DEFAULT_CO_AUTHOR = {
13
+ login: "pi-mono",
14
+ email: "261679550+pi-mono@users.noreply.github.com",
15
+ required: true,
16
+ };
17
+
18
+ const DEFAULT_MESSAGE = {
19
+ template: "{type}({scope}): {subject}",
20
+ templateNoScope: "{type}: {subject}",
21
+ typeDefault: "chore",
22
+ scopeDefault: "harness",
23
+ bodySeparator: "\n\n",
24
+ coAuthorTrailer: "Co-authored-by: {login} <{email}>",
25
+ maxSubjectLength: 72,
26
+ };
27
+
28
+ /** @param {unknown} value */
29
+ function isPlainObject(value) {
30
+ return typeof value === "object" && value !== null && !Array.isArray(value);
31
+ }
32
+
33
+ /**
34
+ * Deep-merge objects; arrays and scalars from override replace base.
35
+ * @param {Record<string, unknown>} base
36
+ * @param {Record<string, unknown>} override
37
+ */
38
+ export function deepMerge(base, override) {
39
+ const out = { ...base };
40
+ for (const [key, val] of Object.entries(override)) {
41
+ if (
42
+ isPlainObject(val) &&
43
+ isPlainObject(out[key]) &&
44
+ !Array.isArray(val)
45
+ ) {
46
+ out[key] = deepMerge(
47
+ /** @type {Record<string, unknown>} */ (out[key]),
48
+ /** @type {Record<string, unknown>} */ (val),
49
+ );
50
+ } else {
51
+ out[key] = val;
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+
57
+ async function readJsonIfExists(path) {
58
+ try {
59
+ await access(path, constants.R_OK);
60
+ } catch {
61
+ return null;
62
+ }
63
+ const raw = await readFile(path, "utf-8");
64
+ return JSON.parse(raw);
65
+ }
66
+
67
+ /**
68
+ * @param {string} template
69
+ */
70
+ export function assertValidTemplate(template) {
71
+ const re = /\{([a-zA-Z_]+)\}/g;
72
+ let m;
73
+ while ((m = re.exec(template)) !== null) {
74
+ if (!TEMPLATE_PLACEHOLDERS.has(m[1])) {
75
+ throw new Error(
76
+ `auto-commit: unknown placeholder {${m[1]}} in template (allowed: ${[...TEMPLATE_PLACEHOLDERS].join(", ")})`,
77
+ );
78
+ }
79
+ }
80
+ }
81
+
82
+ /**
83
+ * @param {Record<string, unknown>} config
84
+ */
85
+ export function validateAutoCommitConfig(config) {
86
+ const coAuthor = /** @type {Record<string, unknown>} */ (
87
+ config.coAuthor ?? {}
88
+ );
89
+ const login = coAuthor.login;
90
+ const email = coAuthor.email;
91
+ if (typeof login !== "string" || !login.trim()) {
92
+ throw new Error("auto-commit: coAuthor.login is required");
93
+ }
94
+ if (typeof email !== "string" || !email.trim() || !email.includes("@")) {
95
+ throw new Error("auto-commit: coAuthor.email must be a valid email");
96
+ }
97
+
98
+ const message = /** @type {Record<string, unknown>} */ (
99
+ config.message ?? {}
100
+ );
101
+ const template = message.template;
102
+ if (typeof template !== "string" || !template.trim()) {
103
+ throw new Error("auto-commit: message.template is required");
104
+ }
105
+ assertValidTemplate(template);
106
+ const templateNoScope = message.templateNoScope;
107
+ if (templateNoScope != null) {
108
+ if (typeof templateNoScope !== "string" || !templateNoScope.trim()) {
109
+ throw new Error("auto-commit: message.templateNoScope must be non-empty");
110
+ }
111
+ assertValidTemplate(templateNoScope);
112
+ }
113
+ const trailer = message.coAuthorTrailer;
114
+ if (typeof trailer !== "string" || !trailer.trim()) {
115
+ throw new Error("auto-commit: message.coAuthorTrailer is required");
116
+ }
117
+ assertValidTemplate(trailer);
118
+ }
119
+
120
+ /**
121
+ * @param {string} projectRoot
122
+ * @param {string} upPkg
123
+ */
124
+ export async function resolveAutoCommitConfig(projectRoot, upPkg) {
125
+ const pkgPath = join(upPkg, ".pi", "auto-commit.json");
126
+ const projectPath = join(projectRoot, ".pi", "auto-commit.json");
127
+
128
+ const pkgRaw = (await readJsonIfExists(pkgPath)) ?? {};
129
+ const projectRaw = (await readJsonIfExists(projectPath)) ?? {};
130
+
131
+ const base = {
132
+ dryRun: false,
133
+ coAuthor: { ...DEFAULT_CO_AUTHOR },
134
+ message: { ...DEFAULT_MESSAGE },
135
+ ...(isPlainObject(pkgRaw) ? pkgRaw : {}),
136
+ };
137
+ const merged = deepMerge(
138
+ /** @type {Record<string, unknown>} */ (base),
139
+ /** @type {Record<string, unknown>} */ (
140
+ isPlainObject(projectRaw) ? projectRaw : {}
141
+ ),
142
+ );
143
+
144
+ if (!isPlainObject(merged.message)) {
145
+ merged.message = { ...DEFAULT_MESSAGE };
146
+ } else {
147
+ merged.message = { ...DEFAULT_MESSAGE, ...merged.message };
148
+ }
149
+ if (!isPlainObject(merged.coAuthor)) {
150
+ merged.coAuthor = { ...DEFAULT_CO_AUTHOR };
151
+ } else {
152
+ merged.coAuthor = { ...DEFAULT_CO_AUTHOR, ...merged.coAuthor };
153
+ }
154
+
155
+ validateAutoCommitConfig(merged);
156
+ return merged;
157
+ }
158
+
159
+ /**
160
+ * @param {string} template
161
+ * @param {Record<string, string>} vars
162
+ */
163
+ function applyTemplate(template, vars) {
164
+ return template.replace(/\{([a-zA-Z_]+)\}/g, (_, key) => vars[key] ?? "");
165
+ }
166
+
167
+ /**
168
+ * @param {Record<string, unknown>} config
169
+ * @param {{ type?: string, scope?: string, subject: string, body?: string }} input
170
+ */
171
+ export function formatCommitMessage(config, input) {
172
+ const message = /** @type {Record<string, unknown>} */ (config.message);
173
+ const type =
174
+ (input.type ?? message.typeDefault ?? "chore").toString().trim() ||
175
+ "chore";
176
+ let scope = (input.scope ?? message.scopeDefault ?? "").toString().trim();
177
+ const subject = input.subject.trim();
178
+ if (!subject) {
179
+ throw new Error("auto-commit: subject is required");
180
+ }
181
+
182
+ const maxLen =
183
+ typeof message.maxSubjectLength === "number"
184
+ ? message.maxSubjectLength
185
+ : 72;
186
+ let subjectLine = subject.split(/\r?\n/)[0] ?? subject;
187
+ if (subjectLine.length > maxLen) {
188
+ subjectLine = `${subjectLine.slice(0, maxLen - 3)}...`;
189
+ }
190
+
191
+ const template =
192
+ scope.length > 0
193
+ ? String(message.template)
194
+ : String(message.templateNoScope ?? message.template);
195
+ const subjectFormatted = applyTemplate(template, {
196
+ type,
197
+ scope,
198
+ subject: subjectLine,
199
+ });
200
+
201
+ const body = (input.body ?? "").trim();
202
+ const bodySep = String(message.bodySeparator ?? "\n\n");
203
+ if (!body) {
204
+ return subjectFormatted;
205
+ }
206
+ return `${subjectFormatted}${bodySep}${body}`;
207
+ }
208
+
209
+ /**
210
+ * Strip trailing co-authored-by lines from commit message body.
211
+ * @param {string} message
212
+ */
213
+ export function stripCoAuthorTrailers(message) {
214
+ const lines = message.replace(/\r\n/g, "\n").split("\n");
215
+ while (lines.length > 0) {
216
+ const last = lines[lines.length - 1]?.trim() ?? "";
217
+ if (!last) {
218
+ lines.pop();
219
+ continue;
220
+ }
221
+ if (/^co-authored-by:/i.test(last)) {
222
+ lines.pop();
223
+ continue;
224
+ }
225
+ break;
226
+ }
227
+ return lines.join("\n").trimEnd();
228
+ }
229
+
230
+ /**
231
+ * @param {Record<string, unknown>} coAuthor
232
+ * @param {string} trailerTemplate
233
+ */
234
+ export function renderCoAuthorTrailer(coAuthor, trailerTemplate) {
235
+ return applyTemplate(trailerTemplate, {
236
+ login: String(coAuthor.login).trim(),
237
+ email: String(coAuthor.email).trim(),
238
+ type: "",
239
+ scope: "",
240
+ subject: "",
241
+ });
242
+ }
243
+
244
+ /**
245
+ * @param {string} message
246
+ * @param {Record<string, unknown>} coAuthor
247
+ * @param {string} trailerTemplate
248
+ */
249
+ export function messageHasCoAuthorTrailer(message, coAuthor, trailerTemplate) {
250
+ const expected = renderCoAuthorTrailer(coAuthor, trailerTemplate)
251
+ .trim()
252
+ .toLowerCase();
253
+ const normalized = message.replace(/\r\n/g, "\n").toLowerCase();
254
+ return normalized.includes(expected);
255
+ }
256
+
257
+ /**
258
+ * @param {string} message
259
+ * @param {Record<string, unknown>} config
260
+ */
261
+ export function appendCoAuthorTrailer(message, config) {
262
+ const coAuthor = /** @type {Record<string, unknown>} */ (config.coAuthor);
263
+ const messageCfg = /** @type {Record<string, unknown>} */ (config.message);
264
+ const trailerTemplate = String(
265
+ messageCfg.coAuthorTrailer ?? DEFAULT_MESSAGE.coAuthorTrailer,
266
+ );
267
+
268
+ if (coAuthor.required === false) {
269
+ return message;
270
+ }
271
+
272
+ const stripped = stripCoAuthorTrailers(message);
273
+ if (messageHasCoAuthorTrailer(stripped, coAuthor, trailerTemplate)) {
274
+ return stripped;
275
+ }
276
+
277
+ const trailer = renderCoAuthorTrailer(coAuthor, trailerTemplate);
278
+ if (!stripped) {
279
+ return trailer;
280
+ }
281
+ return `${stripped}\n\n${trailer}`;
282
+ }
283
+
284
+ /**
285
+ * Build final commit message (subject/body + trailer).
286
+ * @param {Record<string, unknown>} config
287
+ * @param {{ type?: string, scope?: string, subject?: string, body?: string, message?: string }} input
288
+ */
289
+ export function buildFullCommitMessage(config, input) {
290
+ let core;
291
+ if (input.message != null && String(input.message).trim()) {
292
+ core = String(input.message).trim();
293
+ } else if (input.subject != null && String(input.subject).trim()) {
294
+ core = formatCommitMessage(config, {
295
+ type: input.type,
296
+ scope: input.scope,
297
+ subject: String(input.subject),
298
+ body: input.body,
299
+ });
300
+ } else {
301
+ throw new Error(
302
+ "auto-commit: provide --message or --subject for commit text",
303
+ );
304
+ }
305
+ return appendCoAuthorTrailer(core, config);
306
+ }
307
+
308
+ /** @param {string} message */
309
+ export function splitSubjectAndBody(message) {
310
+ const normalized = message.replace(/\r\n/g, "\n");
311
+ const idx = normalized.indexOf("\n\n");
312
+ if (idx === -1) {
313
+ return { subject: normalized.trim(), body: "" };
314
+ }
315
+ return {
316
+ subject: normalized.slice(0, idx).trim(),
317
+ body: normalized.slice(idx + 2).trim(),
318
+ };
319
+ }
320
+
321
+ export { DEFAULT_CO_AUTHOR, DEFAULT_MESSAGE, TEMPLATE_PLACEHOLDERS };
@@ -0,0 +1,158 @@
1
+ import * as nodeFs from "node:fs";
2
+ import { AnchorStateManager } from "../../harness-anchored-edit/anchor-state.js";
3
+ import { EditExecutor } from "../../harness-anchored-edit/edit-executor.js";
4
+ import { splitAnchor } from "../../harness-anchored-edit/line-protocol.js";
5
+ import type { AnchoredEdit } from "../../harness-anchored-edit/types.js";
6
+ import { tryCorrectIndentationMismatchFromContent } from "./edit-autopatch.js";
7
+ import { retargetReplacementIndentation } from "./indent-retarget.js";
8
+
9
+ function leadingIndent(line: string): string {
10
+ return line.match(/^[\t ]*/)?.[0] ?? "";
11
+ }
12
+
13
+ function isIndentationOnlyChange(before: string, after: string): boolean {
14
+ const beforeLines = before.replace(/\r\n/g, "\n").split("\n");
15
+ const afterLines = after.replace(/\r\n/g, "\n").split("\n");
16
+ if (beforeLines.length !== afterLines.length) return false;
17
+ return beforeLines.every(
18
+ (line, index) => line.trim() === afterLines[index].trim(),
19
+ );
20
+ }
21
+
22
+ type AnchoredEditInput = {
23
+ edits?: AnchoredEdit[];
24
+ };
25
+
26
+ export function isAnchoredEditToolInput(
27
+ editInput: unknown,
28
+ ): editInput is AnchoredEditInput {
29
+ if (!editInput || typeof editInput !== "object") return false;
30
+ const edits = (editInput as AnchoredEditInput).edits;
31
+ if (!Array.isArray(edits) || edits.length === 0) return false;
32
+ return typeof edits[0]?.anchor === "string";
33
+ }
34
+
35
+ /**
36
+ * Indentation-only correction for harness anchored edit.text before apply.
37
+ */
38
+ export function applyAnchoredEditAutopatch(
39
+ filePath: string,
40
+ editInput: AnchoredEditInput,
41
+ taskId: string,
42
+ ): { block: true; reason: string } | undefined {
43
+ const edits = editInput.edits;
44
+ if (!edits?.length) return undefined;
45
+
46
+ let crlfContent: string;
47
+ try {
48
+ crlfContent = nodeFs.readFileSync(filePath, "utf-8").replace(/\r\n/g, "\n");
49
+ } catch {
50
+ return undefined;
51
+ }
52
+
53
+ const lines = crlfContent.split("\n");
54
+ const lineAnchors = AnchorStateManager.reconcile(filePath, lines, taskId);
55
+ const executor = new EditExecutor();
56
+ const { resolvedEdits, failedEdits } = executor.resolveEdits(
57
+ edits,
58
+ lines,
59
+ lineAnchors,
60
+ );
61
+ if (failedEdits.length > 0) return undefined;
62
+
63
+ const corrected: Array<{
64
+ label: string;
65
+ original: string;
66
+ corrected: string;
67
+ indentationOnly: boolean;
68
+ apply: (value: string) => void;
69
+ }> = [];
70
+
71
+ for (const { lineIdx, endIdx, edit } of resolvedEdits) {
72
+ const editType = edit.edit_type ?? "replace";
73
+ const text = edit.text ?? "";
74
+ if (!text.trim()) continue;
75
+
76
+ let referenceBlock: string;
77
+ if (editType === "replace") {
78
+ referenceBlock = lines.slice(lineIdx, endIdx + 1).join("\n");
79
+ } else {
80
+ referenceBlock = lines[lineIdx] ?? "";
81
+ }
82
+
83
+ const correctedText = tryCorrectIndentationMismatchFromContent(
84
+ text,
85
+ crlfContent,
86
+ );
87
+ if (correctedText === undefined) {
88
+ const refIndent = leadingIndent(referenceBlock.split("\n")[0] ?? "");
89
+ const textIndent = leadingIndent(text.split("\n")[0] ?? "");
90
+ if (
91
+ refIndent !== textIndent &&
92
+ isIndentationOnlyChange(
93
+ textIndent + text.trimStart(),
94
+ refIndent + text.trimStart(),
95
+ )
96
+ ) {
97
+ const retargeted = retargetReplacementIndentation(
98
+ text,
99
+ textIndent + text.trimStart(),
100
+ refIndent + text.trimStart(),
101
+ );
102
+ if (retargeted !== undefined) {
103
+ corrected.push({
104
+ label: `edits anchor ${splitAnchor(edit.anchor).anchor}`,
105
+ original: text,
106
+ corrected: retargeted,
107
+ indentationOnly: true,
108
+ apply: (value) => {
109
+ edit.text = value;
110
+ },
111
+ });
112
+ }
113
+ }
114
+ continue;
115
+ }
116
+
117
+ if (correctedText !== text) {
118
+ const retargeted = retargetReplacementIndentation(
119
+ text,
120
+ text,
121
+ correctedText,
122
+ );
123
+ corrected.push({
124
+ label: `edits anchor ${splitAnchor(edit.anchor).anchor}`,
125
+ original: text,
126
+ corrected: retargeted ?? correctedText,
127
+ indentationOnly: isIndentationOnlyChange(text, correctedText),
128
+ apply: (value) => {
129
+ edit.text = value;
130
+ },
131
+ });
132
+ }
133
+ }
134
+
135
+ if (corrected.length === 0) return undefined;
136
+
137
+ const unsafe = corrected.filter((entry) => !entry.indentationOnly);
138
+ if (unsafe.length > 0) {
139
+ const details = unsafe
140
+ .map(({ label, original }) => {
141
+ const preview = original.trimStart().slice(0, 60).replace(/\n/g, "↵");
142
+ return `${label} ("${preview}…") cannot be auto-patched (not indentation-only).`;
143
+ })
144
+ .join("\n");
145
+ return {
146
+ block: true,
147
+ reason:
148
+ `🔄 RETRYABLE — Indentation mismatch on anchored edit text\n\n` +
149
+ `${details}\n\n` +
150
+ `Next action: re-read the relevant section, then retry with text matching file indentation.`,
151
+ };
152
+ }
153
+
154
+ for (const entry of corrected) {
155
+ entry.apply(entry.corrected);
156
+ }
157
+ return undefined;
158
+ }