@agentwhy/cli 0.3.0 → 0.3.1

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 (33) hide show
  1. package/dist/adapter/codex/contract/assumptions.js +4 -4
  2. package/dist/adapter/codex/contract/deliveries.js +12 -0
  3. package/dist/adapter/codex/contract/messages.js +8 -1
  4. package/dist/adapter/codex/events/cell-commands.js +191 -0
  5. package/dist/adapter/codex/events/delivered-output.js +30 -0
  6. package/dist/adapter/codex/events/rollout-scan.js +99 -7
  7. package/dist/core/access/command-line.js +4 -1
  8. package/dist/core/access/listing.js +10 -3
  9. package/dist/core/access/protected-access.js +94 -1
  10. package/dist/core/access/recorded-effect.js +22 -1
  11. package/dist/report/build-report.js +125 -19
  12. package/dist/report/render/report-page/file-story.js +33 -2
  13. package/dist/report/render/report-page/files-view.js +176 -55
  14. package/dist/report/render/report-page/files.js +51 -8
  15. package/dist/report/render/report-page/fix-wizard-script.js +17 -7
  16. package/dist/report/render/report-page/fix-wizard.js +4 -1
  17. package/dist/report/render/report-page/helpers.js +2 -1
  18. package/dist/report/render/report-page/report-page-renderer.js +51 -12
  19. package/dist/report/render/report-page/story-window.js +33 -12
  20. package/dist/report/render/report-page/to-do-view.js +42 -10
  21. package/dist/report/render/ui/app-sidebar.js +31 -2
  22. package/dist/report/render/ui/confirm-dialog.js +69 -4
  23. package/dist/report/render/ui/words/app-words.js +6 -0
  24. package/dist/report/render/ui/words/report-words.js +114 -12
  25. package/dist/report/render/ui/words/settings-words.js +12 -9
  26. package/dist/report/start/conversations/calendar-view.js +1 -1
  27. package/dist/report/start/conversations/conversation-columns.js +6 -4
  28. package/dist/report/start/conversations/period-section.js +11 -10
  29. package/dist/report/start/conversations/periods.js +7 -1
  30. package/dist/report/start/render/session-status.js +14 -9
  31. package/dist/report/start/session-start.js +6 -3
  32. package/dist/report/start/settings/alerts-tab.js +2 -1
  33. package/package.json +1 -1
@@ -1,7 +1,8 @@
1
1
  import { matchesGlob } from '../policy/glob.js';
2
2
  import { protectionOf } from '../policy/policy.js';
3
- import { commandPathCandidates, fileOperandsIn, printsContentBesideNames, printsContentOnly } from './command-line.js';
3
+ import { commandPathCandidates, fileOperandsIn, printsContentBesideNames, printsContentOnly, simpleCommandsIn } from './command-line.js';
4
4
  import { listingPathCandidates } from './listing.js';
5
+ import { shapedLikePath } from './path-shape.js';
5
6
  import { pathTokens, stringsIn } from './path-tokens.js';
6
7
  import { outputIsClean, outputShowsReach } from './recorded-effect.js';
7
8
  import { hitLines } from './search-output.js';
@@ -74,6 +75,8 @@ function targetOutcome(event, printed, shown) {
74
75
  }
75
76
  /** A search hit with its line number: `path:12:text`. A diagnostic `path: message` has none. */
76
77
  const NUMBERED_HIT = /^[^:]+:\d+:/;
78
+ /** A hit of a search of one file, numbered and with no name: `12:text`. */
79
+ const NUMBERED_LINE = /^\d+:/;
77
80
  /** How many lines of each file a succeeded search printed (H1, H2, H12): `printedLines`, counted. */
78
81
  export function linesPrinted(event, named) {
79
82
  return new Map([...printedLines(event, named)].map(([path, lines]) => [path, lines.length]));
@@ -95,6 +98,16 @@ export function printedLines(event, named) {
95
98
  const numbered = stringsIn(event.result?.content).join('\n').split('\n').filter((line) => NUMBERED_HIT.test(line)).join('\n');
96
99
  for (const hit of hitLines(numbered, false))
97
100
  printed.set(hit.path, [...(printed.get(hit.path) ?? []), hit.text]);
101
+ // H3 where the exit is not known clean (XD4: a cell records none): a numbered search of the one protected file the
102
+ // call named prints `12:text` for each hit, and no diagnostic opens with a line number - `rg: x: No such file` names
103
+ // its program first. Those lines are that file's, and nothing else of the output is.
104
+ const one = [...new Set(named)];
105
+ if (printed.size === 0 && search.numbered === true && search.names !== 'always' && one.length === 1) {
106
+ const lines = stringsIn(event.result?.content).join('\n').split('\n').filter((line) => NUMBERED_LINE.test(line))
107
+ .map((line) => line.replace(NUMBERED_LINE, ''));
108
+ if (lines.length > 0)
109
+ printed.set(one[0], lines);
110
+ }
98
111
  return printed;
99
112
  }
100
113
  const output = stringsIn(event.result?.content).join('\n');
@@ -119,6 +132,86 @@ export function printedLines(event, named) {
119
132
  }
120
133
  return printed;
121
134
  }
135
+ /** A word that names a file by its own shape: a name with an extension or a leading dot, or a path with a folder in it. */
136
+ const NAMES_A_FILE = /[^.]\.[^.]|^\.[^./]|\//;
137
+ /** A count given to an option: `head -n 2 f.csv` reads one file, not a file called `2`. */
138
+ const COUNT = /^\d+$/;
139
+ /** What a pattern or a script holds and a file name does not: `sed 's#^./##'` names no file. */
140
+ const NOT_A_NAME = /[#^$\\|<>!&;]/;
141
+ /**
142
+ * Folders every tool that makes them makes as folders, named like a file with a leading dot. A listing that does not say
143
+ * which is which - `find . -maxdepth 1` - lists them beside `.env` and `.npmrc`, and only the name is left to tell.
144
+ */
145
+ const TOOL_FOLDERS = new Set(['.git', '.claude', '.codex', '.cursor', '.vscode', '.idea', '.github', '.next', '.cache', '.turbo']);
146
+ /**
147
+ * What one call shows of files no protected pattern matches (`the-report-page.md` P32, changed 2026-10-05 by the
148
+ * maintainer: every file of a session is a row, so a person sees what else the AI was among, and may make it private).
149
+ * Read by the rules that find a protected path, so no prose becomes a file:
150
+ *
151
+ * - a word of a shell line shaped like a path is a file the call named; the operand of a program that prints a file,
152
+ * where the call succeeded, one it read;
153
+ * - a search hit's path is a file whose lines came back - read where they are what the model was handed (X10);
154
+ * - a listing's name, by its place: the last field of `ls -l` where its line is no directory's, or a line that is a path
155
+ * whole (`ls -1`, `find`, `rg --files`). A word before a colon is a search hit's place, and outside a search it is a
156
+ * diagnostic's (`ls: x: No such file`), so it names no file here.
157
+ *
158
+ * `.` and `..` are no files. A tool the adapter has no profile for names nothing (R12b).
159
+ */
160
+ export function everydayReach(event, policy) {
161
+ if (!event.toolKnown)
162
+ return [];
163
+ const found = new Map();
164
+ const note = (written, how) => {
165
+ // `./README.md` and `README.md` are one file, and `./app` the bare folder name `app` (2026-10-05, `find | sed`).
166
+ const path = written.replace(/^(?:\.\/)+/, '');
167
+ if (path === '' || path === '.' || path === '..' || TOOL_FOLDERS.has(path) || NOT_A_NAME.test(path))
168
+ return;
169
+ if (!pathLike(path, { allowWhitespace: false }) || protectionOf(policy, path) !== undefined || protectionOf(policy, written) !== undefined)
170
+ return;
171
+ if (found.get(path) !== 'read')
172
+ found.set(path, how);
173
+ };
174
+ const read = event.outcome === 'succeeded' && readsContent(event);
175
+ const operands = new Set(fileOperandsIn(event.commands));
176
+ for (const command of event.commands) {
177
+ // The line's own words - the program and its arguments, never the code an interpreter is handed (`python3 -c "…"`
178
+ // held `csv.DictReader`) - each a file where its place says so or its shape does (found 2026-10-05).
179
+ for (const { program, args } of simpleCommandsIn(command)) {
180
+ for (const word of [program, ...args]) {
181
+ if (word.startsWith('-') || COUNT.test(word))
182
+ continue;
183
+ // A printer's operand is a file by its place; any other word is one where it holds a name's dot or a folder's
184
+ // slash - `README.md`, `app/page.tsx` - and never a program (`ls`), a folder (`app`) or a pattern (`TODO`).
185
+ if (operands.has(word) || NAMES_A_FILE.test(word.replace(/^(?:\.\/)+/, '')))
186
+ note(word, read && operands.has(word) ? 'read' : 'named');
187
+ }
188
+ }
189
+ }
190
+ const content = event.result?.content;
191
+ if (content === undefined || !outputShowsReach(event))
192
+ return [...found].map(([path, how]) => ({ path, how }));
193
+ const output = stringsIn(content).join('\n');
194
+ const search = searchOf(event);
195
+ if (search !== undefined) {
196
+ // A hit's path by its shape too: a cell's `Output:` header reads as a hit on the word before its colon.
197
+ for (const hit of hitLines(output, search.context)) {
198
+ if (NAMES_A_FILE.test(hit.path.replace(/^(?:\.\/)+/, '')))
199
+ note(hit.path, event.result?.stage === 'model' ? 'read' : 'named');
200
+ }
201
+ }
202
+ else if (event.resultShape === 'listing' && !readsContent(event)) {
203
+ for (const raw of output.split('\n')) {
204
+ const line = raw.trim();
205
+ if (line === '')
206
+ continue;
207
+ const name = (listingPathCandidates(line)[0] ?? []).find((candidate) => candidate.listed === 'file' || (candidate.listed === undefined && candidate.text === line && shapedLikePath(line) &&
208
+ NAMES_A_FILE.test(line.replace(/^(?:\.\/)+/, ''))));
209
+ if (name !== undefined)
210
+ note(name.text, 'named');
211
+ }
212
+ }
213
+ return [...found].map(([path, how]) => ({ path, how }));
214
+ }
122
215
  /**
123
216
  * The call is a search that prints what it matched: a shell search read from its command, or a tool that said so. The
124
217
  * Grep tool may or may not name the file on its lines, so that is left to what it printed.
@@ -6,6 +6,14 @@ import { readOptions } from './search-reach.js';
6
6
  * operands here: `grep` exits 1 for a file it read and found nothing in, and a script exits however it likes.
7
7
  */
8
8
  const READS_EVERY_OPERAND = new Set(['cat', 'head', 'tail', 'nl']);
9
+ /**
10
+ * Searches whose exit is documented: 0 where a line matched, 1 where none did, 2 or more where an operand could not be
11
+ * read. With 0 or 1 recorded, each operand was opened and searched, whatever was printed. Found 2026-10-05: a search of a
12
+ * tracked file for a name it does not hold exited 1, and the file was "an attempt with no known end". What a search
13
+ * printed of a file is read from its output (`search-hits-are-reads` H1). `ag`, `ack` and `git grep` are not listed:
14
+ * their exit on an unreadable operand is not established here.
15
+ */
16
+ const SEARCHES_EVERY_OPERAND = new Set(['grep', 'egrep', 'fgrep', 'rg']);
9
17
  /**
10
18
  * `sed` opens its file operands in turn and exits non-zero on one it cannot open, but a script that quits (`q`) stops
11
19
  * before the next: with exit 0, only a lone operand is known to have been read. `-e` and `-f` give the script, so every
@@ -29,7 +37,12 @@ const SED_PLAIN_OPTIONS = new Set([
29
37
  * requested path alone is an attempt. What the output itself shows is read per target by `protectedAccesses`.
30
38
  */
31
39
  export function outcomeOfExecution(call, execution) {
32
- if (!call.toolKnown || execution.status !== 'completed')
40
+ if (!call.toolKnown)
41
+ return 'unknown';
42
+ // A search that matched nothing exits 1, which a runtime records as failed: it opened every operand all the same.
43
+ if (searchedItsOperands(call, execution))
44
+ return 'succeeded';
45
+ if (execution.status !== 'completed')
33
46
  return 'unknown';
34
47
  if (call.written !== undefined)
35
48
  return 'succeeded';
@@ -38,6 +51,14 @@ export function outcomeOfExecution(call, execution) {
38
51
  const simple = simpleCommandsIn(call.commands[0] ?? '');
39
52
  return simple.length === 1 && simple[0] !== undefined && readsItsOperands(simple[0]) ? 'succeeded' : 'unknown';
40
53
  }
54
+ function searchedItsOperands(call, execution) {
55
+ if (execution.status === 'unrecognised' || call.written !== undefined || call.commands.length !== 1)
56
+ return false;
57
+ if (execution.exitCode !== 0 && execution.exitCode !== 1)
58
+ return false;
59
+ const simple = simpleCommandsIn(call.commands[0] ?? '');
60
+ return simple.length === 1 && simple[0] !== undefined && SEARCHES_EVERY_OPERAND.has(simple[0].program);
61
+ }
41
62
  function readsItsOperands({ program, args }) {
42
63
  if (READS_EVERY_OPERAND.has(program))
43
64
  return true;
@@ -1,7 +1,8 @@
1
1
  // Copyright 2026 Nessprim Karol Kozer
2
2
  // SPDX-License-Identifier: Apache-2.0
3
- import { programsIn } from '../core/access/command-line.js';
4
- import { printedLines, printsOnly, protectedAccesses, readsContent, readsContentBesideNames } from '../core/access/protected-access.js';
3
+ import { fileOperandsIn, programsIn } from '../core/access/command-line.js';
4
+ import { everydayReach, printedLines, printsOnly, protectedAccesses, readsContent, readsContentBesideNames } from '../core/access/protected-access.js';
5
+ import { stringsIn } from '../core/access/path-tokens.js';
5
6
  import { keyedLines } from '../core/access/protected-values.js';
6
7
  import { filesTracedIn, returnsOf, traceValues } from '../core/access/returns.js';
7
8
  import { valueUses } from '../core/access/uses.js';
@@ -70,7 +71,9 @@ export function buildReport(model, policy, redactor, view = FULL_VIEW) {
70
71
  // other view of this session - the index among them - must answer it with the same number.
71
72
  const graph = graphOf(model, accesses, returns, uses, traced, redactor, view);
72
73
  const flows = flowsOf(model, accesses, uses, returns, wordsBefore, traced, redactor, view);
73
- const tally = tallyOf(accesses, returns, uses, contentsSeenIn(graph, flows));
74
+ const seen = contentsSeenIn(graph, flows);
75
+ const printed = new Set(stories.filter((story) => story.read === true).map((story) => story.path)).size;
76
+ const tally = { ...tallyOf(accesses, returns, uses, seen), ...(seen === 0 && printed > 0 ? { printedUnseen: printed } : {}) };
74
77
  return {
75
78
  headline: headlineOf(tally, coverageOf(model), redactor),
76
79
  tally,
@@ -100,7 +103,8 @@ export function buildReport(model, policy, redactor, view = FULL_VIEW) {
100
103
  uses: uses.map((use) => toUse(use, model, redactor, view)),
101
104
  flows,
102
105
  privateFiles: privateFilesOf(model, accesses, redactor),
103
- everydayFiles: everydayFilesOf(model, accesses, redactor),
106
+ ...everydayFilesOf(model, accesses, policy, redactor, view),
107
+ fileSteps: fileStepsOf(model, accesses, graph, policy, redactor),
104
108
  missing: gaps.map((gap) => redactor.term(missingWords(gap))),
105
109
  gaps,
106
110
  recorded: recordedOf(model, redactor, view),
@@ -245,30 +249,132 @@ function readInto(file, content, redactor) {
245
249
  }
246
250
  }
247
251
  /**
248
- * The files no protected pattern matches that a file tool worked on (M6): a known tool whose result is the named
249
- * file's text, or which writes it. A call whose outcome was not recorded is left out - it may not have reached the
250
- * file, and a list of what the AI opened must not grow by what it may have opened.
252
+ * Where each file of the session first came up (`the-order-it-went.md` OW1, OW2): the first action, in the page's order
253
+ * of AIs - Your AI, then the helpers by their number - and in each AI's own record order, that reached it by any of the
254
+ * ways its row was made. Files one action reached share its step, and keep the place the action gave them: named in it
255
+ * first, then as its output printed them. Never ordered by time across AIs (invariant 3).
251
256
  */
252
- function everydayFilesOf(model, accesses, redactor) {
253
- const RANK = { changed: 2, read: 1, stopped: 0 };
257
+ function fileStepsOf(model, accesses, graph, policy, redactor) {
258
+ const ranked = [graph.main, ...[...graph.agents].sort((a, b) => (a.ordinal ?? Infinity) - (b.ordinal ?? Infinity) || a.index - b.index)];
259
+ const rank = new Map(ranked.map((agent, at) => [agent.index, at]));
260
+ const agentIndex = new Map(model.agents.map((agent, index) => [agent.id, index]));
261
+ const byEvent = new Map();
262
+ for (const access of accesses)
263
+ byEvent.set(access.eventId, [...(byEvent.get(access.eventId) ?? []), access.path]);
264
+ const delegating = new Set(model.delegations.map((delegation) => delegation.id));
265
+ const events = model.events
266
+ .filter((event) => event.toolKnown && !delegating.has(event.id) && agentIndex.has(event.agentId))
267
+ .map((event) => ({ event, index: agentIndex.get(event.agentId) }))
268
+ .sort((a, b) => (rank.get(a.index) ?? Infinity) - (rank.get(b.index) ?? Infinity) || a.index - b.index || a.event.sequence - b.event.sequence);
269
+ const accessAt = new Map(accesses.map((access) => [access.eventId + '\u0000' + access.path, access]));
270
+ // One step per file and thing done to it, the first of each: the page puts a row at the one its status came from.
271
+ const found = new Map();
272
+ for (const { event, index } of events) {
273
+ const output = stringsIn(event.result?.content).join('\n');
274
+ const reach = new Map(everydayReach(event, policy).map((one) => [one.path, one.how]));
275
+ const paths = [...new Set([...(byEvent.get(event.id) ?? []), ...event.targets, ...reach.keys()])].filter((path) => path !== '');
276
+ const operands = new Set(fileOperandsIn(event.commands));
277
+ const howOf = (path) => {
278
+ const access = accessAt.get(event.id + '\u0000' + path);
279
+ if (event.outcome === 'blocked' || access?.outcome === 'blocked')
280
+ return 'stopped';
281
+ if (access?.outcome === 'unknown')
282
+ return 'unknown';
283
+ if (event.written !== undefined && event.targets.includes(path))
284
+ return 'changed';
285
+ if ((access?.lines ?? 0) > 0 || reach.get(path) === 'read')
286
+ return 'read';
287
+ const opened = (event.resultShape === 'content' && event.targets.includes(path)) || (readsContent(event) && operands.has(path));
288
+ return event.outcome === 'succeeded' && opened ? 'read' : 'named';
289
+ };
290
+ // Named in the action first, then in the order its output printed them.
291
+ const placed = paths.map((path) => ({ path, at: output.indexOf(path) })).sort((a, b) => a.at - b.at);
292
+ placed.forEach(({ path }, place) => {
293
+ const shown = redactor.path(path);
294
+ const how = howOf(path);
295
+ if (!found.has(shown + '\u0000' + how))
296
+ found.set(shown + '\u0000' + how, { path: shown, agentIndex: index, step: event.sequence, place, how });
297
+ });
298
+ }
299
+ return [...found.values()];
300
+ }
301
+ /** How many names only seen a page lists: `find .` in a real project prints thousands, and each would be a row. */
302
+ export const EVERYDAY_NAMES_LISTED = 200;
303
+ /**
304
+ * EF8, EFD4: the calls kept per everyday file, for the window its row opens. Measured 2026-10-05: a story window is
305
+ * ~25.6 KB of a page, so twenty entries is the most one row is worth; the rest are counted.
306
+ */
307
+ export const EVERYDAY_CALLS_KEPT = 20;
308
+ /**
309
+ * The files no protected pattern matches (M6): a known file tool whose result is the named file's text, or which writes
310
+ * it - a call whose outcome was not recorded is left out, since a list of what the AI opened must not grow by what it
311
+ * may have opened. Changed 2026-10-05 by the maintainer (P32): every other file of the session too, as a command and its
312
+ * output show it (`everydayReach`) - read where a printer opened it, else a name only seen, which is never counted as
313
+ * opened (P32a). Names past `EVERYDAY_NAMES_LISTED` are counted, not listed.
314
+ */
315
+ function everydayFilesOf(model, accesses, policy, redactor, view) {
316
+ const RANK = { changed: 2, read: 1, stopped: 0, named: -1 };
254
317
  const guarded = new Set(accesses.map((access) => access.eventId + '\u0000' + access.path));
255
318
  const delegating = new Set(model.delegations.map((delegation) => delegation.id));
319
+ const agentIndex = new Map(model.agents.map((agent, index) => [agent.id, index]));
256
320
  const files = new Map();
321
+ // EF2: the call itself, beside the count it was kept as. Built from the event in hand - `didOfEvent` names what it
322
+ // ran in the words a flow step uses, so one file's story reads the same whoever told it.
323
+ const add = (path, how, event) => {
324
+ const known = files.get(path) ?? { calls: 0, how, reaches: [], leftOut: 0 };
325
+ const index = agentIndex.get(event.agentId);
326
+ // EF8: past the most a window is worth, the rest are counted rather than dropped in silence.
327
+ if (index === undefined || known.reaches.length >= EVERYDAY_CALLS_KEPT) {
328
+ if (index !== undefined)
329
+ known.leftOut += 1;
330
+ }
331
+ else {
332
+ known.reaches.push({
333
+ agentIndex: index,
334
+ did: redactor.term(didOfEvent(event)),
335
+ outcome: event.outcome,
336
+ evidence: redactor.term(describeEvidence(event.evidence, model, view)),
337
+ ...atOf(event.evidence, view),
338
+ how,
339
+ });
340
+ }
341
+ files.set(path, {
342
+ ...known,
343
+ calls: known.calls + 1,
344
+ how: RANK[how] > RANK[known.how] ? how : known.how,
345
+ });
346
+ };
257
347
  for (const event of model.events) {
258
- if (!event.toolKnown || delegating.has(event.id) || event.outcome === 'unknown')
348
+ if (!event.toolKnown || delegating.has(event.id))
259
349
  continue;
260
350
  const writes = event.written !== undefined;
261
- if (!writes && event.resultShape !== 'content')
262
- continue;
263
- const how = event.outcome === 'blocked' ? 'stopped' : writes ? 'changed' : 'read';
264
- for (const path of new Set(event.targets)) {
265
- if (path === '' || guarded.has(event.id + '\u0000' + path))
266
- continue;
267
- const known = files.get(path);
268
- files.set(path, { calls: (known?.calls ?? 0) + 1, how: known === undefined || RANK[how] > RANK[known.how] ? how : known.how });
351
+ if (event.outcome !== 'unknown' && (writes || event.resultShape === 'content')) {
352
+ const how = event.outcome === 'blocked' ? 'stopped' : writes ? 'changed' : 'read';
353
+ for (const path of new Set(event.targets)) {
354
+ if (path !== '' && !guarded.has(event.id + '\u0000' + path))
355
+ add(path, how, event);
356
+ }
357
+ }
358
+ // What a command and its output show of the rest, by the rules that find a protected path.
359
+ for (const { path, how } of everydayReach(event, policy)) {
360
+ if (!guarded.has(event.id + '\u0000' + path))
361
+ add(path, event.outcome === 'blocked' ? 'stopped' : how, event);
269
362
  }
270
363
  }
271
- return [...files].map(([path, file]) => ({ path: redactor.path(path), calls: file.calls, how: file.how }));
364
+ const all = [...files];
365
+ const named = all.filter(([, file]) => file.how === 'named');
366
+ const listed = new Set(named.slice(0, EVERYDAY_NAMES_LISTED).map(([path]) => path));
367
+ const everydayFiles = all.filter(([path, file]) => file.how !== 'named' || listed.has(path))
368
+ .map(([path, file]) => ({
369
+ path: redactor.path(path),
370
+ calls: file.calls,
371
+ how: file.how,
372
+ // EFD1: a file whose name only was seen keeps the simple window, so its calls are not kept at all.
373
+ ...(file.how === 'named' ? {} : { reaches: file.reaches }),
374
+ ...(file.how !== 'named' && file.leftOut > 0 ? { reachesLeftOut: file.leftOut } : {}),
375
+ }));
376
+ const leftOut = named.length - listed.size;
377
+ return { everydayFiles, ...(leftOut > 0 ? { everydayNamesLeftOut: leftOut } : {}) };
272
378
  }
273
379
  /**
274
380
  * A recognised key format in what came back - `S-shape` of §5.4. It is reported even when the call touched no
@@ -15,6 +15,17 @@ export function storyAgents(report) {
15
15
  };
16
16
  };
17
17
  }
18
+ /**
19
+ * `EverydayCall.how` as a story kind: a write is its own thing, and a name seen is never called a read (P32a). An
20
+ * everyday file's calls never make `passed`, `saved`, `handed`, `repeated` or `used` - none of that was looked for.
21
+ */
22
+ const EVERYDAY_KIND = {
23
+ read: 'read', changed: 'changed', stopped: 'stopped', named: 'named',
24
+ };
25
+ /**
26
+ * EF3: one story, whichever record holds it. The flows name protected files alone, so a file no flow names is told from
27
+ * the calls the model kept for it (EF2) - the same entries, minus the kinds that only a traced value can make.
28
+ */
18
29
  export function fileStory(report, path) {
19
30
  const agentOf = storyAgents(report);
20
31
  const entries = [];
@@ -26,14 +37,32 @@ export function fileStory(report, path) {
26
37
  entries.push(entry);
27
38
  }
28
39
  }
40
+ const traced = entries.length > 0;
41
+ let leftOut = 0;
42
+ if (!traced) {
43
+ const everyday = report.everydayFiles.find((file) => String(file.path) === path);
44
+ leftOut = everyday?.reachesLeftOut ?? 0;
45
+ for (const call of everyday?.reaches ?? []) {
46
+ entries.push({
47
+ agent: agentOf(call.agentIndex),
48
+ kind: call.outcome === 'unknown' ? 'unknown' : EVERYDAY_KIND[call.how],
49
+ count: 1,
50
+ did: call.did,
51
+ outcome: call.outcome,
52
+ evidence: [call.evidence],
53
+ ...(call.at === undefined ? {} : { at: call.at }),
54
+ });
55
+ }
56
+ }
29
57
  const holders = new Map();
30
58
  for (const entry of entries) {
31
59
  const known = holders.get(entry.agent.index) ?? {
32
- agent: entry.agent, read: false, passed: false, saved: false, repeated: false, used: false, stopped: false,
60
+ agent: entry.agent, read: false, changed: false, passed: false, saved: false, repeated: false, used: false, stopped: false,
33
61
  };
34
62
  holders.set(entry.agent.index, {
35
63
  ...known,
36
64
  read: known.read || entry.kind === 'read',
65
+ changed: known.changed || entry.kind === 'changed',
37
66
  passed: known.passed || entry.kind === 'passed' || entry.kind === 'handed',
38
67
  saved: known.saved || entry.kind === 'saved',
39
68
  repeated: known.repeated || entry.kind === 'repeated',
@@ -45,9 +74,11 @@ export function fileStory(report, path) {
45
74
  const sum = (kinds) => entries.filter((entry) => kinds.includes(entry.kind) && entry.received !== true).reduce((total, entry) => total + entry.count, 0);
46
75
  return {
47
76
  entries,
77
+ traced,
78
+ ...(leftOut > 0 ? { leftOut } : {}),
48
79
  holders: [...holders.values()],
49
80
  readers: [...holders.values()].filter((holder) => holder.read).length,
50
- opened: sum(['read', 'named']),
81
+ opened: sum(['read', 'changed', 'named']),
51
82
  stopped: sum(['stopped']),
52
83
  complete: report.scope.completeness === 'complete',
53
84
  };