@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.
Files changed (68) hide show
  1. package/README.md +33 -0
  2. package/assets/pugi-mascot.ansi +41 -0
  3. package/dist/commands/deploy.js +439 -0
  4. package/dist/core/agents/loader.js +104 -0
  5. package/dist/core/agents/registry.js +1 -1
  6. package/dist/core/consensus/anvil-fanout.js +276 -0
  7. package/dist/core/consensus/diff-capture.js +382 -0
  8. package/dist/core/consensus/rubric.js +233 -0
  9. package/dist/core/context/index.js +21 -0
  10. package/dist/core/context/pugiignore.js +316 -0
  11. package/dist/core/context/repo-skeleton.js +533 -0
  12. package/dist/core/context/watcher.js +342 -0
  13. package/dist/core/context/working-set.js +165 -0
  14. package/dist/core/edits/dispatch.js +185 -0
  15. package/dist/core/edits/index.js +15 -0
  16. package/dist/core/edits/layer-a-apply.js +217 -0
  17. package/dist/core/edits/layer-b-apply.js +211 -0
  18. package/dist/core/edits/layer-c-apply.js +160 -0
  19. package/dist/core/edits/layer-d-ast.js +29 -0
  20. package/dist/core/edits/marker-parser.js +401 -0
  21. package/dist/core/edits/security-gate.js +223 -0
  22. package/dist/core/edits/worktree.js +229 -0
  23. package/dist/core/engine/native-pugi.js +6 -1
  24. package/dist/core/engine/prompts.js +4 -1
  25. package/dist/core/engine/tool-bridge.js +33 -1
  26. package/dist/core/lsp/client.js +631 -0
  27. package/dist/core/repl/ask.js +512 -0
  28. package/dist/core/repl/cancellation.js +98 -0
  29. package/dist/core/repl/dispatch-fsm.js +220 -0
  30. package/dist/core/repl/privacy-banner.js +71 -0
  31. package/dist/core/repl/session.js +1896 -13
  32. package/dist/core/repl/slash-commands.js +59 -32
  33. package/dist/core/repl/store/index.js +12 -0
  34. package/dist/core/repl/store/jsonl-log.js +321 -0
  35. package/dist/core/repl/store/lockfile.js +155 -0
  36. package/dist/core/repl/store/session-store.js +792 -0
  37. package/dist/core/repl/store/types.js +44 -0
  38. package/dist/core/repl/store/uuid-v7.js +68 -0
  39. package/dist/core/repl/workspace-context.js +72 -1
  40. package/dist/core/skills/loader.js +454 -0
  41. package/dist/core/skills/sources.js +480 -0
  42. package/dist/core/skills/trust.js +172 -0
  43. package/dist/runtime/cli.js +767 -10
  44. package/dist/runtime/commands/agents.js +385 -0
  45. package/dist/runtime/commands/config.js +338 -8
  46. package/dist/runtime/commands/lsp.js +184 -0
  47. package/dist/runtime/commands/patch.js +111 -0
  48. package/dist/runtime/commands/review-consensus.js +399 -0
  49. package/dist/runtime/commands/skills.js +401 -0
  50. package/dist/runtime/commands/worktree.js +133 -0
  51. package/dist/tools/apply-patch.js +314 -0
  52. package/dist/tools/file-tools.js +90 -0
  53. package/dist/tools/lsp-tools.js +189 -0
  54. package/dist/tools/registry.js +18 -0
  55. package/dist/tools/web-fetch.js +1 -1
  56. package/dist/tui/agent-tree-pane.js +9 -0
  57. package/dist/tui/ask-cli.js +52 -0
  58. package/dist/tui/ask-modal.js +211 -0
  59. package/dist/tui/conversation-pane.js +48 -3
  60. package/dist/tui/input-box.js +48 -5
  61. package/dist/tui/markdown-render.js +266 -0
  62. package/dist/tui/repl-render.js +185 -0
  63. package/dist/tui/repl-splash-mascot.js +130 -0
  64. package/dist/tui/repl-splash.js +7 -1
  65. package/dist/tui/repl.js +82 -11
  66. package/dist/tui/status-bar.js +63 -3
  67. package/dist/tui/tool-stream-pane.js +91 -0
  68. 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