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.
- package/.agents/skills/harness-decisions/SKILL.md +68 -2
- package/.agents/skills/harness-git-commit/SKILL.md +72 -0
- package/.agents/skills/harness-governor/SKILL.md +2 -2
- package/.agents/skills/harness-ls-lint-setup/SKILL.md +59 -0
- package/.agents/skills/harness-plan/SKILL.md +13 -11
- package/.agents/skills/harness-review/SKILL.md +1 -1
- package/.agents/skills/harness-sentrux-repair/SKILL.md +48 -0
- package/.agents/skills/sentrux/SKILL.md +4 -2
- package/.agents/skills/wiki-save/SKILL.md +1 -1
- package/.pi/PACKAGING.md +6 -0
- package/.pi/SYSTEM.md +21 -3
- package/.pi/agents/harness/ls-lint-steward.md +49 -0
- package/.pi/agents/harness/planning/decompose.md +4 -4
- package/.pi/agents/harness/reviewing/evaluator.md +1 -1
- package/.pi/agents/harness/running/executor.md +43 -2
- package/.pi/agents/harness/sentrux-repair-advisor.md +50 -0
- package/.pi/agents/pi-pi/prompt-expert.md +17 -2
- package/.pi/auto-commit.json +9 -2
- package/.pi/extensions/debate-orchestrator.ts +3 -0
- package/.pi/extensions/harness-anchored-edit.ts +139 -0
- package/.pi/extensions/harness-ask-user.ts +13 -34
- package/.pi/extensions/harness-debate-tools.ts +43 -4
- package/.pi/extensions/harness-live-widget.ts +28 -19
- package/.pi/extensions/harness-run-context.ts +278 -115
- package/.pi/extensions/harness-web-tools.ts +598 -471
- package/.pi/extensions/ls-lint-rules-sync.ts +103 -0
- package/.pi/extensions/observation-bus.ts +4 -0
- package/.pi/extensions/policy-gate.ts +270 -229
- package/.pi/extensions/sentrux-rules-sync.ts +2 -0
- package/.pi/extensions/soundboard.ts +48 -48
- package/.pi/harness/README.md +4 -0
- package/.pi/harness/agents.manifest.json +15 -7
- package/.pi/harness/agents.policy.yaml +47 -81
- package/.pi/harness/docs/adrs/0051-hash-anchored-executor-edits.md +41 -0
- package/.pi/harness/docs/adrs/0052-ls-lint-naming-lifecycle.md +45 -0
- package/.pi/harness/docs/adrs/0052-sentrux-structured-repair.md +38 -0
- package/.pi/harness/docs/adrs/0053-plan-task-clarification-gate.md +39 -0
- package/.pi/harness/docs/adrs/0054-harness-native-ask-user.md +40 -0
- package/.pi/harness/docs/adrs/0055-auto-commit-coauthor-lifecycle.md +40 -0
- package/.pi/harness/docs/adrs/README.md +7 -0
- package/.pi/harness/docs/practice-map.md +21 -5
- package/.pi/harness/evals/smoke/ls-lint-stub.json +10 -0
- package/.pi/harness/evolution/self-healing-rules.json +16 -0
- package/.pi/harness/ls-lint/naming.manifest.json +128 -0
- package/.pi/harness/sentrux/architecture.manifest.json +1 -1
- package/.pi/harness/specs/auto-commit.schema.json +63 -0
- package/.pi/harness/specs/ls-lint-manifest-proposal.schema.json +80 -0
- package/.pi/harness/specs/ls-lint-signal.schema.json +47 -0
- package/.pi/harness/specs/naming-manifest.schema.json +54 -0
- package/.pi/harness/specs/plan-task-clarification.schema.json +88 -0
- package/.pi/harness/specs/sentrux-diagnostics.schema.json +173 -0
- package/.pi/harness/specs/sentrux-repair-plan.schema.json +133 -0
- package/.pi/harness/specs/sentrux-report.schema.json +119 -0
- package/.pi/harness/specs/sentrux-signal.schema.json +34 -1
- package/.pi/lib/agents-policy.d.mts +26 -47
- package/.pi/lib/agents-policy.mjs +84 -29
- package/.pi/lib/agents-policy.ts +1 -0
- package/.pi/lib/agt/build-evaluation-context.ts +136 -64
- package/.pi/lib/ask-user/constants.mjs +3 -0
- package/.pi/lib/ask-user/constants.ts +4 -0
- package/.pi/lib/ask-user/contracts/glimpse-parse.ts +56 -0
- package/.pi/lib/ask-user/contracts/glimpse-payload-build.ts +58 -0
- package/.pi/lib/ask-user/contracts/glimpse-payload.ts +38 -0
- package/.pi/lib/ask-user/core/questionnaire.ts +74 -0
- package/.pi/lib/ask-user/dialog.ts +2 -314
- package/.pi/lib/ask-user/fallback.ts +2 -78
- package/.pi/lib/ask-user/format.ts +85 -0
- package/.pi/lib/ask-user/glimpseui.d.ts +10 -0
- package/.pi/lib/ask-user/index.ts +114 -0
- package/.pi/lib/ask-user/merge-task-clarification.ts +98 -0
- package/.pi/lib/ask-user/policy.mjs +43 -0
- package/.pi/lib/ask-user/policy.ts +104 -0
- package/.pi/lib/ask-user/presenters/glimpse.ts +130 -0
- package/.pi/lib/ask-user/presenters/headless.ts +131 -0
- package/.pi/lib/ask-user/presenters/select.ts +60 -0
- package/.pi/lib/ask-user/presenters/tui.ts +373 -0
- package/.pi/lib/ask-user/presenters/types.ts +13 -0
- package/.pi/lib/ask-user/render.ts +40 -9
- package/.pi/lib/ask-user/schema.ts +66 -13
- package/.pi/lib/ask-user/types.ts +60 -3
- package/.pi/lib/ask-user/validate-core.mjs +193 -7
- package/.pi/lib/ask-user/validate.ts +53 -34
- package/.pi/lib/harness-anchored-edit/.hash_anchors +1721 -0
- package/.pi/lib/harness-anchored-edit/anchor-state.ts +320 -0
- package/.pi/lib/harness-anchored-edit/apply-anchored-edits.ts +161 -0
- package/.pi/lib/harness-anchored-edit/edit-executor.ts +146 -0
- package/.pi/lib/harness-anchored-edit/index.ts +9 -0
- package/.pi/lib/harness-anchored-edit/line-protocol.ts +38 -0
- package/.pi/lib/harness-anchored-edit/package.json +3 -0
- package/.pi/lib/harness-anchored-edit/settings.ts +1 -0
- package/.pi/lib/harness-anchored-edit/task-id.ts +8 -0
- package/.pi/lib/harness-anchored-edit/types.ts +19 -0
- package/.pi/lib/harness-artifact-gate.ts +75 -21
- package/.pi/lib/harness-auto-commit-config.mjs +321 -0
- package/.pi/lib/harness-lens/clients/anchored-edit-autopatch.ts +158 -0
- package/.pi/lib/harness-lens/clients/lsp/client.ts +62 -39
- package/.pi/lib/harness-lens/clients/tool-policy.ts +73 -181
- package/.pi/lib/harness-lens/index.ts +246 -96
- package/.pi/lib/harness-lens/tools/lsp-navigation.ts +10 -8
- package/.pi/lib/harness-repair-brief.ts +84 -25
- package/.pi/lib/harness-run-context.ts +42 -52
- package/.pi/lib/harness-sentrux-parse.mjs +272 -0
- package/.pi/lib/harness-sentrux-root.mjs +78 -0
- package/.pi/lib/harness-slash-completions.ts +116 -0
- package/.pi/lib/harness-spawn-topology.ts +121 -87
- package/.pi/lib/harness-subagent-submit-registry.ts +10 -0
- package/.pi/lib/harness-subagents-bridge.ts +11 -6
- package/.pi/lib/harness-ui-state.ts +95 -48
- package/.pi/lib/plan-approval/dialog.ts +5 -0
- package/.pi/lib/plan-approval/validate.ts +1 -1
- package/.pi/lib/plan-approval-readiness.ts +32 -0
- package/.pi/lib/plan-debate-gate.ts +154 -114
- package/.pi/lib/plan-task-clarification.ts +158 -0
- package/.pi/prompts/harness-auto.md +2 -2
- package/.pi/prompts/harness-ls-lint-steward.md +43 -0
- package/.pi/prompts/harness-plan.md +58 -8
- package/.pi/prompts/harness-review.md +40 -6
- package/.pi/prompts/harness-run.md +33 -11
- package/.pi/prompts/harness-setup.md +72 -3
- package/.pi/prompts/harness-steer.md +3 -2
- package/.pi/prompts/wiki-save.md +5 -4
- package/.pi/scripts/README.md +8 -0
- package/.pi/scripts/generate-agents-policy-yaml.mjs +14 -2
- package/.pi/scripts/harness-anchored-edit-smoke.mjs +45 -0
- package/.pi/scripts/harness-auto-commit-bootstrap.mjs +96 -0
- package/.pi/scripts/harness-cli-verify.sh +47 -0
- package/.pi/scripts/harness-git-churn.mjs +77 -0
- package/.pi/scripts/harness-git-commit.mjs +173 -0
- package/.pi/scripts/harness-ls-lint-bootstrap.mjs +142 -0
- package/.pi/scripts/harness-ls-lint-cli.mjs +184 -0
- package/.pi/scripts/harness-seed-project-contracts.mjs +47 -0
- package/.pi/scripts/harness-sentrux-diagnostics.mjs +230 -0
- package/.pi/scripts/harness-sentrux-report.mjs +256 -0
- package/.pi/scripts/harness-verify.mjs +347 -117
- package/.pi/scripts/ls-lint-rules-sync.mjs +265 -0
- package/.pi/scripts/run-tests.mjs +65 -0
- package/.pi/settings.example.json +1 -0
- package/.sentrux/rules.toml +1 -1
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +31 -0
- package/README.md +13 -4
- package/THIRD_PARTY_NOTICES.md +7 -0
- package/package.json +8 -3
- package/vendor/pi-subagents/src/agents.ts +5 -0
- package/vendor/pi-subagents/src/subagents.ts +22 -3
- package/vendor/pi-vcc/src/hooks/before-compact.ts +86 -60
- 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 ===
|
|
106
|
-
const
|
|
107
|
-
if (
|
|
108
|
-
errors.push(`${normalized}: ${
|
|
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
|
-
|
|
125
|
-
|
|
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
|
+
}
|