atom-agent 1.2.0 → 1.3.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +13 -4
  3. package/atom.example.json +11 -0
  4. package/dist/App.js +923 -200
  5. package/dist/adapters.js +82 -13
  6. package/dist/agent/goal-evaluator.js +69 -0
  7. package/dist/agent/loop.js +517 -76
  8. package/dist/cli.js +11 -3
  9. package/dist/compact.js +41 -15
  10. package/dist/config.js +43 -7
  11. package/dist/context-manager.js +16 -198
  12. package/dist/context-windows.js +4 -2
  13. package/dist/env-block.js +5 -5
  14. package/dist/extension-commands.js +196 -0
  15. package/dist/extension-ui.js +153 -0
  16. package/dist/extensions.js +1571 -0
  17. package/dist/goal.js +583 -0
  18. package/dist/project-trust.js +96 -0
  19. package/dist/providers.js +6 -6
  20. package/dist/scheduler.js +74 -36
  21. package/dist/session.js +23 -5
  22. package/dist/sessions.js +25 -6
  23. package/dist/telemetry-dashboard.js +28 -0
  24. package/dist/telemetry.js +39 -0
  25. package/dist/tools/compaction-hooks.js +165 -0
  26. package/dist/tools/custom.js +189 -0
  27. package/dist/tools/intercept.js +145 -0
  28. package/dist/tools/overrides.js +105 -0
  29. package/dist/tools/provider-hooks.js +224 -0
  30. package/dist/tools/registry.js +246 -17
  31. package/dist/tools.js +44 -0
  32. package/dist/ui/palette.js +1 -1
  33. package/dist/ui/status-bar.js +80 -5
  34. package/dist/zen.js +305 -75
  35. package/documentation/architecture.md +114 -0
  36. package/documentation/cli.md +82 -0
  37. package/documentation/compaction.md +50 -0
  38. package/documentation/configuration.md +111 -0
  39. package/documentation/development.md +62 -0
  40. package/documentation/extensions.md +160 -0
  41. package/documentation/getting-started.md +63 -0
  42. package/documentation/goals.md +41 -0
  43. package/documentation/index.md +41 -0
  44. package/documentation/observability.md +70 -0
  45. package/documentation/permissions.md +66 -0
  46. package/documentation/providers.md +78 -0
  47. package/documentation/sessions.md +92 -0
  48. package/documentation/skills.md +57 -0
  49. package/documentation/tools.md +94 -0
  50. package/documentation/troubleshooting.md +54 -0
  51. package/examples/extensions/01-audit-gate.js +24 -0
  52. package/examples/extensions/02-notes-tool.js +32 -0
  53. package/examples/extensions/03-custom-command.js +32 -0
  54. package/package.json +6 -2
package/dist/goal.js ADDED
@@ -0,0 +1,583 @@
1
+ import { toolSignature } from "./agent/normalize.js";
2
+ // Declared-unverifiable checks on a `complete` report (ticket 06): checks
3
+ // the model could not run, recorded openly in the closing verdict — never a
4
+ // gate (they do not block completion). Bounded so a report stays a short
5
+ // transcript line, never a pasted log.
6
+ export const GOAL_UNVERIFIED_MAX_ITEMS = 10;
7
+ export const GOAL_UNVERIFIED_MAX_LENGTH = 200;
8
+ // Expected update_goal shape for validation details (mirrors the registry's
9
+ // expectedShape framing — the tool definition itself lives in src/tools.ts).
10
+ const UPDATE_GOAL_EXPECTED = `{"status": "continue" | "complete" | "blocked", "next"?: string, "reason"?: string, "unverified"?: string[]}`;
11
+ // Arg validation for update_goal (same detail-string contract as the
12
+ // registry validators — the loop wraps it with invalidCall, so bad args are
13
+ // a model mistake that records nothing). `continue` takes an optional
14
+ // non-empty next action; `complete`/`blocked` require a non-empty reason.
15
+ // `complete` also takes an optional `unverified` list of checks the model
16
+ // could not run (non-empty strings, capped in count and length — recorded
17
+ // openly in the verdict, never a gate). Unknown fields are ignored (same
18
+ // leniency as the registry validators).
19
+ export function validateUpdateGoalArgs(args) {
20
+ if (typeof args !== "object" || args === null || Array.isArray(args)) {
21
+ return `arguments for tool "update_goal" must be an object. Expected ${UPDATE_GOAL_EXPECTED}`;
22
+ }
23
+ const a = args;
24
+ const status = a["status"];
25
+ if (status !== "continue" && status !== "complete" && status !== "blocked") {
26
+ return (`field "status" for tool "update_goal" must be one of "continue", "complete", "blocked" ` +
27
+ `(got ${JSON.stringify(status) ?? String(status)}). Expected ${UPDATE_GOAL_EXPECTED}`);
28
+ }
29
+ if (status === "continue") {
30
+ const next = a["next"];
31
+ if (next !== undefined && (typeof next !== "string" || next.trim().length === 0)) {
32
+ return (`field "next" for tool "update_goal" must be a non-empty string when present ` +
33
+ `(got ${JSON.stringify(next) ?? String(next)}). Expected ${UPDATE_GOAL_EXPECTED}`);
34
+ }
35
+ return null;
36
+ }
37
+ const reason = a["reason"];
38
+ if (typeof reason !== "string" || reason.trim().length === 0) {
39
+ return (`field "reason" for tool "update_goal" with status "${status}" must be a non-empty string. ` +
40
+ `Expected ${UPDATE_GOAL_EXPECTED}`);
41
+ }
42
+ if (status === "complete") {
43
+ const unverified = a["unverified"];
44
+ if (unverified !== undefined) {
45
+ if (!Array.isArray(unverified)) {
46
+ return (`field "unverified" for tool "update_goal" must be an array of non-empty strings ` +
47
+ `when present. Expected ${UPDATE_GOAL_EXPECTED}`);
48
+ }
49
+ if (unverified.length > GOAL_UNVERIFIED_MAX_ITEMS) {
50
+ return (`field "unverified" for tool "update_goal" must hold at most ` +
51
+ `${GOAL_UNVERIFIED_MAX_ITEMS} items (got ${unverified.length}). Expected ${UPDATE_GOAL_EXPECTED}`);
52
+ }
53
+ for (const item of unverified) {
54
+ if (typeof item !== "string" || item.trim().length === 0) {
55
+ return (`field "unverified" for tool "update_goal" must be an array of non-empty strings. ` +
56
+ `Expected ${UPDATE_GOAL_EXPECTED}`);
57
+ }
58
+ if (item.trim().length > GOAL_UNVERIFIED_MAX_LENGTH) {
59
+ return (`field "unverified" for tool "update_goal" must hold items of at most ` +
60
+ `${GOAL_UNVERIFIED_MAX_LENGTH} characters. Expected ${UPDATE_GOAL_EXPECTED}`);
61
+ }
62
+ }
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+ // Build the typed disposition from validated args (call only after
68
+ // validateUpdateGoalArgs returns null — anything else is a caller bug, and
69
+ // the unknown-status fallthrough below keeps it total rather than throwing).
70
+ export function updateGoalDisposition(args) {
71
+ const a = args;
72
+ if (a.status === "continue") {
73
+ return typeof a.next === "string" ? { status: "continue", next: a.next } : { status: "continue" };
74
+ }
75
+ if (a.status === "complete") {
76
+ const reason = a.reason;
77
+ // Defensive carry: validation already capped count/length, but the
78
+ // builder stays total on unvalidated input (judge verdicts, direct
79
+ // callers) — trim, drop empties, cap, and omit when nothing remains.
80
+ const raw = a.unverified;
81
+ if (Array.isArray(raw)) {
82
+ const items = raw
83
+ .filter((s) => typeof s === "string")
84
+ .map((s) => s.trim())
85
+ .filter((s) => s.length > 0)
86
+ .slice(0, GOAL_UNVERIFIED_MAX_ITEMS);
87
+ if (items.length > 0)
88
+ return { status: "complete", reason, unverified: items };
89
+ }
90
+ return { status: "complete", reason };
91
+ }
92
+ if (a.status === "blocked") {
93
+ return { status: "blocked", reason: a.reason };
94
+ }
95
+ return { status: "continue" };
96
+ }
97
+ // Same-value comparison for idempotence (absent next counts as empty —
98
+ // `{continue}` and `{continue, next:""}` can never both be valid anyway).
99
+ export function sameGoalDisposition(a, b) {
100
+ if (a.status === "continue" && b.status === "continue") {
101
+ return (a.next ?? "") === (b.next ?? "");
102
+ }
103
+ if (a.status === "complete" && b.status === "complete") {
104
+ if (a.reason !== b.reason)
105
+ return false;
106
+ const au = a.unverified ?? [];
107
+ const bu = b.unverified ?? [];
108
+ return au.length === bu.length && au.every((s, i) => s === bu[i]);
109
+ }
110
+ if (a.status === "blocked" && b.status === "blocked") {
111
+ return a.reason === b.reason;
112
+ }
113
+ return false;
114
+ }
115
+ // Terminal verdict (ticket 03): the run's final text AND the pause notice —
116
+ // same `(…)` voice as the stop notices, carrying the model's reason. A
117
+ // `complete` may also carry declared-unverifiable checks (ticket 06): they
118
+ // print openly as a trailing `(unverified: …)` segment — recorded, never
119
+ // gated. Total: unvalidated input degrades to the bare verdict.
120
+ export function goalVerdictNotice(objective, status, reason, unverified) {
121
+ const base = `(goal ${status} — "${objective}": ${reason})`;
122
+ if (status !== "complete" || !Array.isArray(unverified))
123
+ return base;
124
+ const items = unverified
125
+ .filter((s) => typeof s === "string")
126
+ .map((s) => s.trim())
127
+ .filter((s) => s.length > 0)
128
+ .slice(0, GOAL_UNVERIFIED_MAX_ITEMS);
129
+ if (items.length === 0)
130
+ return base;
131
+ return `${base} (unverified: ${items.join(", ")})`;
132
+ }
133
+ // Success ack for a recorded report (a transcript-visible receipt —
134
+ // deliberately not an Error, so it never trips failure accounting).
135
+ export function goalReportAck(disposition) {
136
+ switch (disposition.status) {
137
+ case "continue":
138
+ return disposition.next !== undefined
139
+ ? `(goal report recorded — "continue": ${disposition.next})`
140
+ : `(goal report recorded — "continue")`;
141
+ case "complete":
142
+ return `(goal report recorded — "complete": ${disposition.reason})`;
143
+ case "blocked":
144
+ return `(goal report recorded — "blocked": ${disposition.reason})`;
145
+ }
146
+ }
147
+ // Post-terminal rejection (ticket 03): the first terminal report sticks —
148
+ // anything after it changes nothing and reads as a notice (not an Error,
149
+ // so failure accounting stays quiet).
150
+ export function goalReportRejectedNotice(existing) {
151
+ return (`(update_goal already reported "${existing.status}" for this turn — ` +
152
+ `keeping the first report; further reports change nothing)`);
153
+ }
154
+ // Outside-goal structured error (ticket 03): with no live goal turn there is
155
+ // nothing to record into, so the call changes zero state — the Error:
156
+ // framing keeps it repairable, never silent.
157
+ export function goalReportOutsideError() {
158
+ return (`Error: update_goal is only available during an active goal turn ` +
159
+ `(no active goal — set one with /goal <objective>). Nothing was recorded.`);
160
+ }
161
+ // Pure arg parser: bare `/goal` (or whitespace-only) shows status,
162
+ // `/goal clear` / `/goal pause` / `/goal resume` (case-insensitive) manage
163
+ // the goal, everything else after `/goal ` is the objective verbatim (case
164
+ // preserved — it echoes back). A literal objective of "pause"/"resume"/
165
+ // "clear" reads as the command (same pre-existing ambiguity as "clear").
166
+ export function parseGoalCommand(raw) {
167
+ const text = raw.trim();
168
+ const rest = text === "/goal" ? "" : text.slice("/goal".length).trim();
169
+ if (rest === "")
170
+ return { kind: "status" };
171
+ const lowered = rest.toLowerCase();
172
+ if (lowered === "clear")
173
+ return { kind: "clear" };
174
+ if (lowered === "pause")
175
+ return { kind: "pause" };
176
+ if (lowered === "resume")
177
+ return { kind: "resume" };
178
+ return { kind: "set", objective: rest };
179
+ }
180
+ // Zero stats for a fresh goal (a set/replace always resets the counters).
181
+ export function emptyGoalStats() {
182
+ return { turns: 0, requests: 0, tokens: 0, workMs: 0 };
183
+ }
184
+ // Read view: absent stats (ticket-01 literals) count as zeros — never throw.
185
+ export function goalStatsOf(goal) {
186
+ const s = goal?.stats;
187
+ return {
188
+ turns: s?.turns ?? 0,
189
+ requests: s?.requests ?? 0,
190
+ tokens: s?.tokens ?? 0,
191
+ workMs: s?.workMs ?? 0,
192
+ };
193
+ }
194
+ // Token slice for one reported usage payload: total_tokens when the API
195
+ // sent it, else prompt + completion. Anything unreported counts as zero —
196
+ // goal spend, like session spend, is real reports only, never estimated.
197
+ export function goalTokensForUsage(u) {
198
+ const total = u.total_tokens;
199
+ if (typeof total === "number" && Number.isFinite(total))
200
+ return Math.max(0, Math.floor(total));
201
+ const prompt = typeof u.prompt_tokens === "number" && Number.isFinite(u.prompt_tokens) ? u.prompt_tokens : 0;
202
+ const completion = typeof u.completion_tokens === "number" && Number.isFinite(u.completion_tokens)
203
+ ? u.completion_tokens
204
+ : 0;
205
+ return Math.max(0, Math.floor(prompt) + Math.floor(completion));
206
+ }
207
+ // Serialize the live goal for a session save: a deep copy (the save must
208
+ // never alias live state), or null when no goal is live. Total: never throws.
209
+ export function serializeGoalForPersist(goal) {
210
+ try {
211
+ if (!goal)
212
+ return null;
213
+ if (typeof goal.objective !== "string" || goal.objective.length === 0)
214
+ return null;
215
+ const s = goal.stats ?? emptyGoalStats();
216
+ return {
217
+ objective: goal.objective,
218
+ active: goal.active === true,
219
+ stats: {
220
+ turns: coerceGoalCounter(s.turns),
221
+ requests: coerceGoalCounter(s.requests),
222
+ tokens: coerceGoalCounter(s.tokens),
223
+ workMs: coerceGoalCounter(s.workMs),
224
+ },
225
+ };
226
+ }
227
+ catch {
228
+ return null;
229
+ }
230
+ }
231
+ // Restore a saved goal: valid records come back verbatim (counters intact,
232
+ // never reset); absent or corrupt data loads as no-goal (null) — never a
233
+ // throw, at most the caller's warning. Old saves without a goal key and
234
+ // records with a trashed stats block both land here safely.
235
+ export function restoreGoalFromPersist(value) {
236
+ try {
237
+ if (value === null || value === undefined)
238
+ return null;
239
+ if (typeof value !== "object" || Array.isArray(value))
240
+ return null;
241
+ const r = value;
242
+ if (typeof r["objective"] !== "string" || r["objective"].length === 0)
243
+ return null;
244
+ if (typeof r["active"] !== "boolean")
245
+ return null;
246
+ const stats = r["stats"];
247
+ if (stats === undefined)
248
+ return { objective: r["objective"], active: r["active"] };
249
+ if (typeof stats !== "object" || stats === null || Array.isArray(stats))
250
+ return null;
251
+ const s = stats;
252
+ return {
253
+ objective: r["objective"],
254
+ active: r["active"],
255
+ stats: {
256
+ turns: coerceGoalCounter(s["turns"]),
257
+ requests: coerceGoalCounter(s["requests"]),
258
+ tokens: coerceGoalCounter(s["tokens"]),
259
+ workMs: coerceGoalCounter(s["workMs"]),
260
+ },
261
+ };
262
+ }
263
+ catch {
264
+ return null;
265
+ }
266
+ }
267
+ // One counter from untrusted save data: finite, floored, never negative —
268
+ // anything else reads as zero (a trashed counter must not trash the goal).
269
+ function coerceGoalCounter(value) {
270
+ return typeof value === "number" && Number.isFinite(value) && value > 0
271
+ ? Math.floor(value)
272
+ : 0;
273
+ }
274
+ // Compact counters for status lines: raw below 1K, one-decimal K above.
275
+ export function formatGoalTokens(n) {
276
+ if (typeof n !== "number" || !Number.isFinite(n) || n <= 0)
277
+ return "0";
278
+ const v = Math.floor(n);
279
+ if (v < 1000)
280
+ return `${v}`;
281
+ return `${(v / 1000).toFixed(1)}K`;
282
+ }
283
+ // Wall-clock rendering for status lines: 4s / 1m23s / 2h05m. Never throws.
284
+ export function formatGoalWorkMs(ms) {
285
+ if (typeof ms !== "number" || !Number.isFinite(ms) || ms <= 0)
286
+ return "0s";
287
+ const s = Math.floor(ms / 1000);
288
+ if (s < 60)
289
+ return `${s}s`;
290
+ const m = Math.floor(s / 60);
291
+ if (m < 60)
292
+ return `${m}m${s % 60}s`;
293
+ return `${Math.floor(m / 60)}h${m % 60}m`;
294
+ }
295
+ // Stats segment for the status line (zeros when absent — a fresh goal still
296
+ // shows the shape so the counters are discoverable).
297
+ export function goalStatsText(stats) {
298
+ const s = stats ?? emptyGoalStats();
299
+ return (`turns ${s.turns} · requests ${s.requests} · ` +
300
+ `tokens ${formatGoalTokens(s.tokens)} · work ${formatGoalWorkMs(s.workMs)}`);
301
+ }
302
+ // Status line: goal text plus state plus cumulative stats, or the none-hint.
303
+ export function goalStatusText(goal) {
304
+ if (!goal)
305
+ return "(no goal — set one with /goal <objective>)";
306
+ return `(goal [${goal.active ? "active" : "paused"}] — ${goal.objective} — ${goalStatsText(goal.stats)})`;
307
+ }
308
+ // Set confirmation: echoes the objective; replacing an active goal says so.
309
+ export function goalSetNotice(objective, previous) {
310
+ if (previous)
311
+ return `(goal replaced — "${previous.objective}" replaced with "${objective}")`;
312
+ return `(goal set — "${objective}")`;
313
+ }
314
+ // Clear notice: ends the active goal, or a harmless no-op when absent.
315
+ export function goalClearNotice(previous) {
316
+ if (!previous)
317
+ return "(no goal — nothing to clear)";
318
+ return `(goal cleared — "${previous.objective}")`;
319
+ }
320
+ // Manual pause/resume notices: state flips live in App; these only describe.
321
+ export function goalPauseNotice(previous) {
322
+ if (!previous)
323
+ return "(no goal — nothing to pause)";
324
+ if (!previous.active)
325
+ return `(goal already paused — "${previous.objective}")`;
326
+ return `(goal paused — "${previous.objective}")`;
327
+ }
328
+ export function goalResumeNotice(previous) {
329
+ if (!previous)
330
+ return "(no goal — set one with /goal <objective>)";
331
+ if (previous.active)
332
+ return `(goal already active — "${previous.objective}")`;
333
+ return `(goal resumed — "${previous.objective}")`;
334
+ }
335
+ // Auto-continue follow-up (tickets 02–03): the ONLY continuation message
336
+ // the goal seam pushes — same assistant+user commit shape as the turn-end
337
+ // guards, so assistant/tool pairing stays valid and the transcript shows
338
+ // each turn normally (no synthetic user input beyond this mechanism). It
339
+ // also carries the report protocol (the only model-visible channel for the
340
+ // goal-scoped update_goal tool — the tool-schema payload wiring arrives in
341
+ // a later ticket): the model reports each turn's outcome, and a turn with
342
+ // no report counts as `continue` until the ticket-04 evaluator lands.
343
+ export function goalFollowUp(objective) {
344
+ return (`(goal continues: "${objective}" — keep working toward the goal with ` +
345
+ `tool calls, or answer in text when there is nothing left to do. ` +
346
+ `Final text starts the next goal turn automatically; the goal runs ` +
347
+ `until it is paused or cleared. Report the turn outcome with the ` +
348
+ `update_goal tool (status "continue" with the next action, or ` +
349
+ `"complete"/"blocked" with a reason); a turn with no report continues ` +
350
+ `the goal.)`);
351
+ }
352
+ // Pause-with-preservation notice for cancel/budget paths: names the reason,
353
+ // keeps the objective quoted, and points at /goal resume. The goal itself
354
+ // is never cleared here — pausing only flips `active`.
355
+ export function goalPausedNotice(objective, reason) {
356
+ return `(goal paused — "${objective}" preserved ${reason}; resume with /goal resume)`;
357
+ }
358
+ // Judge tail (ticket 04): the evaluator sees the goal text plus the recent
359
+ // turns only — never the full history. 20 messages covers a few turns of
360
+ // tool traffic without bloating the judge POST.
361
+ export const GOAL_JUDGE_TAIL_MESSAGES = 20;
362
+ // Slice the recent transcript tail for the judge (read-only — the caller
363
+ // shares the message objects, and the judge must never mutate them).
364
+ export function recentTurnsForJudge(history) {
365
+ if (!Array.isArray(history))
366
+ return [];
367
+ return history.slice(-GOAL_JUDGE_TAIL_MESSAGES);
368
+ }
369
+ // Strict-but-tolerant judge-verdict parsing (ticket 04): the judge must
370
+ // answer with one JSON verdict object; anything else (prose, empty text, a
371
+ // bad shape, failed validation) counts as unclear → null → the loop pauses
372
+ // instead of looping. Code fences are tolerated (the slice runs from the
373
+ // first `{` to the last `}`); single-object only. An empty `next` on
374
+ // `continue` reads as absent (still a clear verdict — the generic follow-up
375
+ // covers it); everything else validates exactly like update_goal args.
376
+ export function parseGoalJudgeVerdict(text) {
377
+ if (typeof text !== "string")
378
+ return null;
379
+ const start = text.indexOf("{");
380
+ const end = text.lastIndexOf("}");
381
+ const candidate = start >= 0 && end > start ? text.slice(start, end + 1) : text;
382
+ let parsed;
383
+ try {
384
+ parsed = JSON.parse(candidate);
385
+ }
386
+ catch {
387
+ return null;
388
+ }
389
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
390
+ return null;
391
+ const args = { ...parsed };
392
+ if (args["status"] === "continue" &&
393
+ typeof args["next"] === "string" &&
394
+ args["next"].trim().length === 0) {
395
+ delete args["next"];
396
+ }
397
+ if (validateUpdateGoalArgs(args) !== null)
398
+ return null;
399
+ const disposition = updateGoalDisposition(args);
400
+ // Trim payloads so a padded verdict prints clean (validation already
401
+ // guaranteed the required fields are non-empty).
402
+ if (disposition.status === "continue" && typeof disposition.next === "string") {
403
+ return { status: "continue", next: disposition.next.trim() };
404
+ }
405
+ if (disposition.status === "complete" || disposition.status === "blocked") {
406
+ return { status: disposition.status, reason: disposition.reason.trim() };
407
+ }
408
+ return disposition;
409
+ }
410
+ // Novelty progress guard (ticket 05): the goal measures real forward motion,
411
+ // not motion. Every committed tool result fingerprints to name + stable args
412
+ // + result; an exact repeat (fingerprint already seen) advances nothing,
413
+ // while genuinely new evidence bumps the novel count and resets the stall
414
+ // streak. Error results never count — the error-streak machinery owns those.
415
+ // When the streak reaches GOAL_STALL_REPEATS at a goal turn end, the loop
416
+ // pushes the replan nudge as the next follow-up and resets the epoch —
417
+ // stalling never pauses, clears, or ends the goal. Loop-local only: a whole
418
+ // goal run normally lives inside one runLoopWithChat call, so the seen-set
419
+ // never leaves the turn (a later ticket can expose it if App/telemetry ever
420
+ // needs it).
421
+ // Consecutive non-novel commits before the replan nudge fires: 3. One repeat
422
+ // is often a legit retry (truncation repair, parallel re-fetch) and two can
423
+ // be coincidence; three identical results is a loop. It also mirrors the
424
+ // other turn-end guards (MAX_VERIFY_ROUNDS / MAX_TODO_ROUNDS = 3, error-streak
425
+ // default 3), so every guard agrees on what "sustained" means.
426
+ export const GOAL_STALL_REPEATS = 3;
427
+ // Upper bound on retained fingerprints: runs are budget-bounded, but the
428
+ // default budgets are uncapped, so the set never grows without limit. Past
429
+ // the cap an unseen key still counts as novel (the safe direction — progress
430
+ // over stall) without being stored.
431
+ export const GOAL_PROGRESS_SEEN_CAP = 5000;
432
+ // Fresh per-run progress memory (the loop owns one per runLoopWithChat call).
433
+ export function emptyGoalProgress() {
434
+ return { seen: new Set(), novel: 0, stale: 0 };
435
+ }
436
+ // FNV-1a (32-bit, non-crypto): small, dependency-free, and total — never
437
+ // throws, so accounting can never break the turn.
438
+ function goalFingerprintHash(text) {
439
+ let hash = 0x811c9dc5;
440
+ for (let i = 0; i < text.length; i++) {
441
+ hash ^= text.charCodeAt(i);
442
+ hash = Math.imul(hash, 0x01000193);
443
+ }
444
+ return (hash >>> 0).toString(16);
445
+ }
446
+ // Bounded novelty key: name + stable args (via toolSignature, so key order
447
+ // never aliases) + full result, hashed — the set keeps ~40 chars per entry,
448
+ // never result text. Total: never throws (unstringifiable args fall back
449
+ // inside toolSignature; anything else degrades to a length-only key).
450
+ export function goalProgressFingerprint(name, parsed, result) {
451
+ const tool = typeof name === "string" ? name : "(unknown)";
452
+ const body = typeof result === "string" ? result : "";
453
+ let argsKey;
454
+ try {
455
+ argsKey = toolSignature(tool, parsed ?? {});
456
+ }
457
+ catch {
458
+ argsKey = `${tool} {}`;
459
+ }
460
+ return `${tool}#${goalFingerprintHash(`${argsKey}\n${body}`)}:${body.length}`;
461
+ }
462
+ // Record one committed result: errors are ignored (owned by the error-streak
463
+ // machinery — they neither advance nor stall progress); a repeat bumps the
464
+ // streak; anything new bumps the novel count and resets the streak. Returns
465
+ // true when the commit advanced progress. Total: never throws.
466
+ export function noteGoalProgress(state, name, parsed, result, isError) {
467
+ try {
468
+ if (!state || isError)
469
+ return false;
470
+ const key = goalProgressFingerprint(name, parsed, result);
471
+ if (state.seen.has(key)) {
472
+ state.stale += 1;
473
+ return false;
474
+ }
475
+ if (state.seen.size < GOAL_PROGRESS_SEEN_CAP)
476
+ state.seen.add(key);
477
+ state.novel += 1;
478
+ state.stale = 0;
479
+ return true;
480
+ }
481
+ catch {
482
+ return false;
483
+ }
484
+ }
485
+ // The stall redirect fires when the streak reaches the threshold (never
486
+ // earlier — a partial streak is still a working run). Total: never throws.
487
+ export function goalStallReached(state) {
488
+ try {
489
+ return (state?.stale ?? 0) >= GOAL_STALL_REPEATS;
490
+ }
491
+ catch {
492
+ return false;
493
+ }
494
+ }
495
+ // Reset the stall epoch after the nudge fires (the seen-set stays — continued
496
+ // repeats keep reading as repeats, so a stuck run nudges again instead of
497
+ // going quiet). Total: never throws.
498
+ export function resetGoalStall(state) {
499
+ try {
500
+ if (state)
501
+ state.stale = 0;
502
+ }
503
+ catch {
504
+ // accounting never breaks the turn
505
+ }
506
+ }
507
+ // Replan nudge (ticket 05): the stall-epoch follow-up — same `(…)` notice
508
+ // voice as the other turn-end gates, naming the objective and the repeat
509
+ // count so the redirect is auditable in the transcript. Total: never throws.
510
+ export function goalStallNudge(objective, repeats) {
511
+ try {
512
+ const goal = typeof objective === "string" && objective.length > 0 ? objective : "(unknown)";
513
+ const count = typeof repeats === "number" && Number.isFinite(repeats) && repeats > 0
514
+ ? Math.floor(repeats)
515
+ : GOAL_STALL_REPEATS;
516
+ return (`(goal stalled — "${goal}": the last ${count} tool results repeated earlier work ` +
517
+ `with nothing new. Replan: try a different approach, file, or check instead of ` +
518
+ `repeating the same calls. The goal stays active — keep working toward it.)`);
519
+ }
520
+ catch {
521
+ return `(goal stalled — replanning needed; the goal stays active.)`;
522
+ }
523
+ }
524
+ // Compacted-summary goal block (ticket 08): the canonical `Goal:` text that
525
+ // rides inside the compaction summary, following the `Touched files:`
526
+ // precedent — appended to the model summary within budget, surfaced verbatim
527
+ // on resume. It carries the objective verbatim (never trimmed — user text),
528
+ // the live state flag (active vs paused — a paused goal still resumes), and
529
+ // the cumulative stats (never reset by compaction), plus the open checklist
530
+ // lines (pending/in_progress only — completed work belongs in the summary
531
+ // prose the instruction already asks the summarizer to preserve). The block
532
+ // is the model's context backstop only: record restore (ticket 07) stays the
533
+ // restore path, so nothing here needs machine-parsing — it just has to read
534
+ // unambiguously. Total: never throws; no goal renders "" (caller appends
535
+ // nothing, keeping non-goal output byte-identical).
536
+ // Upper bounds for the checklist tail: the block rides with the model text
537
+ // (never shrunk by the budget fitter — only the touched-files lists shrink),
538
+ // so it stays bounded on its own. 10 lines mirrors
539
+ // GOAL_UNVERIFIED_MAX_ITEMS; 120 chars keeps one line scannable.
540
+ export const GOAL_COMPACT_TODOS_MAX = 10;
541
+ export const GOAL_COMPACT_TODO_CHARS = 120;
542
+ export function formatGoalForCompact(goal, todos) {
543
+ try {
544
+ if (!goal)
545
+ return "";
546
+ if (typeof goal.objective !== "string" || goal.objective.length === 0)
547
+ return "";
548
+ const lines = [
549
+ `Goal: "${goal.objective}" [${goal.active ? "active" : "paused"}] (${goalStatsText(goal.stats)})`,
550
+ ];
551
+ if (Array.isArray(todos)) {
552
+ const open = [];
553
+ for (const t of todos) {
554
+ if (open.length >= GOAL_COMPACT_TODOS_MAX)
555
+ break;
556
+ if (!t || typeof t.content !== "string" || typeof t.status !== "string")
557
+ continue;
558
+ if (t.status === "completed")
559
+ continue;
560
+ const content = t.content.trim();
561
+ if (content.length === 0)
562
+ continue;
563
+ const short = content.length > GOAL_COMPACT_TODO_CHARS
564
+ ? `${content.slice(0, GOAL_COMPACT_TODO_CHARS)}…`
565
+ : content;
566
+ open.push(`[${t.status}] ${short}`);
567
+ }
568
+ if (open.length > 0)
569
+ lines.push(`Goal todos: ${open.join("; ")}`);
570
+ }
571
+ return lines.join("\n");
572
+ }
573
+ catch {
574
+ return "";
575
+ }
576
+ }
577
+ // Append the goal block to a summary (mirrors appendTouchedFiles — empty
578
+ // renders the summary untouched).
579
+ export function appendGoalBlock(summaryText, goalBlock) {
580
+ if (typeof goalBlock !== "string" || goalBlock.length === 0)
581
+ return summaryText;
582
+ return `${summaryText}\n\n${goalBlock}`;
583
+ }
@@ -0,0 +1,96 @@
1
+ // Project trust for extensions (ticket 07): project-scope extensions run
2
+ // unsandboxed with full user privileges, so they never execute until the
3
+ // user trusts the project. Global-scope extensions are user-owned
4
+ // (implicitly trusted, like the user's own config); the gate applies to
5
+ // project-scope + explicit paths only.
6
+ //
7
+ // The grant is durable per project directory (asked once): grants live in
8
+ // ~/.atom/trusted-projects.json (a plain JSON array of absolute dir paths,
9
+ // following the auth.json load-never-throws pattern). A decline persists
10
+ // nothing, so the next boot asks again — declining leaves project extensions
11
+ // fully inert for this session with a visible notice, never silently loaded.
12
+ // Session tool trust (/trust in App.tsx) is a separate, in-memory tier for
13
+ // write/edit/bash approval — this file is only about extension loading.
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import * as path from "node:path";
16
+ import { atomDir } from "./auth.js";
17
+ export const TRUSTED_PROJECTS_FILENAME = "trusted-projects.json";
18
+ // Canonical trust prompt (single source so App and tests share the exact
19
+ // wording): states plainly that extensions run unsandboxed with full user
20
+ // privileges. The acceptance criterion pins on these words.
21
+ export function projectTrustQuestion(names) {
22
+ const list = names.length > 0 ? ` (${names.join(", ")})` : "";
23
+ return (`This project contains ${names.length} extension(s)${list} that run unsandboxed ` +
24
+ `with your full user privileges — they can read/write your files and run commands as you. ` +
25
+ `Load them?`);
26
+ }
27
+ export function trustedProjectsFilePath(home) {
28
+ return path.join(atomDir(home), TRUSTED_PROJECTS_FILENAME);
29
+ }
30
+ function normalizeDir(dir) {
31
+ return path.resolve(dir);
32
+ }
33
+ // Load granted project dirs; missing/corrupt files yield [] (never throws —
34
+ // a missing grant file is the normal first-run case).
35
+ export function loadTrustedProjects(home) {
36
+ let raw;
37
+ try {
38
+ const p = trustedProjectsFilePath(home);
39
+ if (!existsSync(p))
40
+ return [];
41
+ raw = readFileSync(p, "utf8");
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ let data;
47
+ try {
48
+ data = JSON.parse(raw);
49
+ }
50
+ catch {
51
+ return [];
52
+ }
53
+ if (!Array.isArray(data))
54
+ return [];
55
+ const out = [];
56
+ for (const e of data) {
57
+ if (typeof e === "string" && e.length > 0) {
58
+ const abs = normalizeDir(e);
59
+ if (!out.includes(abs))
60
+ out.push(abs);
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+ export function isProjectTrusted(dir, home) {
66
+ return loadTrustedProjects(home).includes(normalizeDir(dir));
67
+ }
68
+ function saveTrustedProjects(dirs, home) {
69
+ const dir = atomDir(home);
70
+ mkdirSync(dir, { recursive: true });
71
+ writeFileSync(path.join(dir, TRUSTED_PROJECTS_FILENAME), JSON.stringify([...dirs].sort(), null, 2) + "\n", "utf8");
72
+ }
73
+ // Persist a grant (never throws — disk errors leave trust ungranted, so the
74
+ // next boot asks again rather than assuming consent).
75
+ export function grantProjectTrust(dir, home) {
76
+ try {
77
+ const abs = normalizeDir(dir);
78
+ const current = loadTrustedProjects(home);
79
+ if (current.includes(abs))
80
+ return;
81
+ saveTrustedProjects([...current, abs], home);
82
+ }
83
+ catch {
84
+ // best-effort only; trust stays ungranted
85
+ }
86
+ }
87
+ // Remove a grant (never throws).
88
+ export function revokeProjectTrust(dir, home) {
89
+ try {
90
+ const abs = normalizeDir(dir);
91
+ saveTrustedProjects(loadTrustedProjects(home).filter((d) => d !== abs), home);
92
+ }
93
+ catch {
94
+ // best-effort only
95
+ }
96
+ }