protect-context 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +613 -2
- package/extension/index.ts +1024 -0
- package/extension/lib/brief.ts +128 -0
- package/extension/lib/config.ts +67 -0
- package/extension/lib/contract.ts +160 -0
- package/extension/lib/contract_check.ts +62 -0
- package/extension/lib/defaults.ts +124 -0
- package/extension/lib/events.ts +69 -0
- package/extension/lib/fingerprint.ts +56 -0
- package/extension/lib/gitparse.ts +229 -0
- package/extension/lib/journal.ts +100 -0
- package/extension/lib/loops.ts +151 -0
- package/extension/lib/orientation.ts +209 -0
- package/extension/lib/status.ts +88 -0
- package/extension/lib/store.ts +500 -0
- package/extension/lib/text.ts +24 -0
- package/extension/lib/verify.ts +96 -0
- package/extension/lib/widget.ts +28 -0
- package/package.json +54 -4
- package/preview.png +0 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git numstat / shortstat parsing and diff verdicts (SPEC §5, §1 #8).
|
|
3
|
+
* Pure logic — no pi imports.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { closeSync, lstatSync, openSync, readSync } from "node:fs";
|
|
7
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
export const UNTRACKED_MAX_FILES_SCANNED = 1_000;
|
|
10
|
+
export const UNTRACKED_MAX_BYTES_PER_FILE = 1_000_000;
|
|
11
|
+
export const UNTRACKED_MAX_BYTES_TOTAL = 8_000_000;
|
|
12
|
+
const READ_CHUNK = 64 * 1024;
|
|
13
|
+
|
|
14
|
+
export interface NumstatRow {
|
|
15
|
+
path: string;
|
|
16
|
+
additions: number;
|
|
17
|
+
deletions: number;
|
|
18
|
+
binary: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Numstat {
|
|
22
|
+
files: NumstatRow[];
|
|
23
|
+
fileCount: number;
|
|
24
|
+
lines: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Parse `git diff --numstat HEAD` output. Binary rows report "-\t-". */
|
|
28
|
+
export function parseNumstat(out: string): Numstat {
|
|
29
|
+
const files: NumstatRow[] = [];
|
|
30
|
+
for (const line of out.split("\n")) {
|
|
31
|
+
const trimmed = line.trim();
|
|
32
|
+
if (trimmed === "") continue;
|
|
33
|
+
const parts = trimmed.split("\t");
|
|
34
|
+
if (parts.length < 3) continue;
|
|
35
|
+
const path = parts.slice(2).join("\t");
|
|
36
|
+
const additions = parts[0] === "-" ? 0 : Number(parts[0]);
|
|
37
|
+
const deletions = parts[1] === "-" ? 0 : Number(parts[1]);
|
|
38
|
+
files.push({
|
|
39
|
+
path,
|
|
40
|
+
additions: Number.isFinite(additions) ? additions : 0,
|
|
41
|
+
deletions: Number.isFinite(deletions) ? deletions : 0,
|
|
42
|
+
binary: parts[0] === "-" || parts[1] === "-",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
files,
|
|
47
|
+
fileCount: files.length,
|
|
48
|
+
lines: files.reduce((sum, f) => sum + f.additions + f.deletions, 0),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Parse `git status --porcelain=v1 -z --untracked-files=all --`. Porcelain
|
|
53
|
+
* v1's NUL form is stable and paths are repo-root-relative; ignored files are
|
|
54
|
+
* absent because Git, rather than a home-grown ignore matcher, selects them. */
|
|
55
|
+
export function parseUntrackedPaths(out: string, activeStorePath: string | null = ".agent-guard"): string[] {
|
|
56
|
+
const store = activeStorePath
|
|
57
|
+
?.replaceAll("\\", "/")
|
|
58
|
+
.replace(/^\.\//, "")
|
|
59
|
+
.replace(/\/+$/, "");
|
|
60
|
+
const paths = new Set<string>();
|
|
61
|
+
for (const record of out.split("\0")) {
|
|
62
|
+
if (!record.startsWith("?? ")) continue;
|
|
63
|
+
const path = record.slice(3);
|
|
64
|
+
// The audit itself creates this runtime store. It is never part of the
|
|
65
|
+
// user's diff, even when the consumer has not installed the recommended
|
|
66
|
+
// .gitignore rule. Every other exclusion remains Git's decision.
|
|
67
|
+
const normalizedPath = path.replaceAll("\\", "/");
|
|
68
|
+
if (store && (normalizedPath === store || normalizedPath.startsWith(`${store}/`))) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
paths.add(path);
|
|
72
|
+
}
|
|
73
|
+
return [...paths].sort((a, b) => a.localeCompare(b));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface UntrackedScan {
|
|
77
|
+
/** Exact number of non-ignored untracked paths reported by Git. */
|
|
78
|
+
files: number;
|
|
79
|
+
lines: number;
|
|
80
|
+
filesScanned: number;
|
|
81
|
+
bytesScanned: number;
|
|
82
|
+
binaryFiles: number;
|
|
83
|
+
skippedFiles: number;
|
|
84
|
+
truncatedFiles: number;
|
|
85
|
+
scanLimited: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Count text lines in untracked files under strict I/O caps. Symlinks and
|
|
89
|
+
* paths escaping `root` are never followed. File count remains exact even
|
|
90
|
+
* when content scanning reaches a cap. */
|
|
91
|
+
export function scanUntrackedFiles(root: string, paths: string[], hardLineBudget: number): UntrackedScan {
|
|
92
|
+
const rootAbs = resolve(root);
|
|
93
|
+
const budget = Math.max(0, hardLineBudget);
|
|
94
|
+
let lines = 0;
|
|
95
|
+
let filesScanned = 0;
|
|
96
|
+
let bytesScanned = 0;
|
|
97
|
+
let binaryFiles = 0;
|
|
98
|
+
let skippedFiles = 0;
|
|
99
|
+
let truncatedFiles = 0;
|
|
100
|
+
|
|
101
|
+
for (const path of paths) {
|
|
102
|
+
if (
|
|
103
|
+
filesScanned >= UNTRACKED_MAX_FILES_SCANNED ||
|
|
104
|
+
bytesScanned >= UNTRACKED_MAX_BYTES_TOTAL ||
|
|
105
|
+
lines >= budget
|
|
106
|
+
) {
|
|
107
|
+
skippedFiles += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const candidate = resolve(rootAbs, path);
|
|
112
|
+
const rel = relative(rootAbs, candidate);
|
|
113
|
+
if (rel === "" || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || rel === ".." || isAbsolute(rel)) {
|
|
114
|
+
skippedFiles += 1;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const stat = lstatSync(candidate);
|
|
120
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
121
|
+
skippedFiles += 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
skippedFiles += 1;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let fd: number | null = null;
|
|
130
|
+
let fileBytes = 0;
|
|
131
|
+
let fileLines = 0;
|
|
132
|
+
let lastByte: number | null = null;
|
|
133
|
+
let binary = false;
|
|
134
|
+
let complete = true;
|
|
135
|
+
try {
|
|
136
|
+
fd = openSync(candidate, "r");
|
|
137
|
+
for (;;) {
|
|
138
|
+
const remainingFile = UNTRACKED_MAX_BYTES_PER_FILE - fileBytes;
|
|
139
|
+
const remainingTotal = UNTRACKED_MAX_BYTES_TOTAL - bytesScanned;
|
|
140
|
+
if (remainingFile <= 0 || remainingTotal <= 0 || lines + fileLines >= budget) {
|
|
141
|
+
complete = false;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
const size = Math.min(READ_CHUNK, remainingFile, remainingTotal);
|
|
145
|
+
const buffer = Buffer.allocUnsafe(size);
|
|
146
|
+
const read = readSync(fd, buffer, 0, size, null);
|
|
147
|
+
if (read === 0) break;
|
|
148
|
+
const chunk = buffer.subarray(0, read);
|
|
149
|
+
fileBytes += read;
|
|
150
|
+
bytesScanned += read;
|
|
151
|
+
if (chunk.includes(0)) {
|
|
152
|
+
binary = true;
|
|
153
|
+
fileLines = 0;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
for (const byte of chunk) if (byte === 0x0a) fileLines += 1;
|
|
157
|
+
lastByte = chunk[read - 1] ?? null;
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
skippedFiles += 1;
|
|
161
|
+
continue;
|
|
162
|
+
} finally {
|
|
163
|
+
if (fd !== null) {
|
|
164
|
+
try {
|
|
165
|
+
closeSync(fd);
|
|
166
|
+
} catch {
|
|
167
|
+
// Best-effort close after a read failure.
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
filesScanned += 1;
|
|
173
|
+
if (binary) {
|
|
174
|
+
binaryFiles += 1;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (complete && fileBytes > 0 && lastByte !== 0x0a) fileLines += 1;
|
|
178
|
+
if (!complete) truncatedFiles += 1;
|
|
179
|
+
lines += fileLines;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
files: paths.length,
|
|
184
|
+
lines,
|
|
185
|
+
filesScanned,
|
|
186
|
+
bytesScanned,
|
|
187
|
+
binaryFiles,
|
|
188
|
+
skippedFiles,
|
|
189
|
+
truncatedFiles,
|
|
190
|
+
scanLimited: skippedFiles > 0 || truncatedFiles > 0,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface Shortstat {
|
|
195
|
+
files: number;
|
|
196
|
+
lines: number;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Parse `git diff --shortstat HEAD` output, e.g.
|
|
200
|
+
* " 3 files changed, 120 insertions(+), 4 deletions(-)". */
|
|
201
|
+
export function parseShortstat(out: string): Shortstat {
|
|
202
|
+
const filesM = out.match(/(\d+)\s+files? changed/);
|
|
203
|
+
const insM = out.match(/(\d+)\s+insertions?\(\+\)/);
|
|
204
|
+
const delM = out.match(/(\d+)\s+deletions?\(-\)/);
|
|
205
|
+
const files = filesM ? Number(filesM[1]) : 0;
|
|
206
|
+
const lines = (insM ? Number(insM[1]) : 0) + (delM ? Number(delM[1]) : 0);
|
|
207
|
+
return { files, lines };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export type DiffLevel = "ok" | "warn" | "hard";
|
|
211
|
+
|
|
212
|
+
export interface DiffVerdict {
|
|
213
|
+
level: DiffLevel;
|
|
214
|
+
lines: number;
|
|
215
|
+
files: number;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface DiffThresholds {
|
|
219
|
+
diff_warn_lines: number;
|
|
220
|
+
diff_hard_lines: number;
|
|
221
|
+
diff_warn_files: number;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Verdict of a diff against configured thresholds (SPEC §1 #8). */
|
|
225
|
+
export function diffVerdict(lines: number, files: number, t: DiffThresholds): DiffVerdict {
|
|
226
|
+
if (lines >= t.diff_hard_lines) return { level: "hard", lines, files };
|
|
227
|
+
if (lines >= t.diff_warn_lines || files >= t.diff_warn_files) return { level: "warn", lines, files };
|
|
228
|
+
return { level: "ok", lines, files };
|
|
229
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journal (.agent-guard/journal.jsonl) — decisions/corrections/risks/notes
|
|
3
|
+
* (SPEC §1 #6 #7, §2). No pi imports.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { appendJsonl, journalPath, readJsonl } from "./store.ts";
|
|
7
|
+
import { normalizeOneLine } from "./text.ts";
|
|
8
|
+
|
|
9
|
+
export type JournalType = "decision" | "correction" | "risk" | "note";
|
|
10
|
+
|
|
11
|
+
export interface JournalEntry {
|
|
12
|
+
ts: string;
|
|
13
|
+
type: JournalType;
|
|
14
|
+
text: string;
|
|
15
|
+
tags: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isJournalType(v: unknown): v is JournalType {
|
|
19
|
+
return v === "decision" || v === "correction" || v === "risk" || v === "note";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Free-text cap (chars): journal text is re-injected into briefs (goal #12). */
|
|
23
|
+
export const TEXT_CAP = 500;
|
|
24
|
+
export const TAG_COUNT_CAP = 8;
|
|
25
|
+
export const TAG_TEXT_CAP = 40;
|
|
26
|
+
|
|
27
|
+
/** Replace terminal/control formatting, collapse whitespace and bound output. */
|
|
28
|
+
export function sanitizeJournalText(value: unknown, cap = TEXT_CAP): string {
|
|
29
|
+
return normalizeOneLine(value, cap);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Clean, deduplicate and bound tags before they reach storage or a brief. */
|
|
33
|
+
export function sanitizeTags(tags: unknown): string[] {
|
|
34
|
+
const clean: string[] = [];
|
|
35
|
+
const seen = new Set<string>();
|
|
36
|
+
const candidates = Array.isArray(tags) ? tags : typeof tags === "string" ? [tags] : [];
|
|
37
|
+
for (const tag of candidates) {
|
|
38
|
+
const value = sanitizeJournalText(tag, TAG_TEXT_CAP);
|
|
39
|
+
if (value !== "" && !seen.has(value)) {
|
|
40
|
+
clean.push(value);
|
|
41
|
+
seen.add(value);
|
|
42
|
+
}
|
|
43
|
+
if (clean.length >= TAG_COUNT_CAP) break;
|
|
44
|
+
}
|
|
45
|
+
return clean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Preserve a required provenance tag even when caller tags fill the cap. */
|
|
49
|
+
export function tagsWithRequired(
|
|
50
|
+
tags: unknown,
|
|
51
|
+
required: string,
|
|
52
|
+
): string[] {
|
|
53
|
+
const requiredClean = sanitizeJournalText(required, TAG_TEXT_CAP);
|
|
54
|
+
if (requiredClean === "") throw new Error("required journal tag must not be blank");
|
|
55
|
+
const clean = sanitizeTags(tags).filter((tag) => tag !== requiredClean);
|
|
56
|
+
return [...clean.slice(0, TAG_COUNT_CAP - 1), requiredClean];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function appendJournal(
|
|
60
|
+
cwd: string,
|
|
61
|
+
entry: { type: JournalType; text: string; tags?: string[] },
|
|
62
|
+
): JournalEntry {
|
|
63
|
+
const cleanText = sanitizeJournalText(entry.text);
|
|
64
|
+
if (cleanText === "") throw new Error("journal text must not be blank");
|
|
65
|
+
const full: JournalEntry = {
|
|
66
|
+
ts: new Date().toISOString(),
|
|
67
|
+
type: entry.type,
|
|
68
|
+
text: cleanText,
|
|
69
|
+
tags: sanitizeTags(entry.tags),
|
|
70
|
+
};
|
|
71
|
+
appendJsonl(journalPath(cwd), full);
|
|
72
|
+
return full;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function readJournal(cwd: string): JournalEntry[] {
|
|
76
|
+
return readJsonl<JournalEntry>(journalPath(cwd));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Most recent trusted entries of one type, newest first. Model-authored
|
|
80
|
+
* entries carry the reserved `agent` provenance tag and are never re-injected. */
|
|
81
|
+
export function recentTrustedEntries(
|
|
82
|
+
journal: JournalEntry[],
|
|
83
|
+
type: "decision" | "correction",
|
|
84
|
+
n: number,
|
|
85
|
+
): JournalEntry[] {
|
|
86
|
+
return journal
|
|
87
|
+
.filter((e) => e.type === type && !sanitizeTags(e.tags).includes("agent"))
|
|
88
|
+
.slice(-n)
|
|
89
|
+
.reverse();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Most recent trusted corrections, newest first. */
|
|
93
|
+
export function recentCorrections(journal: JournalEntry[], n: number): JournalEntry[] {
|
|
94
|
+
return recentTrustedEntries(journal, "correction", n);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Most recent trusted decisions, newest first. */
|
|
98
|
+
export function recentDecisions(journal: JournalEntry[], n: number): JournalEntry[] {
|
|
99
|
+
return recentTrustedEntries(journal, "decision", n);
|
|
100
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop detection from events.jsonl (SPEC §1 #9, §5).
|
|
3
|
+
* Pure logic — no pi imports.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { EventRecord } from "./events.ts";
|
|
7
|
+
import { normalizeOneLine } from "./text.ts";
|
|
8
|
+
import { normalizeCommand } from "./fingerprint.ts";
|
|
9
|
+
import type { VerifyRun } from "./verify.ts";
|
|
10
|
+
|
|
11
|
+
export interface LoopInfo {
|
|
12
|
+
cmd: string;
|
|
13
|
+
failures: number;
|
|
14
|
+
lastExit: number | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface LoopOutcome {
|
|
18
|
+
ts: string;
|
|
19
|
+
cmd: string;
|
|
20
|
+
ok: boolean | null;
|
|
21
|
+
exit: number | null;
|
|
22
|
+
order: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function timestampMillis(value: string): number | null {
|
|
26
|
+
const raw = value.trim();
|
|
27
|
+
if (raw === "") return null;
|
|
28
|
+
// Python's legacy writer could omit a zone; both runtimes treat such store
|
|
29
|
+
// rows as UTC so reconstruction does not depend on the host timezone.
|
|
30
|
+
const zoned = /(?:z|[+-]\d{2}:\d{2})$/i.test(raw) ? raw : `${raw}Z`;
|
|
31
|
+
const parsed = Date.parse(zoned);
|
|
32
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function compareOutcomes(a: LoopOutcome, b: LoopOutcome): number {
|
|
36
|
+
const aMs = timestampMillis(a.ts);
|
|
37
|
+
const bMs = timestampMillis(b.ts);
|
|
38
|
+
if (aMs !== null && bMs !== null) return aMs - bMs || a.order - b.order;
|
|
39
|
+
if (aMs !== null) return -1;
|
|
40
|
+
if (bMs !== null) return 1;
|
|
41
|
+
return a.ts.localeCompare(b.ts) || a.order - b.order;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function applyOutcome(map: Map<string, LoopInfo>, outcome: LoopOutcome): void {
|
|
45
|
+
const cmd = normalizeCommand(outcome.cmd);
|
|
46
|
+
if (cmd === "") return;
|
|
47
|
+
if (outcome.ok === true) {
|
|
48
|
+
map.delete(cmd);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (outcome.ok !== false) return;
|
|
52
|
+
const cur = map.get(cmd) ?? { cmd, failures: 0, lastExit: null };
|
|
53
|
+
cur.failures += 1;
|
|
54
|
+
if (outcome.exit !== null) cur.lastExit = outcome.exit;
|
|
55
|
+
map.set(cmd, cur);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Failing-command streaks per fingerprint-normalized command for a session.
|
|
60
|
+
* SPEC §1 #9 calls these "streaks": the count of failures since the LAST
|
|
61
|
+
* SUCCESSFUL run of that same fingerprint — a success (ok=true) resets the
|
|
62
|
+
* streak, a failure of a different command does not. This mirrors
|
|
63
|
+
* loops.streaks() on the Python side exactly (same store, same arithmetic).
|
|
64
|
+
*/
|
|
65
|
+
export function failedAttempts(events: EventRecord[], session: string): Map<string, LoopInfo> {
|
|
66
|
+
const map = new Map<string, LoopInfo>();
|
|
67
|
+
for (const e of events) {
|
|
68
|
+
if (e.kind !== "tool_result" || e.session !== session) continue;
|
|
69
|
+
if (typeof e.cmd !== "string" || e.cmd === "") continue;
|
|
70
|
+
applyOutcome(map, {
|
|
71
|
+
ts: typeof e.ts === "string" ? e.ts : "",
|
|
72
|
+
cmd: e.cmd,
|
|
73
|
+
ok: typeof e.ok === "boolean" ? e.ok : null,
|
|
74
|
+
exit: typeof e.exit === "number" && Number.isFinite(e.exit) ? e.exit : null,
|
|
75
|
+
order: 0,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return map;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Reconstruct streaks from both persisted sources in timestamp order.
|
|
82
|
+
* guard_verify writes the evidence ledger rather than events.jsonl, so using
|
|
83
|
+
* events alone forgets its failures after a restart. Session scoping is
|
|
84
|
+
* applied before merging; older sessions can never affect the new one. */
|
|
85
|
+
export function failedAttemptsFromLedgers(
|
|
86
|
+
events: EventRecord[],
|
|
87
|
+
runs: VerifyRun[],
|
|
88
|
+
session: string,
|
|
89
|
+
): Map<string, LoopInfo> {
|
|
90
|
+
const outcomes: LoopOutcome[] = [];
|
|
91
|
+
let order = 0;
|
|
92
|
+
for (const event of events) {
|
|
93
|
+
if (event.kind !== "tool_result" || event.session !== session || typeof event.cmd !== "string" || event.cmd === "") continue;
|
|
94
|
+
outcomes.push({
|
|
95
|
+
ts: typeof event.ts === "string" ? event.ts : "",
|
|
96
|
+
cmd: event.cmd,
|
|
97
|
+
ok: typeof event.ok === "boolean" ? event.ok : null,
|
|
98
|
+
exit: typeof event.exit === "number" && Number.isFinite(event.exit) ? event.exit : null,
|
|
99
|
+
order: order++,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
for (const run of runs) {
|
|
103
|
+
if (run.session !== session || typeof run.cmd !== "string" || run.cmd === "") continue;
|
|
104
|
+
outcomes.push({
|
|
105
|
+
ts: typeof run.ts === "string" ? run.ts : "",
|
|
106
|
+
cmd: run.cmd,
|
|
107
|
+
ok: typeof run.ok === "boolean" ? run.ok : null,
|
|
108
|
+
exit: typeof run.exit === "number" && Number.isFinite(run.exit) ? run.exit : null,
|
|
109
|
+
order: order++,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
outcomes.sort(compareOutcomes);
|
|
113
|
+
|
|
114
|
+
const map = new Map<string, LoopInfo>();
|
|
115
|
+
for (const outcome of outcomes) applyOutcome(map, outcome);
|
|
116
|
+
return map;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Block when prior failures >= loop_block_after - 1 (3rd default attempt).
|
|
121
|
+
* The max(1, …) clamp mirrors loops.block_threshold() on the Python side:
|
|
122
|
+
* loop_block_after=1 must never block a command with zero prior failures.
|
|
123
|
+
*/
|
|
124
|
+
export function shouldBlockLoop(priorFailures: number, loopBlockAfter: number): boolean {
|
|
125
|
+
return priorFailures >= Math.max(1, loopBlockAfter - 1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** FROZEN block reason format (SPEC §5). */
|
|
129
|
+
export function loopBlockReason(cmd: string, priorFailures: number): string {
|
|
130
|
+
const safeCommand = normalizeOneLine(cmd, 60);
|
|
131
|
+
return (
|
|
132
|
+
`guard: '${safeCommand}' already failed ${priorFailures} times this session — do not re-run unchanged; ` +
|
|
133
|
+
`change approach or read the error. ` +
|
|
134
|
+
`(Raise loop_block_after in .agent-guard/config.json to override.)`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Commands currently in a failing loop (would be blocked on next attempt). */
|
|
139
|
+
export function openLoopsFromMap(map: Map<string, LoopInfo>, loopBlockAfter: number): LoopInfo[] {
|
|
140
|
+
const out: LoopInfo[] = [];
|
|
141
|
+
for (const info of map.values()) {
|
|
142
|
+
if (shouldBlockLoop(info.failures, loopBlockAfter)) out.push(info);
|
|
143
|
+
}
|
|
144
|
+
out.sort((a, b) => b.failures - a.failures || a.cmd.localeCompare(b.cmd));
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Same, computed from raw events (disk path). */
|
|
149
|
+
export function openLoops(events: EventRecord[], session: string, loopBlockAfter: number): LoopInfo[] {
|
|
150
|
+
return openLoopsFromMap(failedAttempts(events, session), loopBlockAfter);
|
|
151
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orientation digest (SPEC §1 #4): repo tree ≤2 levels, language counts by
|
|
3
|
+
* file extension, conventions-file detection (CLAUDE.md/AGENTS.md/README...).
|
|
4
|
+
* Pure local filesystem scanning — no network, no LLM, no pi imports.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors src/protect_context/orientation.py: the session-start surface
|
|
7
|
+
* injects this digest (digest-gated like the brief) so goal #4 ("misread the
|
|
8
|
+
* repo before acting") has a reachable delivery path in the extension.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { lstatSync, readdirSync } from "node:fs";
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { normalizeOneLine } from "./text.ts";
|
|
15
|
+
|
|
16
|
+
const SKIP_DIRS = new Set([
|
|
17
|
+
"venv",
|
|
18
|
+
"env",
|
|
19
|
+
"node_modules",
|
|
20
|
+
"__pycache__",
|
|
21
|
+
"dist",
|
|
22
|
+
"build",
|
|
23
|
+
"coverage",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
export const CONVENTIONS_FILES = ["CLAUDE.md", "AGENTS.md", "CONVENTIONS.md", "README.md", "README"] as const;
|
|
27
|
+
|
|
28
|
+
const EXT_LANG: Record<string, string> = {
|
|
29
|
+
".py": "Python",
|
|
30
|
+
".pyw": "Python",
|
|
31
|
+
".ts": "TypeScript",
|
|
32
|
+
".tsx": "TypeScript",
|
|
33
|
+
".js": "JavaScript",
|
|
34
|
+
".jsx": "JavaScript",
|
|
35
|
+
".mjs": "JavaScript",
|
|
36
|
+
".cjs": "JavaScript",
|
|
37
|
+
".rs": "Rust",
|
|
38
|
+
".go": "Go",
|
|
39
|
+
".java": "Java",
|
|
40
|
+
".kt": "Kotlin",
|
|
41
|
+
".swift": "Swift",
|
|
42
|
+
".rb": "Ruby",
|
|
43
|
+
".php": "PHP",
|
|
44
|
+
".cs": "C#",
|
|
45
|
+
".c": "C",
|
|
46
|
+
".h": "C",
|
|
47
|
+
".cpp": "C++",
|
|
48
|
+
".cc": "C++",
|
|
49
|
+
".hpp": "C++",
|
|
50
|
+
".sh": "Shell",
|
|
51
|
+
".bash": "Shell",
|
|
52
|
+
".zsh": "Shell",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const MAX_TREE_ENTRIES = 12;
|
|
56
|
+
export const MAX_CHILDREN = 8;
|
|
57
|
+
const MAX_WALK_DEPTH = 3;
|
|
58
|
+
const MAX_FILES_SCANNED = 4000;
|
|
59
|
+
|
|
60
|
+
function isSkippedDir(name: string): boolean {
|
|
61
|
+
return name.startsWith(".") || SKIP_DIRS.has(name) || name.endsWith(".egg-info");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function displayName(name: string): string {
|
|
65
|
+
return normalizeOneLine(name, 120) || "(control-only name)";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface OrientationScan {
|
|
69
|
+
root: string;
|
|
70
|
+
/** Top-level dirs (with a child sample) then top-level files. */
|
|
71
|
+
tree: string[];
|
|
72
|
+
languages: Array<{ lang: string; count: number }>;
|
|
73
|
+
conventions: string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Resolve nested launch directories to their containing worktree root.
|
|
77
|
+
* Failures are intentionally quiet: outside Git (or without Git installed),
|
|
78
|
+
* orientation remains a bounded scan of the caller-supplied directory. */
|
|
79
|
+
export function orientationRoot(start: string): string {
|
|
80
|
+
try {
|
|
81
|
+
const value = execFileSync("git", ["-C", start, "rev-parse", "--show-toplevel"], {
|
|
82
|
+
encoding: "utf8",
|
|
83
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
84
|
+
timeout: 3000,
|
|
85
|
+
maxBuffer: 64 * 1024,
|
|
86
|
+
}).trim();
|
|
87
|
+
return value || start;
|
|
88
|
+
} catch {
|
|
89
|
+
return start;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function childrenSample(dir: string): string {
|
|
94
|
+
let entries: string[];
|
|
95
|
+
try {
|
|
96
|
+
entries = readdirSync(dir);
|
|
97
|
+
} catch {
|
|
98
|
+
return "";
|
|
99
|
+
}
|
|
100
|
+
const names: string[] = [];
|
|
101
|
+
const visible = entries.filter((e) => !isSkippedDir(e)).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
|
102
|
+
for (const name of visible) {
|
|
103
|
+
let isDir = false;
|
|
104
|
+
try {
|
|
105
|
+
// lstat prevents a repository symlink from turning this bounded local
|
|
106
|
+
// orientation scan into a traversal of an arbitrary external tree.
|
|
107
|
+
isDir = lstatSync(join(dir, name)).isDirectory();
|
|
108
|
+
} catch {
|
|
109
|
+
// unreadable entry: still list it
|
|
110
|
+
}
|
|
111
|
+
const shown = displayName(name);
|
|
112
|
+
names.push(isDir ? `${shown}/` : shown);
|
|
113
|
+
if (names.length >= MAX_CHILDREN) break;
|
|
114
|
+
}
|
|
115
|
+
const remaining = visible.length - names.length;
|
|
116
|
+
let s = names.join(", ");
|
|
117
|
+
if (remaining > 0) s += `, +${remaining} more`;
|
|
118
|
+
return s;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function languageCounts(root: string): Array<{ lang: string; count: number }> {
|
|
122
|
+
const counts = new Map<string, number>();
|
|
123
|
+
let seen = 0;
|
|
124
|
+
|
|
125
|
+
const walk = (dir: string, depth: number): void => {
|
|
126
|
+
if (seen >= MAX_FILES_SCANNED || depth > MAX_WALK_DEPTH) return;
|
|
127
|
+
let entries: string[];
|
|
128
|
+
try {
|
|
129
|
+
entries = readdirSync(dir);
|
|
130
|
+
} catch {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
for (const name of entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))) {
|
|
134
|
+
if (seen >= MAX_FILES_SCANNED) return;
|
|
135
|
+
const full = join(dir, name);
|
|
136
|
+
let st;
|
|
137
|
+
try {
|
|
138
|
+
st = lstatSync(full);
|
|
139
|
+
} catch {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (st.isDirectory()) {
|
|
143
|
+
if (!isSkippedDir(name)) walk(full, depth + 1);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
seen += 1;
|
|
147
|
+
const dot = name.lastIndexOf(".");
|
|
148
|
+
if (dot <= 0) continue; // no extension / dotfiles
|
|
149
|
+
const lang = EXT_LANG[name.slice(dot).toLowerCase()];
|
|
150
|
+
if (lang) counts.set(lang, (counts.get(lang) ?? 0) + 1);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
walk(root, 0);
|
|
155
|
+
return [...counts.entries()]
|
|
156
|
+
.map(([lang, count]) => ({ lang, count }))
|
|
157
|
+
.sort((a, b) => b.count - a.count || a.lang.localeCompare(b.lang));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function scanOrientation(start: string): OrientationScan {
|
|
161
|
+
const root = orientationRoot(start);
|
|
162
|
+
const dirs: string[] = [];
|
|
163
|
+
const files: string[] = [];
|
|
164
|
+
let entries: string[] = [];
|
|
165
|
+
try {
|
|
166
|
+
entries = readdirSync(root);
|
|
167
|
+
} catch {
|
|
168
|
+
entries = [];
|
|
169
|
+
}
|
|
170
|
+
for (const name of entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))) {
|
|
171
|
+
if (isSkippedDir(name)) continue;
|
|
172
|
+
let isDir = false;
|
|
173
|
+
try {
|
|
174
|
+
isDir = lstatSync(join(root, name)).isDirectory();
|
|
175
|
+
} catch {
|
|
176
|
+
// unreadable entry: list as file
|
|
177
|
+
}
|
|
178
|
+
if (isDir) {
|
|
179
|
+
const kids = childrenSample(join(root, name));
|
|
180
|
+
dirs.push(`${displayName(name)}/` + (kids ? ` (${kids})` : ""));
|
|
181
|
+
} else {
|
|
182
|
+
files.push(displayName(name));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const conventions = CONVENTIONS_FILES.filter((c) => {
|
|
186
|
+
try {
|
|
187
|
+
return lstatSync(join(root, c)).isFile();
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
return { root, tree: dirs.concat(files).slice(0, MAX_TREE_ENTRIES), languages: languageCounts(root), conventions };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Compact orientation digest (ends with Nudge) — mirrors orientation.digest(). */
|
|
196
|
+
export function renderOrientation(scan: OrientationScan): string {
|
|
197
|
+
const name = displayName(scan.root.split(/[\\/]/).filter(Boolean).pop() || scan.root);
|
|
198
|
+
const lines = [`PC orientation · ${name}`];
|
|
199
|
+
lines.push(...scan.tree);
|
|
200
|
+
const langs =
|
|
201
|
+
scan.languages
|
|
202
|
+
.slice(0, 6)
|
|
203
|
+
.map((l) => `${l.lang}=${l.count}`)
|
|
204
|
+
.join(", ") || "none detected";
|
|
205
|
+
lines.push(`languages: ${langs}`);
|
|
206
|
+
lines.push("conventions: " + (scan.conventions.join(", ") || "none found"));
|
|
207
|
+
lines.push("Nudge: read the conventions files and relevant code before the first edit — never code from guesses.");
|
|
208
|
+
return lines.join("\n");
|
|
209
|
+
}
|