@evoclock/pi-agentic-driver 0.4.3
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/LICENSE +736 -0
- package/PROVENANCE.md +69 -0
- package/README.md +325 -0
- package/config/herdr-worker-repositories.v1.json +9 -0
- package/extensions/aidr.ts +5 -0
- package/extensions/code-phage.js +144 -0
- package/extensions/herdr-communication.ts +7 -0
- package/extensions/herdr-lifecycle.ts +7 -0
- package/extensions/linux-microvm.ts +10 -0
- package/lib/adapters/diff-scope.mjs +148 -0
- package/lib/adapters/evidence.mjs +151 -0
- package/lib/adapters/narrative.mjs +171 -0
- package/lib/adapters/review-feedback.mjs +77 -0
- package/lib/adapters/visualization.mjs +176 -0
- package/lib/code-phage-core.mjs +882 -0
- package/lib/python_ast_metrics.py +378 -0
- package/lib/typescript_ast_metrics.mjs +441 -0
- package/package.json +50 -0
- package/scripts/aidr_writing_review.js +468 -0
- package/scripts/enforcement/herdr_communication_pi.js +1198 -0
- package/scripts/enforcement/herdr_lifecycle_pi.js +902 -0
- package/scripts/enforcement/linux_microvm_cutover_pi.js +328 -0
- package/scripts/enforcement/linux_microvm_remote_fixture.sh +366 -0
- package/scripts/enforcement/native_tui_context.js +11 -0
- package/templates/AGENTS.md +72 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
// diff-scope adapter — changed-range extraction for code-phage.
|
|
5
|
+
//
|
|
6
|
+
// Concept provenance: pi-simplify v0.2.3 (MIT, MattDevy/pi-extensions,
|
|
7
|
+
// commit 8fcf9b1) changed-file and changed-line extraction, adapted and
|
|
8
|
+
// extended: explicit added/deleted/renamed classification, both-side line
|
|
9
|
+
// ranges from `git diff --unified=0`, bounded output, and no prompt-based
|
|
10
|
+
// enforcement. This adapter performs read-only Git queries only; it never
|
|
11
|
+
// mutates the repository or stages anything.
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
|
|
14
|
+
const MAX_DIFF_BYTES = 2_000_000;
|
|
15
|
+
const MAX_FILES = 512;
|
|
16
|
+
const MAX_HUNKS_PER_FILE = 512;
|
|
17
|
+
|
|
18
|
+
const GIT_ENV = { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" };
|
|
19
|
+
|
|
20
|
+
function git(root, args) {
|
|
21
|
+
const result = spawnSync("git", ["-C", root, ...args], {
|
|
22
|
+
encoding: "utf8",
|
|
23
|
+
maxBuffer: MAX_DIFF_BYTES,
|
|
24
|
+
env: GIT_ENV,
|
|
25
|
+
});
|
|
26
|
+
if (result.error || result.status !== 0) {
|
|
27
|
+
return { ok: false, stdout: "", stderr: result.stderr || String(result.error || "git failed") };
|
|
28
|
+
}
|
|
29
|
+
return { ok: true, stdout: result.stdout, stderr: result.stderr };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseNameStatus(stdout) {
|
|
33
|
+
// --name-status -M output: "XY\tpath" or "XY\told -> new".
|
|
34
|
+
const files = [];
|
|
35
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
36
|
+
if (!line) continue;
|
|
37
|
+
const parts = line.split("\t");
|
|
38
|
+
if (parts.length < 2) continue;
|
|
39
|
+
const status = parts[0];
|
|
40
|
+
const raw = parts.length > 2 ? parts.at(-1) : parts[1];
|
|
41
|
+
const oldPath = parts.length > 2 ? parts[1] : undefined;
|
|
42
|
+
files.push({
|
|
43
|
+
status: status.startsWith("R") ? "renamed" : status,
|
|
44
|
+
path: raw,
|
|
45
|
+
oldPath,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return files;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseRange(spec) {
|
|
52
|
+
// "start,count" | "start" ; treat "0,0" as absent (no lines on that side).
|
|
53
|
+
if (!spec) return null;
|
|
54
|
+
const [startText, countText] = spec.split(",");
|
|
55
|
+
const start = Number(startText);
|
|
56
|
+
const count = countText === undefined ? 1 : Number(countText);
|
|
57
|
+
if (!Number.isInteger(start) || !Number.isInteger(count) || count <= 0) return null;
|
|
58
|
+
return { start, end: start + count - 1 };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseHunks(stdout) {
|
|
62
|
+
// Map path -> { added: [ranges], removed: [ranges] } from -U0 output.
|
|
63
|
+
// Each hunk contributes its range once, not once per changed line.
|
|
64
|
+
const byFile = new Map();
|
|
65
|
+
let currentPath = undefined;
|
|
66
|
+
let current = undefined;
|
|
67
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
68
|
+
if (line.startsWith("diff --git ")) {
|
|
69
|
+
const match = line.match(/^diff --git (?:"?a\/(.+?)) (?:"?b\/(.+?)"?)$/);
|
|
70
|
+
currentPath = match ? match[2] : undefined;
|
|
71
|
+
current = undefined;
|
|
72
|
+
if (currentPath && !byFile.has(currentPath)) byFile.set(currentPath, { added: [], removed: [] });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const hunk = line.match(/^@@ -(\d+(?:,\d+)?) \+(\d+(?:,\d+)?) @@/);
|
|
76
|
+
if (hunk) {
|
|
77
|
+
if (!currentPath) continue;
|
|
78
|
+
const entry = byFile.get(currentPath);
|
|
79
|
+
if (!entry) continue;
|
|
80
|
+
const added = parseRange(hunk[2]);
|
|
81
|
+
const removed = parseRange(hunk[1]);
|
|
82
|
+
if (added && !entry.added.some((r) => r.start === added.start && r.end === added.end)) entry.added.push(added);
|
|
83
|
+
if (removed && !entry.removed.some((r) => r.start === removed.start && r.end === removed.end)) entry.removed.push(removed);
|
|
84
|
+
current = { added, removed };
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!current || !currentPath) continue;
|
|
88
|
+
}
|
|
89
|
+
return byFile;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function capRanges(ranges, limitations, path, side) {
|
|
93
|
+
if (ranges.length > MAX_HUNKS_PER_FILE) {
|
|
94
|
+
limitations.push(`truncated ${side} ranges for ${path} at ${MAX_HUNKS_PER_FILE}`);
|
|
95
|
+
return ranges.slice(0, MAX_HUNKS_PER_FILE);
|
|
96
|
+
}
|
|
97
|
+
return ranges;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function changedRanges(root, ref = "HEAD") {
|
|
101
|
+
const limitations = [];
|
|
102
|
+
const names = git(root, ["diff", "--name-status", "-M", ref]);
|
|
103
|
+
if (!names.ok) {
|
|
104
|
+
return { status: "git-unavailable", files: [], limitations: ["git diff --name-status was unavailable; no changed-file scope was inferred."], ref };
|
|
105
|
+
}
|
|
106
|
+
// `git diff` never reports untracked files; discover them separately.
|
|
107
|
+
const untracked = git(root, ["ls-files", "--others", "--exclude-standard"]);
|
|
108
|
+
const rawPaths = parseNameStatus(names.stdout).map((file) => ({ ...file, untracked: false }));
|
|
109
|
+
let untrackedPaths = [];
|
|
110
|
+
if (untracked.ok) {
|
|
111
|
+
untrackedPaths = untracked.stdout.split(/\r?\n/).filter(Boolean).map((path) => ({ status: "A", path, untracked: true }));
|
|
112
|
+
} else {
|
|
113
|
+
limitations.push("untracked-file discovery failed; untracked additions may be missing from the changed scope.");
|
|
114
|
+
}
|
|
115
|
+
const files = [...rawPaths, ...untrackedPaths];
|
|
116
|
+
const totalFiles = files.length;
|
|
117
|
+
const boundedFiles = files.slice(0, MAX_FILES);
|
|
118
|
+
if (totalFiles > MAX_FILES) limitations.push(`truncated changed files at ${MAX_FILES}; ${totalFiles - MAX_FILES} file(s) are outside the bounded scope.`);
|
|
119
|
+
const hunks = git(root, ["diff", "--unified=0", ref]);
|
|
120
|
+
if (!hunks.ok) limitations.push("hunk-range query failed; added/removed line ranges may be incomplete for modified files.");
|
|
121
|
+
const rangeMap = hunks.ok ? parseHunks(hunks.stdout) : new Map();
|
|
122
|
+
const result = [];
|
|
123
|
+
for (const file of boundedFiles) {
|
|
124
|
+
const ranges = rangeMap.get(file.path) || { added: [], removed: [] };
|
|
125
|
+
if (file.status === "A") {
|
|
126
|
+
result.push({ ...file, classification: "added-file", added: [], removed: [], wholeCurrentFile: true });
|
|
127
|
+
} else if (file.status === "D") {
|
|
128
|
+
result.push({ ...file, classification: "deleted-file", added: [], removed: [], wholeCurrentFile: false, deleted: true });
|
|
129
|
+
} else if (file.status === "renamed") {
|
|
130
|
+
result.push({
|
|
131
|
+
...file,
|
|
132
|
+
classification: "renamed",
|
|
133
|
+
added: capRanges(ranges.added, limitations, file.path, "added"),
|
|
134
|
+
removed: capRanges(ranges.removed, limitations, file.path, "removed"),
|
|
135
|
+
wholeCurrentFile: false,
|
|
136
|
+
});
|
|
137
|
+
} else {
|
|
138
|
+
result.push({
|
|
139
|
+
...file,
|
|
140
|
+
classification: "modified",
|
|
141
|
+
added: capRanges(ranges.added, limitations, file.path, "added"),
|
|
142
|
+
removed: capRanges(ranges.removed, limitations, file.path, "removed"),
|
|
143
|
+
wholeCurrentFile: false,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { status: "observed", files: result, limitations, ref };
|
|
148
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
// evidence adapter — content-bound source anchors for code-phage.
|
|
5
|
+
//
|
|
6
|
+
// Concept provenance: review-craft v0.7.1 (MIT, bigKING67/review-craft,
|
|
7
|
+
// commit cfd74b9) content-bound source/span SHA-256 anchors, the
|
|
8
|
+
// candidate → finding → decision separation, and advisory complexity
|
|
9
|
+
// ceilings with human-owned decisions. Adapted, not copied: this adapter
|
|
10
|
+
// is a pure function over in-memory source text; it creates no ledger,
|
|
11
|
+
// no files, and no authority. Agentic Driver remains the canonical
|
|
12
|
+
// evidence owner.
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
|
|
15
|
+
// Advisory review ceilings per callable. They are diagnostic signals for
|
|
16
|
+
// human review, never automatic rejection thresholds.
|
|
17
|
+
export const ADVISORY_CEILINGS = Object.freeze({
|
|
18
|
+
cognitiveComplexity: 15,
|
|
19
|
+
cyclomaticComplexity: 10,
|
|
20
|
+
maxNesting: 4,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function sha256(value) {
|
|
24
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sourceLines(source) {
|
|
28
|
+
return source.split(/\r?\n/);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Build one content-bound anchor for a callable span. The span text is the
|
|
32
|
+
// exact raw source lines [line, endLine]; the digest changes whenever the
|
|
33
|
+
// span content changes.
|
|
34
|
+
export function spanAnchor(source, callable) {
|
|
35
|
+
const lines = sourceLines(source);
|
|
36
|
+
const start = Math.max(1, Number(callable.line) || 1);
|
|
37
|
+
const end = Math.min(lines.length, Math.max(start, Number(callable.endLine) || start));
|
|
38
|
+
const spanText = lines.slice(start - 1, end).join("\n");
|
|
39
|
+
return {
|
|
40
|
+
line: start,
|
|
41
|
+
endLine: end,
|
|
42
|
+
lineCount: end - start + 1,
|
|
43
|
+
spanSha256: sha256(spanText),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Build the full evidence record for one analyzed file: a source-level
|
|
48
|
+
// digest plus per-callable span anchors. Deleted files have no current
|
|
49
|
+
// source side; their evidence stays path-level only.
|
|
50
|
+
export function buildEvidence(source, analysis) {
|
|
51
|
+
if (typeof source !== "string" || !analysis || analysis.status !== "parsed") {
|
|
52
|
+
return { status: "unavailable", sourceSha256: undefined, anchors: [] };
|
|
53
|
+
}
|
|
54
|
+
const byLine = new Map(
|
|
55
|
+
(analysis.callables || []).map((callable) => [Number(callable.line) || 0, callable]),
|
|
56
|
+
);
|
|
57
|
+
const anchors = [];
|
|
58
|
+
const lines = sourceLines(source);
|
|
59
|
+
// Walk callables in source order; nested callables share parent spans.
|
|
60
|
+
const ordered = [...(analysis.callables || [])].sort((a, b) => (Number(a.line) || 0) - (Number(b.line) || 0));
|
|
61
|
+
const covered = [];
|
|
62
|
+
for (const callable of ordered) {
|
|
63
|
+
const anchor = spanAnchor(source, callable);
|
|
64
|
+
covered.push({ start: anchor.line, end: anchor.endLine, name: callable.name });
|
|
65
|
+
anchors.push({
|
|
66
|
+
name: callable.name,
|
|
67
|
+
kind: callable.kind,
|
|
68
|
+
...anchor,
|
|
69
|
+
cyclomaticComplexity: callable.cyclomaticComplexity,
|
|
70
|
+
cognitiveComplexity: callable.cognitiveComplexity,
|
|
71
|
+
overCeiling: {
|
|
72
|
+
cognitiveComplexity: (Number(callable.cognitiveComplexity) || 0) > ADVISORY_CEILINGS.cognitiveComplexity,
|
|
73
|
+
cyclomaticComplexity: (Number(callable.cyclomaticComplexity) || 0) > ADVISORY_CEILINGS.cyclomaticComplexity,
|
|
74
|
+
maxNesting: (Number(callable.maxNesting) || 0) > ADVISORY_CEILINGS.maxNesting,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
status: "anchored",
|
|
80
|
+
sourceSha256: sha256(source),
|
|
81
|
+
lineCount: lines.length,
|
|
82
|
+
anchors,
|
|
83
|
+
ceilings: ADVISORY_CEILINGS,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Map a changeClassification onto an advisory candidate outcome.
|
|
88
|
+
// This is a heuristic signal for human review; the decision owner is
|
|
89
|
+
// always the human reviewer, and no outcome is applied automatically.
|
|
90
|
+
// Outcome terms are this project's own vocabulary, adapted from the
|
|
91
|
+
// review-craft evidence model (see README provenance).
|
|
92
|
+
function suggestedOutcome(classification, anchor) {
|
|
93
|
+
if (anchor.overCeiling.cognitiveComplexity || anchor.overCeiling.cyclomaticComplexity) {
|
|
94
|
+
return {
|
|
95
|
+
outcome: "PROFILE",
|
|
96
|
+
rationale: "callable exceeds an advisory complexity ceiling; measure against the acceptance checks before deciding",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (anchor.overCeiling.maxNesting) {
|
|
100
|
+
return {
|
|
101
|
+
outcome: "TIDY",
|
|
102
|
+
rationale: "nesting exceeds the advisory ceiling; consider flattening without behavior change",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (classification === "removed-lines") {
|
|
106
|
+
return {
|
|
107
|
+
outcome: "HOLD",
|
|
108
|
+
rationale: "only removed lines touch this callable; confirm surrendered behavior before further change",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (classification === "whole-file") {
|
|
112
|
+
return {
|
|
113
|
+
outcome: "DESCRIBE",
|
|
114
|
+
rationale: "new file-level unit; document intent and the deletion test before review closes",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
outcome: "RETAIN",
|
|
119
|
+
rationale: "changed lines stay within advisory limits; confirm the deletion test still holds",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Candidate findings for one changed file. Candidates are not decisions:
|
|
124
|
+
// review-craft's separation is preserved, and rejected candidates remain
|
|
125
|
+
// visible to the human reviewer rather than disappearing.
|
|
126
|
+
export function buildCandidates(path, changedFile, evidence) {
|
|
127
|
+
if (!evidence || evidence.status !== "anchored" || !changedFile) return [];
|
|
128
|
+
const classificationFor = (name) => {
|
|
129
|
+
if (changedFile.wholeCurrentFile) return "whole-file";
|
|
130
|
+
const touched = changedFile.touched;
|
|
131
|
+
if (!Array.isArray(touched)) return undefined;
|
|
132
|
+
const record = touched.find((item) => item.name === name);
|
|
133
|
+
return record?.changeClassification;
|
|
134
|
+
};
|
|
135
|
+
return evidence.anchors.map((anchor) => {
|
|
136
|
+
const classification = classificationFor(anchor.name) || "untouched";
|
|
137
|
+
const suggestion = suggestedOutcome(classification, anchor);
|
|
138
|
+
return {
|
|
139
|
+
path,
|
|
140
|
+
callable: anchor.name,
|
|
141
|
+
line: anchor.line,
|
|
142
|
+
endLine: anchor.endLine,
|
|
143
|
+
spanSha256: anchor.spanSha256,
|
|
144
|
+
changeClassification: classification,
|
|
145
|
+
suggestedOutcome: suggestion.outcome,
|
|
146
|
+
rationale: suggestion.rationale,
|
|
147
|
+
decisionOwner: "human",
|
|
148
|
+
advisoryOnly: true,
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
// narrative adapter — structured, concern-grouped review narrative.
|
|
5
|
+
//
|
|
6
|
+
// Concept provenance: semantic-review v0.3.0 (MIT, mikker/semantic-review,
|
|
7
|
+
// commit f77e5c8) groups review guidance as a short narrative with
|
|
8
|
+
// schema-validated structured output, stable hunk references, and levels
|
|
9
|
+
// from summary to full walkthrough. Adapted here without any model
|
|
10
|
+
// backend or network: the narrative is derived deterministically from
|
|
11
|
+
// code-phage results, grouped by concern rather than file order, and
|
|
12
|
+
// every reference resolves to a review-feedback item or a content-bound
|
|
13
|
+
// candidate span. The narrative is an isolated advisory artifact; it
|
|
14
|
+
// never triggers edits, submissions, or remote calls.
|
|
15
|
+
export const NARRATIVE_SCHEMA = "agentic-driver.code-phage-narrative.v1";
|
|
16
|
+
|
|
17
|
+
const SECTION_TITLES = {
|
|
18
|
+
amend: "EDIT POINTS",
|
|
19
|
+
consult: "OPEN QUESTIONS",
|
|
20
|
+
removed: "SURRENDERED BEHAVIOR",
|
|
21
|
+
ceilings: "MEASUREMENT SIGNALS",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Narrative summary wording uses this project's outcome vocabulary;
|
|
25
|
+
// see the evidence adapter provenance in README.md.
|
|
26
|
+
|
|
27
|
+
const VALID_DIGEST = /^[0-9a-f]{64}$/;
|
|
28
|
+
|
|
29
|
+
function candidateRef(candidate) {
|
|
30
|
+
if (!candidate || typeof candidate.spanSha256 !== "string" || !VALID_DIGEST.test(candidate.spanSha256)) return null;
|
|
31
|
+
return `CAND:${candidate.path}#${candidate.callable}#${candidate.spanSha256.slice(0, 12)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildSections(result) {
|
|
35
|
+
const sections = [];
|
|
36
|
+
const reviewFeedback = Array.isArray(result.reviewFeedback) ? result.reviewFeedback : [];
|
|
37
|
+
const amendItems = reviewFeedback.filter((item) => item.intent === "AMEND");
|
|
38
|
+
if (amendItems.length) {
|
|
39
|
+
sections.push({
|
|
40
|
+
id: "amend",
|
|
41
|
+
title: SECTION_TITLES.amend,
|
|
42
|
+
items: amendItems.map((item) => ({
|
|
43
|
+
ref: `RF:${item.id}`,
|
|
44
|
+
path: item.path,
|
|
45
|
+
callable: item.callable,
|
|
46
|
+
lineStart: item.lineStart,
|
|
47
|
+
lineEnd: item.lineEnd,
|
|
48
|
+
message: item.message,
|
|
49
|
+
})),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const consultItems = reviewFeedback.filter((item) => item.intent === "CONSULT");
|
|
53
|
+
if (consultItems.length) {
|
|
54
|
+
sections.push({
|
|
55
|
+
id: "consult",
|
|
56
|
+
title: SECTION_TITLES.consult,
|
|
57
|
+
items: consultItems.map((item) => ({
|
|
58
|
+
ref: `RF:${item.id}`,
|
|
59
|
+
path: item.path,
|
|
60
|
+
callable: item.callable,
|
|
61
|
+
lineStart: item.lineStart,
|
|
62
|
+
lineEnd: item.lineEnd,
|
|
63
|
+
message: item.message,
|
|
64
|
+
})),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const deletedPaths = result.changedScope?.deletedPaths || [];
|
|
68
|
+
if (deletedPaths.length) {
|
|
69
|
+
sections.push({
|
|
70
|
+
id: "removed",
|
|
71
|
+
title: SECTION_TITLES.removed,
|
|
72
|
+
items: deletedPaths.map((path) => ({
|
|
73
|
+
ref: `DEL:${path}`,
|
|
74
|
+
path,
|
|
75
|
+
callable: undefined,
|
|
76
|
+
message: `Confirm the consumer map and surrendered behavior for the deleted unit ${path} before review closes.`,
|
|
77
|
+
})),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const overCeiling = [];
|
|
81
|
+
for (const entry of result.changedCallables || []) {
|
|
82
|
+
for (const anchor of entry.evidence?.anchors || []) {
|
|
83
|
+
if (!anchor || typeof anchor.spanSha256 !== "string" || !VALID_DIGEST.test(anchor.spanSha256)) continue;
|
|
84
|
+
if (Object.values(anchor.overCeiling || {}).some(Boolean)) {
|
|
85
|
+
overCeiling.push({
|
|
86
|
+
ref: `CAND:${entry.path}#${anchor.name}#${anchor.spanSha256.slice(0, 12)}`,
|
|
87
|
+
path: entry.path,
|
|
88
|
+
callable: anchor.name,
|
|
89
|
+
lineStart: anchor.line,
|
|
90
|
+
lineEnd: anchor.endLine,
|
|
91
|
+
message: `${anchor.name} exceeds an advisory ceiling; measure against the acceptance checks.`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (overCeiling.length) {
|
|
97
|
+
sections.push({
|
|
98
|
+
id: "ceilings",
|
|
99
|
+
title: SECTION_TITLES.ceilings,
|
|
100
|
+
items: overCeiling,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return sections;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Validate one narrative value against the schema. Fails closed: any
|
|
107
|
+
// missing field, unknown level, or unresolvable reference returns errors
|
|
108
|
+
// instead of a pass.
|
|
109
|
+
export function validateNarrative(narrative) {
|
|
110
|
+
const errors = [];
|
|
111
|
+
if (!narrative || typeof narrative !== "object") return { valid: false, errors: ["narrative must be an object"] };
|
|
112
|
+
if (narrative.schema !== NARRATIVE_SCHEMA) errors.push("schema mismatch");
|
|
113
|
+
if (!["summary", "walkthrough"].includes(narrative.level)) errors.push("level must be summary or walkthrough");
|
|
114
|
+
if (typeof narrative.summary !== "string" || !narrative.summary.trim()) errors.push("summary must be non-empty text");
|
|
115
|
+
if (!Array.isArray(narrative.sections)) errors.push("sections must be an array");
|
|
116
|
+
const VALID_DIGEST = /^[0-9a-f]{64}$/;
|
|
117
|
+
const knownRefs = new Set(Object.keys(narrative.referenceIndex || {}));
|
|
118
|
+
for (const [ref, digest] of Object.entries(narrative.referenceIndex || {})) {
|
|
119
|
+
if (digest !== null && (typeof digest !== "string" || !VALID_DIGEST.test(digest))) {
|
|
120
|
+
errors.push(`reference ${ref} is not bound to a valid span digest`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const section of narrative.sections || []) {
|
|
124
|
+
if (!section.id || !section.title || !Array.isArray(section.items)) {
|
|
125
|
+
errors.push(`section ${section.id || "<missing-id>"} is malformed`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
for (const item of section.items) {
|
|
129
|
+
if (!item.ref || !item.message) errors.push(`item in ${section.id} lacks ref or message`);
|
|
130
|
+
if (!knownRefs.has(item.ref)) errors.push(`unresolvable reference ${item.ref}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { valid: errors.length === 0, errors };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Build the narrative plus its reference index. The reference index maps
|
|
137
|
+
// every stable ref to its span digest so narrative guidance stays
|
|
138
|
+
// content-bound across session restarts and compaction.
|
|
139
|
+
export function buildNarrative(result, level = "walkthrough") {
|
|
140
|
+
if (!result || result.schema !== "agentic-driver.code-phage.v1") {
|
|
141
|
+
return { schema: NARRATIVE_SCHEMA, level, valid: false, errors: ["input is not a code-phage result"], sections: [], referenceIndex: {} };
|
|
142
|
+
}
|
|
143
|
+
const sections = buildSections(result);
|
|
144
|
+
const referenceIndex = {};
|
|
145
|
+
for (const item of result.reviewFeedback || []) referenceIndex[`RF:${item.id}`] = item.spanSha256;
|
|
146
|
+
for (const candidate of result.candidates || []) {
|
|
147
|
+
const ref = candidateRef(candidate);
|
|
148
|
+
if (ref) referenceIndex[ref] = candidate.spanSha256;
|
|
149
|
+
}
|
|
150
|
+
for (const path of result.changedScope?.deletedPaths || []) referenceIndex[`DEL:${path}`] = null;
|
|
151
|
+
const counts = result.summary?.reviewFeedback || {};
|
|
152
|
+
const summary = [
|
|
153
|
+
`${result.changedScope?.changedFiles ?? 0} changed file(s) reviewed;`,
|
|
154
|
+
`${result.changedScope?.touchedCallables ?? 0} callable(s) touched;`,
|
|
155
|
+
`${counts.AMEND || 0} AMEND point(s), ${counts.CONSULT || 0} CONSULT question(s);`,
|
|
156
|
+
`${result.summary?.deletedFiles ?? 0} deleted unit(s).`,
|
|
157
|
+
"All findings are advisory; decisions remain with the human reviewer.",
|
|
158
|
+
].join(" ");
|
|
159
|
+
const narrative = {
|
|
160
|
+
schema: NARRATIVE_SCHEMA,
|
|
161
|
+
level,
|
|
162
|
+
isolated: true,
|
|
163
|
+
modelBackend: "none",
|
|
164
|
+
networkAccess: false,
|
|
165
|
+
summary,
|
|
166
|
+
sections,
|
|
167
|
+
referenceIndex,
|
|
168
|
+
};
|
|
169
|
+
const validation = validateNarrative(narrative);
|
|
170
|
+
return { ...narrative, valid: validation.valid, errors: validation.errors };
|
|
171
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
// review-feedback adapter — two-intent review items for code-phage.
|
|
5
|
+
//
|
|
6
|
+
// Concept provenance: pi-slopchop v0.10.1 (MIT, robzolkos/pi-slopchop,
|
|
7
|
+
// commit f2cae88) separates edit-intent comments from prose-intent
|
|
8
|
+
// comments so reviewers never conflate "change this" with "explain
|
|
9
|
+
// this". Adapted here under different terms: AMEND marks a concrete
|
|
10
|
+
// edit point a human may choose to act on; CONSULT marks a question
|
|
11
|
+
// that must be answered in prose and never becomes a code change.
|
|
12
|
+
// Items are advisory, content-bound, and rendered for human submission;
|
|
13
|
+
// no intent is ever applied autonomously and no Pi editor or TUI is
|
|
14
|
+
// touched.
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
|
|
17
|
+
export const REVIEW_INTENT_VOCABULARY = Object.freeze(["AMEND", "CONSULT"]);
|
|
18
|
+
|
|
19
|
+
// Candidate outcomes that point at a concrete edit are AMEND material;
|
|
20
|
+
// everything else is a prose question. Outcome terms use this project's
|
|
21
|
+
// own vocabulary (see evidence adapter provenance).
|
|
22
|
+
const AMEND_OUTCOMES = new Set(["TIDY", "PROFILE"]);
|
|
23
|
+
const CONSULT_OUTCOMES = new Set(["RETAIN", "HOLD", "DESCRIBE"]);
|
|
24
|
+
|
|
25
|
+
function shortId(path, callable, spanSha256) {
|
|
26
|
+
return createHash("sha256").update(`${path}#${callable}#${spanSha256}`, "utf8").digest("hex").slice(0, 12);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function amendMessage(candidate) {
|
|
30
|
+
if (candidate.suggestedOutcome === "PROFILE") {
|
|
31
|
+
return `Measure ${candidate.callable} against the acceptance checks; it exceeds an advisory complexity ceiling.`;
|
|
32
|
+
}
|
|
33
|
+
return `Consider flattening ${candidate.callable}; nesting exceeds the advisory ceiling without behavior change.`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function consultMessage(candidate) {
|
|
37
|
+
switch (candidate.suggestedOutcome) {
|
|
38
|
+
case "HOLD":
|
|
39
|
+
return `Which accepted behavior does ${candidate.callable} still owe after its removed lines? Confirm surrendered behavior before further change.`;
|
|
40
|
+
case "DESCRIBE":
|
|
41
|
+
return `Document the intent and deletion test for ${candidate.callable} before this review closes.`;
|
|
42
|
+
default:
|
|
43
|
+
return `Confirm the deletion test still holds for ${candidate.callable} after these changes.`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const VALID_DIGEST = /^[0-9a-f]{64}$/;
|
|
48
|
+
|
|
49
|
+
// Build stable review items from evidence candidates. Candidates must
|
|
50
|
+
// carry a valid 64-hex content-bound span digest; malformed candidates
|
|
51
|
+
// are skipped so they can never silently shape an ID.
|
|
52
|
+
export function buildReviewFeedback(candidates) {
|
|
53
|
+
if (!Array.isArray(candidates)) return [];
|
|
54
|
+
const items = [];
|
|
55
|
+
for (const candidate of candidates) {
|
|
56
|
+
if (!candidate || candidate.decisionOwner !== "human" || candidate.advisoryOnly !== true) continue;
|
|
57
|
+
if (typeof candidate.spanSha256 !== "string" || !VALID_DIGEST.test(candidate.spanSha256)) continue;
|
|
58
|
+
const isAmend = AMEND_OUTCOMES.has(candidate.suggestedOutcome);
|
|
59
|
+
const isConsult = CONSULT_OUTCOMES.has(candidate.suggestedOutcome);
|
|
60
|
+
if (!isAmend && !isConsult) continue;
|
|
61
|
+
items.push({
|
|
62
|
+
id: shortId(candidate.path, candidate.callable, candidate.spanSha256),
|
|
63
|
+
intent: isAmend ? "AMEND" : "CONSULT",
|
|
64
|
+
path: candidate.path,
|
|
65
|
+
callable: candidate.callable,
|
|
66
|
+
targetSide: candidate.changeClassification === "removed-lines" ? "old-side" : "new-side",
|
|
67
|
+
lineStart: candidate.line,
|
|
68
|
+
lineEnd: candidate.endLine,
|
|
69
|
+
spanSha256: candidate.spanSha256,
|
|
70
|
+
suggestedOutcome: candidate.suggestedOutcome,
|
|
71
|
+
message: isAmend ? amendMessage(candidate) : consultMessage(candidate),
|
|
72
|
+
requiresHumanSubmission: true,
|
|
73
|
+
autonomousEditAllowed: false,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return items.sort((a, b) => a.path.localeCompare(b.path) || a.lineStart - b.lineStart);
|
|
77
|
+
}
|