@pugi/cli 0.1.0-alpha.9 → 0.1.0-beta.2
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/README.md +33 -0
- package/assets/pugi-mascot.ansi +41 -0
- package/dist/commands/deploy.js +439 -0
- package/dist/core/agents/loader.js +104 -0
- package/dist/core/agents/registry.js +1 -1
- package/dist/core/consensus/anvil-fanout.js +276 -0
- package/dist/core/consensus/diff-capture.js +382 -0
- package/dist/core/consensus/rubric.js +233 -0
- package/dist/core/context/index.js +21 -0
- package/dist/core/context/pugiignore.js +316 -0
- package/dist/core/context/repo-skeleton.js +533 -0
- package/dist/core/context/watcher.js +342 -0
- package/dist/core/context/working-set.js +165 -0
- package/dist/core/edits/dispatch.js +185 -0
- package/dist/core/edits/index.js +15 -0
- package/dist/core/edits/layer-a-apply.js +217 -0
- package/dist/core/edits/layer-b-apply.js +211 -0
- package/dist/core/edits/layer-c-apply.js +160 -0
- package/dist/core/edits/layer-d-ast.js +29 -0
- package/dist/core/edits/marker-parser.js +401 -0
- package/dist/core/edits/security-gate.js +223 -0
- package/dist/core/edits/worktree.js +229 -0
- package/dist/core/engine/native-pugi.js +6 -1
- package/dist/core/engine/prompts.js +4 -1
- package/dist/core/engine/tool-bridge.js +33 -1
- package/dist/core/lsp/client.js +631 -0
- package/dist/core/repl/ask.js +512 -0
- package/dist/core/repl/cancellation.js +98 -0
- package/dist/core/repl/dispatch-fsm.js +220 -0
- package/dist/core/repl/privacy-banner.js +71 -0
- package/dist/core/repl/session.js +1896 -13
- package/dist/core/repl/slash-commands.js +59 -32
- package/dist/core/repl/store/index.js +12 -0
- package/dist/core/repl/store/jsonl-log.js +321 -0
- package/dist/core/repl/store/lockfile.js +155 -0
- package/dist/core/repl/store/session-store.js +792 -0
- package/dist/core/repl/store/types.js +44 -0
- package/dist/core/repl/store/uuid-v7.js +68 -0
- package/dist/core/repl/workspace-context.js +72 -1
- package/dist/core/skills/loader.js +454 -0
- package/dist/core/skills/sources.js +480 -0
- package/dist/core/skills/trust.js +172 -0
- package/dist/runtime/cli.js +767 -10
- package/dist/runtime/commands/agents.js +385 -0
- package/dist/runtime/commands/config.js +338 -8
- package/dist/runtime/commands/lsp.js +184 -0
- package/dist/runtime/commands/patch.js +111 -0
- package/dist/runtime/commands/review-consensus.js +399 -0
- package/dist/runtime/commands/skills.js +401 -0
- package/dist/runtime/commands/worktree.js +133 -0
- package/dist/tools/apply-patch.js +314 -0
- package/dist/tools/file-tools.js +90 -0
- package/dist/tools/lsp-tools.js +189 -0
- package/dist/tools/registry.js +18 -0
- package/dist/tools/web-fetch.js +1 -1
- package/dist/tui/agent-tree-pane.js +9 -0
- package/dist/tui/ask-cli.js +52 -0
- package/dist/tui/ask-modal.js +211 -0
- package/dist/tui/conversation-pane.js +48 -3
- package/dist/tui/input-box.js +48 -5
- package/dist/tui/markdown-render.js +266 -0
- package/dist/tui/repl-render.js +185 -0
- package/dist/tui/repl-splash-mascot.js +130 -0
- package/dist/tui/repl-splash.js +7 -1
- package/dist/tui/repl.js +82 -11
- package/dist/tui/status-bar.js +63 -3
- package/dist/tui/tool-stream-pane.js +91 -0
- package/package.json +11 -5
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* α6.6 diff escalation — public barrel.
|
|
3
|
+
*
|
|
4
|
+
* Surface for callers (CLI integration, future engine adapters, tests).
|
|
5
|
+
* Keeps the per-layer module path stable so β-tier additions (Layer D
|
|
6
|
+
* implementation, conflict resolution UI, unified-diff `git apply`
|
|
7
|
+
* fallback) can land without churn at the import sites.
|
|
8
|
+
*/
|
|
9
|
+
export { applyLayerA, countOccurrences, } from './layer-a-apply.js';
|
|
10
|
+
export { applyLayerB, } from './layer-b-apply.js';
|
|
11
|
+
export { applyLayerC, sha256OfUtf8, } from './layer-c-apply.js';
|
|
12
|
+
export { applyLayerD, LayerDDeferredError, } from './layer-d-ast.js';
|
|
13
|
+
export { MarkerParseError, detectFamily, parseMarkers, } from './marker-parser.js';
|
|
14
|
+
export { dispatchEdit, resolveFamily, } from './dispatch.js';
|
|
15
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer A diff applicator — α6.6 diff escalation Phase 1.
|
|
3
|
+
*
|
|
4
|
+
* Layer A is the default and lowest-risk edit primitive: a single
|
|
5
|
+
* `{file, oldString, newString}` block that must match exactly once
|
|
6
|
+
* (whitespace-sensitive). It mirrors the Claude Code Edit-tool semantics
|
|
7
|
+
* by design: agents emit a chunk of context guaranteed to be unique
|
|
8
|
+
* inside the file, and we replace it.
|
|
9
|
+
*
|
|
10
|
+
* Three failure modes are surfaced LOUD (never silent partial match):
|
|
11
|
+
*
|
|
12
|
+
* - `no_match` — oldString does not appear in the file at all.
|
|
13
|
+
* Model must re-read with broader context.
|
|
14
|
+
* - `ambiguous_match` — oldString appears 2+ times. Model must extend
|
|
15
|
+
* the context to disambiguate (line above /
|
|
16
|
+
* below / function name / etc.). The result
|
|
17
|
+
* carries the match count so the dispatcher
|
|
18
|
+
* can surface it to the operator.
|
|
19
|
+
* - `file_missing` — the target file does not exist on disk.
|
|
20
|
+
* Layer A NEVER creates files — that is the
|
|
21
|
+
* job of the write tool or Layer C.
|
|
22
|
+
*
|
|
23
|
+
* Cursor's silent partial-match falling back to inline rewrite is the
|
|
24
|
+
* documented anti-pattern in the spec; we do not replicate it.
|
|
25
|
+
*
|
|
26
|
+
* Writes use the tmp + rename atomic pattern (matches `writeTool` in
|
|
27
|
+
* `apps/pugi-cli/src/tools/file-tools.ts`). A crash mid-write leaves
|
|
28
|
+
* the original file intact; the `.pugi-tmp-*` file is the only
|
|
29
|
+
* orphan and trivially identifiable.
|
|
30
|
+
*/
|
|
31
|
+
import { existsSync, readFileSync, renameSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
32
|
+
import { applySecurityGate } from './security-gate.js';
|
|
33
|
+
/**
|
|
34
|
+
* Apply a single Layer A edit. The function is intentionally pure with
|
|
35
|
+
* respect to errors — any failure returns a structured `ApplyResult`
|
|
36
|
+
* rather than throwing. The dispatcher aggregates results; throwing
|
|
37
|
+
* here would force the dispatcher to translate exceptions back into the
|
|
38
|
+
* same shape on every layer.
|
|
39
|
+
*/
|
|
40
|
+
export async function applyLayerA(edit, opts) {
|
|
41
|
+
// SECURITY GATE — fail-fast before ANY filesystem read/write.
|
|
42
|
+
// The gate runs three checks in order: workspace path scoping,
|
|
43
|
+
// protected-basename / suffix / credential-path deny, and a
|
|
44
|
+
// realpath-based symlink-escape re-check. See `security-gate.ts`
|
|
45
|
+
// for the full rationale. Bypassing this is how PR #392's pre-fix
|
|
46
|
+
// version would have written through `+++ NEW ../../../../etc/passwd`
|
|
47
|
+
// and `+++ NEW .env` markers (triple-review 2026-05-25 P0).
|
|
48
|
+
let gateResult;
|
|
49
|
+
try {
|
|
50
|
+
gateResult = applySecurityGate(edit.file, { cwd: opts.cwd, toolName: 'layer-a' });
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
// The gate only throws on unexpected filesystem errors (anything
|
|
54
|
+
// other than ENOENT / ENOTDIR on the realpath calls). Treat those
|
|
55
|
+
// as a write error so the dispatcher records the actual cause.
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
bytesWritten: 0,
|
|
59
|
+
reason: 'write_error',
|
|
60
|
+
absPath: edit.file,
|
|
61
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (!gateResult.ok) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
bytesWritten: 0,
|
|
68
|
+
reason: gateResult.reason,
|
|
69
|
+
absPath: edit.file,
|
|
70
|
+
detail: gateResult.detail,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const absPath = gateResult.absPath;
|
|
74
|
+
if (!existsSync(absPath)) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
bytesWritten: 0,
|
|
78
|
+
reason: 'file_missing',
|
|
79
|
+
absPath,
|
|
80
|
+
detail: `file does not exist: ${edit.file}`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
// Whitespace-sensitive: read as UTF-8 with no normalisation. CRLF
|
|
84
|
+
// vs LF differences in the model's emitted `oldString` will fail
|
|
85
|
+
// `no_match` LOUD rather than silently re-line-ending the file.
|
|
86
|
+
const before = readFileSync(absPath, 'utf8');
|
|
87
|
+
const matchCount = countOccurrences(before, edit.oldString);
|
|
88
|
+
if (matchCount === 0) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
bytesWritten: 0,
|
|
92
|
+
reason: 'no_match',
|
|
93
|
+
absPath,
|
|
94
|
+
matchCount: 0,
|
|
95
|
+
detail: `oldString not found in ${edit.file}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (matchCount > 1) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
bytesWritten: 0,
|
|
102
|
+
reason: 'ambiguous_match',
|
|
103
|
+
absPath,
|
|
104
|
+
matchCount,
|
|
105
|
+
detail: `oldString matches ${matchCount} times in ${edit.file}; extend context to disambiguate`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (edit.oldString === edit.newString) {
|
|
109
|
+
// Treat no-op edits as a failure so the model corrects itself
|
|
110
|
+
// rather than silently believing the patch landed. This matches
|
|
111
|
+
// the Claude Code Edit-tool contract.
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
bytesWritten: 0,
|
|
115
|
+
reason: 'identical_replacement',
|
|
116
|
+
absPath,
|
|
117
|
+
matchCount: 1,
|
|
118
|
+
detail: 'oldString and newString are identical — no-op',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const after = applySingleReplace(before, edit.oldString, edit.newString);
|
|
122
|
+
if (opts.dryRun) {
|
|
123
|
+
return {
|
|
124
|
+
ok: true,
|
|
125
|
+
bytesWritten: 0,
|
|
126
|
+
absPath,
|
|
127
|
+
matchCount: 1,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
atomicWrite(absPath, after);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
bytesWritten: 0,
|
|
137
|
+
reason: 'write_error',
|
|
138
|
+
absPath,
|
|
139
|
+
matchCount: 1,
|
|
140
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
bytesWritten: Buffer.byteLength(after, 'utf8'),
|
|
146
|
+
absPath,
|
|
147
|
+
matchCount: 1,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Count exact (whitespace-sensitive) occurrences of `needle` in `haystack`.
|
|
152
|
+
* Empty needle returns 0 — a model that emits an empty `oldString` is
|
|
153
|
+
* either bug or attempting a full-file replacement, both of which
|
|
154
|
+
* Layer A rejects in favour of Layer C.
|
|
155
|
+
*
|
|
156
|
+
* Implemented via repeated `indexOf` (not `split(needle).length - 1`)
|
|
157
|
+
* to avoid building an O(N) intermediate array for large files.
|
|
158
|
+
*/
|
|
159
|
+
export function countOccurrences(haystack, needle) {
|
|
160
|
+
if (needle.length === 0)
|
|
161
|
+
return 0;
|
|
162
|
+
let count = 0;
|
|
163
|
+
let from = 0;
|
|
164
|
+
while (true) {
|
|
165
|
+
const idx = haystack.indexOf(needle, from);
|
|
166
|
+
if (idx === -1)
|
|
167
|
+
return count;
|
|
168
|
+
count += 1;
|
|
169
|
+
from = idx + needle.length;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Replace the SINGLE occurrence of `needle` in `haystack` with
|
|
174
|
+
* `replacement`. Pre-condition: caller verified `countOccurrences === 1`.
|
|
175
|
+
* Returns the original buffer if no match (defensive; should not occur).
|
|
176
|
+
*/
|
|
177
|
+
function applySingleReplace(haystack, needle, replacement) {
|
|
178
|
+
const idx = haystack.indexOf(needle);
|
|
179
|
+
if (idx === -1)
|
|
180
|
+
return haystack;
|
|
181
|
+
return haystack.slice(0, idx) + replacement + haystack.slice(idx + needle.length);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Atomic write helper — writes contents to `<path>.pugi-tmp-<ts>-<rand>`
|
|
185
|
+
* then renames over the target. The rename(2) syscall is atomic on
|
|
186
|
+
* POSIX filesystems, so a crash between `writeFileSync` and `renameSync`
|
|
187
|
+
* leaves the ORIGINAL file intact. The orphaned tmp file is named with
|
|
188
|
+
* `pugi-tmp` so a janitor sweep can clean stale artifacts.
|
|
189
|
+
*
|
|
190
|
+
* Mirrors the same pattern used by `writeTool` and `editTool` in
|
|
191
|
+
* `apps/pugi-cli/src/tools/file-tools.ts` so the audit story is
|
|
192
|
+
* uniform.
|
|
193
|
+
*/
|
|
194
|
+
function atomicWrite(absPath, contents) {
|
|
195
|
+
// Suffix combines clock + random so two concurrent edits on the same
|
|
196
|
+
// file from a single process do not collide (Date.now() resolution is
|
|
197
|
+
// millisecond-bounded; two writes inside the same ms would clobber).
|
|
198
|
+
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
199
|
+
const tmp = `${absPath}.pugi-tmp-${suffix}`;
|
|
200
|
+
try {
|
|
201
|
+
writeFileSync(tmp, contents, { encoding: 'utf8', mode: 0o600 });
|
|
202
|
+
renameSync(tmp, absPath);
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
// Best-effort cleanup; if the tmp never landed the unlink will
|
|
206
|
+
// ENOENT and we swallow that. Surface the original error to the
|
|
207
|
+
// caller via re-throw so the apply result records `write_error`.
|
|
208
|
+
try {
|
|
209
|
+
unlinkSync(tmp);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// tmp file may not exist if writeFileSync itself failed.
|
|
213
|
+
}
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
//# sourceMappingURL=layer-a-apply.js.map
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer B diff applicator — α6.6 diff escalation Phase 1.
|
|
3
|
+
*
|
|
4
|
+
* Layer B is the multi-edit primitive: an ordered list of
|
|
5
|
+
* `{oldString, newString}` blocks against a single file, applied
|
|
6
|
+
* ALL-OR-NOTHING. Each block must satisfy the same uniqueness
|
|
7
|
+
* invariant as Layer A, but evaluated against the WORKING buffer
|
|
8
|
+
* after the prior blocks land — not the original disk contents. This
|
|
9
|
+
* lets the model emit edits like "rename `foo` to `bar`, then add a
|
|
10
|
+
* call to `bar(42)`" without the second edit failing because `foo`
|
|
11
|
+
* no longer exists.
|
|
12
|
+
*
|
|
13
|
+
* Atomicity story:
|
|
14
|
+
*
|
|
15
|
+
* 1. We mutate a single in-memory string buffer.
|
|
16
|
+
* 2. Every block runs through `applyOneToBuffer` and either advances
|
|
17
|
+
* the buffer or returns a structured failure.
|
|
18
|
+
* 3. On ANY block failure we discard the buffer and write nothing.
|
|
19
|
+
* The on-disk file remains exactly as it was on entry.
|
|
20
|
+
* 4. On total success we hand the final buffer to the same atomic
|
|
21
|
+
* tmp+rename writer Layer A uses, so a crash mid-write can never
|
|
22
|
+
* leave a partially-edited file.
|
|
23
|
+
*
|
|
24
|
+
* Roll-back is therefore in-memory — no need to track per-block undo
|
|
25
|
+
* because nothing touched the disk until the final write.
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync, readFileSync, renameSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
28
|
+
import { countOccurrences, } from './layer-a-apply.js';
|
|
29
|
+
import { applySecurityGate } from './security-gate.js';
|
|
30
|
+
export async function applyLayerB(batch, opts) {
|
|
31
|
+
// SECURITY GATE — see security-gate.ts. Identical responsibilities
|
|
32
|
+
// to Layer A: workspace path scoping, protected-basename deny,
|
|
33
|
+
// symlink-escape re-check. Runs BEFORE the empty-batch fast-path so
|
|
34
|
+
// a maliciously empty batch targeting `.env` still surfaces the
|
|
35
|
+
// protected-file rejection rather than the no-edits one.
|
|
36
|
+
let gateResult;
|
|
37
|
+
try {
|
|
38
|
+
gateResult = applySecurityGate(batch.file, { cwd: opts.cwd, toolName: 'layer-b' });
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
bytesWritten: 0,
|
|
44
|
+
reason: 'write_error',
|
|
45
|
+
absPath: batch.file,
|
|
46
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
47
|
+
appliedCount: 0,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (!gateResult.ok) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
bytesWritten: 0,
|
|
54
|
+
reason: gateResult.reason,
|
|
55
|
+
absPath: batch.file,
|
|
56
|
+
detail: gateResult.detail,
|
|
57
|
+
appliedCount: 0,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const absPath = gateResult.absPath;
|
|
61
|
+
if (batch.edits.length === 0) {
|
|
62
|
+
// An empty edit array is almost always a bug in the marker parser
|
|
63
|
+
// or the model prompt. Fail loud rather than no-op success.
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
bytesWritten: 0,
|
|
67
|
+
reason: 'no_match',
|
|
68
|
+
absPath,
|
|
69
|
+
detail: 'Layer B batch contains zero sub-edits',
|
|
70
|
+
appliedCount: 0,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (!existsSync(absPath)) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
bytesWritten: 0,
|
|
77
|
+
reason: 'file_missing',
|
|
78
|
+
absPath,
|
|
79
|
+
detail: `file does not exist: ${batch.file}`,
|
|
80
|
+
appliedCount: 0,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const original = readFileSync(absPath, 'utf8');
|
|
84
|
+
let buffer = original;
|
|
85
|
+
let appliedCount = 0;
|
|
86
|
+
for (let i = 0; i < batch.edits.length; i += 1) {
|
|
87
|
+
const sub = batch.edits[i];
|
|
88
|
+
if (!sub)
|
|
89
|
+
continue;
|
|
90
|
+
const stepResult = applyOneToBuffer(buffer, sub);
|
|
91
|
+
if (!stepResult.ok) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
bytesWritten: 0,
|
|
95
|
+
reason: stepResult.reason,
|
|
96
|
+
absPath,
|
|
97
|
+
matchCount: stepResult.matchCount,
|
|
98
|
+
detail: `Layer B aborted at sub-edit ${i}: ${stepResult.detail}`,
|
|
99
|
+
appliedCount,
|
|
100
|
+
subFailures: [
|
|
101
|
+
{
|
|
102
|
+
index: i,
|
|
103
|
+
reason: stepResult.reason,
|
|
104
|
+
matchCount: stepResult.matchCount,
|
|
105
|
+
detail: stepResult.detail,
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
buffer = stepResult.buffer;
|
|
111
|
+
appliedCount += 1;
|
|
112
|
+
}
|
|
113
|
+
// No-op detection: if every sub-edit was an identical-replacement,
|
|
114
|
+
// `appliedCount` would be 0 above because applyOneToBuffer rejects
|
|
115
|
+
// those. A zero-byte diff with appliedCount === batch.edits.length
|
|
116
|
+
// is technically possible if the edits cancel each other out; we
|
|
117
|
+
// accept that quietly because the model presumably knows.
|
|
118
|
+
if (buffer === original) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
bytesWritten: 0,
|
|
122
|
+
reason: 'identical_replacement',
|
|
123
|
+
absPath,
|
|
124
|
+
detail: 'Layer B batch produced no net change to the file',
|
|
125
|
+
appliedCount,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (opts.dryRun) {
|
|
129
|
+
return {
|
|
130
|
+
ok: true,
|
|
131
|
+
bytesWritten: 0,
|
|
132
|
+
absPath,
|
|
133
|
+
matchCount: appliedCount,
|
|
134
|
+
appliedCount,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
atomicWrite(absPath, buffer);
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
bytesWritten: 0,
|
|
144
|
+
reason: 'write_error',
|
|
145
|
+
absPath,
|
|
146
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
147
|
+
appliedCount,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
ok: true,
|
|
152
|
+
bytesWritten: Buffer.byteLength(buffer, 'utf8'),
|
|
153
|
+
absPath,
|
|
154
|
+
matchCount: appliedCount,
|
|
155
|
+
appliedCount,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function applyOneToBuffer(buffer, sub) {
|
|
159
|
+
if (sub.oldString === sub.newString) {
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
reason: 'identical_replacement',
|
|
163
|
+
detail: 'oldString and newString are identical',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const count = countOccurrences(buffer, sub.oldString);
|
|
167
|
+
if (count === 0) {
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
reason: 'no_match',
|
|
171
|
+
matchCount: 0,
|
|
172
|
+
detail: 'oldString not found in current buffer',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (count > 1) {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
reason: 'ambiguous_match',
|
|
179
|
+
matchCount: count,
|
|
180
|
+
detail: `oldString matches ${count} times in current buffer`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const idx = buffer.indexOf(sub.oldString);
|
|
184
|
+
const next = buffer.slice(0, idx) + sub.newString + buffer.slice(idx + sub.oldString.length);
|
|
185
|
+
return { ok: true, buffer: next };
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Atomic write helper — duplicated from `layer-a-apply.ts` (not exported
|
|
189
|
+
* to keep the dependency direction one-way: A is the primitive, B
|
|
190
|
+
* happens to reuse the same disk-write pattern but is otherwise
|
|
191
|
+
* independent). Both helpers are intentionally small enough that
|
|
192
|
+
* duplication is cheaper than dragging in a shared util module.
|
|
193
|
+
*/
|
|
194
|
+
function atomicWrite(absPath, contents) {
|
|
195
|
+
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
196
|
+
const tmp = `${absPath}.pugi-tmp-${suffix}`;
|
|
197
|
+
try {
|
|
198
|
+
writeFileSync(tmp, contents, { encoding: 'utf8', mode: 0o600 });
|
|
199
|
+
renameSync(tmp, absPath);
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
try {
|
|
203
|
+
unlinkSync(tmp);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// ignore — tmp may not exist yet.
|
|
207
|
+
}
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
//# sourceMappingURL=layer-b-apply.js.map
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer C diff applicator — α6.6 diff escalation Phase 1.
|
|
3
|
+
*
|
|
4
|
+
* Layer C is the full-file rewrite escape hatch. The model emits the
|
|
5
|
+
* complete new file contents plus a sha256 of the file as it was when
|
|
6
|
+
* the model READ it. We compute the current sha256 of the on-disk
|
|
7
|
+
* file, compare, and either:
|
|
8
|
+
*
|
|
9
|
+
* - Match — atomically write the new contents.
|
|
10
|
+
* - Mismatch — refuse with `base_sha_mismatch`. The model must
|
|
11
|
+
* re-read the file and resubmit; the disk version changed between
|
|
12
|
+
* read and write (filewatcher event, parallel edit, human typing).
|
|
13
|
+
*
|
|
14
|
+
* This is the "I rewrote the entire file" path. It is the heaviest
|
|
15
|
+
* Layer because the wire payload is the full file body, but it is also
|
|
16
|
+
* the safest for sweeping refactors that touch most lines — Layer A
|
|
17
|
+
* would emit dozens of fragile blocks and Layer B's atomicity goes
|
|
18
|
+
* out the window past ~10 blocks.
|
|
19
|
+
*
|
|
20
|
+
* The sha256 gate is the load-bearing safety. Without it, Layer C
|
|
21
|
+
* silently overwrites concurrent edits; with it, the operator
|
|
22
|
+
* (and Mira) sees a clean `mismatch` event and can resolve manually.
|
|
23
|
+
*/
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
import { existsSync, readFileSync, renameSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
26
|
+
import { applySecurityGate } from './security-gate.js';
|
|
27
|
+
export async function applyLayerC(edit, opts) {
|
|
28
|
+
// SECURITY GATE — see security-gate.ts. Layer C is the most
|
|
29
|
+
// dangerous layer (full-file rewrite) so the gate runs FIRST, ahead
|
|
30
|
+
// of the baseSha check. A missing baseSha on an attempted `.env`
|
|
31
|
+
// rewrite should surface as `protected_file`, not `write_error`.
|
|
32
|
+
let gateResult;
|
|
33
|
+
try {
|
|
34
|
+
gateResult = applySecurityGate(edit.file, { cwd: opts.cwd, toolName: 'layer-c' });
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
bytesWritten: 0,
|
|
40
|
+
reason: 'write_error',
|
|
41
|
+
absPath: edit.file,
|
|
42
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (!gateResult.ok) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
bytesWritten: 0,
|
|
49
|
+
reason: gateResult.reason,
|
|
50
|
+
absPath: edit.file,
|
|
51
|
+
detail: gateResult.detail,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const absPath = gateResult.absPath;
|
|
55
|
+
if (edit.baseSha256.length === 0) {
|
|
56
|
+
// An empty baseSha is almost always a marker-parser bug. Refuse
|
|
57
|
+
// rather than write blind.
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
bytesWritten: 0,
|
|
61
|
+
reason: 'write_error',
|
|
62
|
+
absPath,
|
|
63
|
+
detail: 'Layer C requires a non-empty baseSha256',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (!existsSync(absPath)) {
|
|
67
|
+
// Layer C is a REWRITE, not a CREATE. Use Layer A's sibling
|
|
68
|
+
// `write` tool to create files. Failing loud keeps the layer
|
|
69
|
+
// semantics honest: Layer C's gate is "file was X when I read
|
|
70
|
+
// it"; a non-existent file violates the read invariant.
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
bytesWritten: 0,
|
|
74
|
+
reason: 'file_missing',
|
|
75
|
+
absPath,
|
|
76
|
+
detail: `file does not exist: ${edit.file}; Layer C does not create files`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const current = readFileSync(absPath, 'utf8');
|
|
80
|
+
const actualSha = sha256OfUtf8(current);
|
|
81
|
+
if (actualSha !== edit.baseSha256) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
bytesWritten: 0,
|
|
85
|
+
reason: 'base_sha_mismatch',
|
|
86
|
+
absPath,
|
|
87
|
+
detail: `file changed since model read: expected sha256 ${edit.baseSha256.slice(0, 12)}…, ` +
|
|
88
|
+
`got ${actualSha.slice(0, 12)}…`,
|
|
89
|
+
expectedSha256: edit.baseSha256,
|
|
90
|
+
actualSha256: actualSha,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (current === edit.newContents) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
bytesWritten: 0,
|
|
97
|
+
reason: 'identical_replacement',
|
|
98
|
+
absPath,
|
|
99
|
+
detail: 'newContents is identical to current file',
|
|
100
|
+
expectedSha256: edit.baseSha256,
|
|
101
|
+
actualSha256: actualSha,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (opts.dryRun) {
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
bytesWritten: 0,
|
|
108
|
+
absPath,
|
|
109
|
+
expectedSha256: edit.baseSha256,
|
|
110
|
+
actualSha256: actualSha,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
atomicWrite(absPath, edit.newContents);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
bytesWritten: 0,
|
|
120
|
+
reason: 'write_error',
|
|
121
|
+
absPath,
|
|
122
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
123
|
+
expectedSha256: edit.baseSha256,
|
|
124
|
+
actualSha256: actualSha,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
bytesWritten: Buffer.byteLength(edit.newContents, 'utf8'),
|
|
130
|
+
absPath,
|
|
131
|
+
expectedSha256: edit.baseSha256,
|
|
132
|
+
actualSha256: actualSha,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Sha256 of a UTF-8 string. Hex output, lowercased. Centralised so the
|
|
137
|
+
* apply-side hash and the dispatcher-side hash (when computing the
|
|
138
|
+
* baseSha for the model on read) agree byte-for-byte.
|
|
139
|
+
*/
|
|
140
|
+
export function sha256OfUtf8(contents) {
|
|
141
|
+
return createHash('sha256').update(contents, 'utf8').digest('hex');
|
|
142
|
+
}
|
|
143
|
+
function atomicWrite(absPath, contents) {
|
|
144
|
+
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
145
|
+
const tmp = `${absPath}.pugi-tmp-${suffix}`;
|
|
146
|
+
try {
|
|
147
|
+
writeFileSync(tmp, contents, { encoding: 'utf8', mode: 0o600 });
|
|
148
|
+
renameSync(tmp, absPath);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
try {
|
|
152
|
+
unlinkSync(tmp);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// ignore — tmp may not exist.
|
|
156
|
+
}
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
//# sourceMappingURL=layer-c-apply.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel error type the dispatcher recognises. Distinct from the
|
|
3
|
+
* generic `Error` so the dispatcher can render a friendly
|
|
4
|
+
* "AST edits land in α6.6b" message rather than a stack trace.
|
|
5
|
+
*
|
|
6
|
+
* Caller convention: catch `LayerDDeferredError` explicitly; rethrow
|
|
7
|
+
* any other error as before.
|
|
8
|
+
*/
|
|
9
|
+
export class LayerDDeferredError extends Error {
|
|
10
|
+
code = 'LAYER_D_DEFERRED';
|
|
11
|
+
operation;
|
|
12
|
+
constructor(operation) {
|
|
13
|
+
super(`Layer D (AST) edits land in α6.6b — attempted operation: ${operation}. ` +
|
|
14
|
+
'Use Layer A / B / C until the AST engine ships.');
|
|
15
|
+
this.name = 'LayerDDeferredError';
|
|
16
|
+
this.operation = operation;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Stub applicator. Always rejects with `LayerDDeferredError`. Returns
|
|
21
|
+
* the standard `ApplyResult` shape on the (impossible) success path
|
|
22
|
+
* so the dispatcher can treat all four layers uniformly once Layer D
|
|
23
|
+
* lights up.
|
|
24
|
+
*/
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
26
|
+
export async function applyLayerD(edit, _opts) {
|
|
27
|
+
throw new LayerDDeferredError(edit.operation);
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=layer-d-ast.js.map
|