@inixiative/foundry-core 0.5.1 → 0.5.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/foundry-core",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -209,6 +209,8 @@ export interface InstructionFile {
209
209
  readonly path: string;
210
210
  /** Text with block-level HTML comments removed, as Claude Code injects it. */
211
211
  readonly content: string;
212
+ /** The file's text as read, when stripping comments changed it (`content` is the stripped form). */
213
+ readonly raw?: string;
212
214
  /** Import hops from the file the chain started at (0 for that file). */
213
215
  readonly depth: number;
214
216
  /** The file whose `@path` loaded this one. */
@@ -395,8 +397,15 @@ export async function readInstructionFile(
395
397
  return;
396
398
  }
397
399
  seen.add(real);
398
- const content = stripInstructionComments(await Bun.file(real).text());
399
- files.push({ path: file, content, depth, ...(importedBy ? { importedBy } : {}) });
400
+ const raw = await Bun.file(real).text();
401
+ const content = stripInstructionComments(raw);
402
+ files.push({
403
+ path: file,
404
+ content,
405
+ ...(raw !== content ? { raw } : {}),
406
+ depth,
407
+ ...(importedBy ? { importedBy } : {}),
408
+ });
400
409
  if (depth >= maxDepth) return;
401
410
  for (const target of instructionImports(content)) {
402
411
  const next = resolveInstructionImport(target, file, options.home);
@@ -617,7 +617,8 @@ export async function discoverHierarchy(
617
617
  continue;
618
618
  }
619
619
  if (statSync(real).size > INSTRUCTION_FILE_LIMIT) continue;
620
- let content = readFileSync(unit.path, 'utf8');
620
+ const raw = readFileSync(unit.path, 'utf8');
621
+ let content = raw;
621
622
  if (unit.source.maxLines !== undefined)
622
623
  content = content.split('\n').slice(0, unit.source.maxLines).join('\n');
623
624
  if (unit.source.maxBytes !== undefined)
@@ -625,7 +626,14 @@ export async function discoverHierarchy(
625
626
  const final = tierOf(unit.path, node);
626
627
  seen.add(real);
627
628
  kept.set(real, { path: unit.path, node: final.id, index: files.length });
628
- files.push({ path: unit.path, content, depth: 0, node: final.id, tier: final.tier });
629
+ files.push({
630
+ path: unit.path,
631
+ content,
632
+ ...(raw !== content ? { raw } : {}),
633
+ depth: 0,
634
+ node: final.id,
635
+ tier: final.tier,
636
+ });
629
637
  continue;
630
638
  }
631
639
  const allowed =
@@ -665,7 +673,13 @@ export async function discoverHierarchy(
665
673
  // Every file keeps the narrower tier of its node and any node owning its path, found or imported.
666
674
  const final = tierOf(file.path, node);
667
675
  kept.set(realOf(file.path), { path: file.path, node: final.id, index: files.length });
668
- files.push({ ...file, content, node: final.id, tier: final.tier });
676
+ files.push({
677
+ ...file,
678
+ content,
679
+ ...(content !== file.content ? { raw: file.raw ?? file.content } : {}),
680
+ node: final.id,
681
+ tier: final.tier,
682
+ });
669
683
  }
670
684
  }
671
685
  return { files, duplicates, refused, probes: [...probes] };
@@ -850,7 +864,7 @@ export function dedupeHierarchy<F extends HierarchyFile>(
850
864
  continue;
851
865
  }
852
866
  const content = cut(file.content.split('\n'), dropped);
853
- if (content.trim()) keep.unshift({ ...file, content });
867
+ if (content.trim()) keep.unshift({ ...file, content, raw: file.raw ?? file.content });
854
868
  }
855
869
  out = keep;
856
870
  }
@@ -109,8 +109,55 @@ export interface ContextSource {
109
109
  * Must be cheap: it runs before every assembly.
110
110
  */
111
111
  changed?(): boolean;
112
+ /**
113
+ * What the last load was made of, in order: glue text and attributed parts whose concatenation is
114
+ * exactly the loaded text. Absent when the source cannot attribute its text.
115
+ */
116
+ segments?(): readonly SourceSegment[] | undefined;
117
+ }
118
+
119
+ /**
120
+ * One attributed piece of composed text. `subject` names where it came from (`file:<path>`,
121
+ * `prompt:agent:<id>`, `source-content:<id>`). `retrieved` marks text chosen per message (it is not
122
+ * a slow-moving source). `raw` is the source's own text when the composed `text` is a cleaned or
123
+ * trimmed form of it (comments stripped, a duplicate section removed).
124
+ */
125
+ export interface SourcePart {
126
+ readonly subject: string;
127
+ readonly text: string;
128
+ readonly retrieved?: boolean;
129
+ readonly raw?: string;
130
+ /** The ladder rung of a file part, and its corpus tier. */
131
+ readonly node?: string;
132
+ readonly tier?: string;
133
+ }
134
+
135
+ /** Glue text (headings, separators) or an attributed part. */
136
+ export type SourceSegment = string | SourcePart;
137
+
138
+ /** What a composed agent call was made of, slot by slot (in memory; persisted as version ids). */
139
+ export interface CompositionSnapshot {
140
+ /** The role composed for (an agent id). */
141
+ readonly role?: string;
142
+ readonly instructions: readonly SourceSegment[];
143
+ readonly domainKnowledge: readonly SourceSegment[];
144
+ /** What the composer left out of each slot, and why (excluded by role, a duplicate, refused, over budget). */
145
+ readonly omitted?: {
146
+ readonly instructions: readonly OmittedSource[];
147
+ readonly domainKnowledge: readonly OmittedSource[];
148
+ };
112
149
  }
113
150
 
151
+ export interface OmittedSource {
152
+ readonly subject: string;
153
+ readonly reason: string;
154
+ readonly duplicateOf?: string;
155
+ }
156
+
157
+ /** The text a segment list composes. */
158
+ export const segmentsText = (segments: readonly SourceSegment[]): string =>
159
+ segments.map((segment) => (typeof segment === 'string' ? segment : segment.text)).join('');
160
+
114
161
  /** Selection provenance carried on a layer instance for inspection. */
115
162
  export interface LayerSelectionState {
116
163
  readonly focusHash?: string;
@@ -320,6 +367,8 @@ export class ContextLayer {
320
367
  readonly perTurn: boolean;
321
368
 
322
369
  private _content: string = '';
370
+ /** Each source's text from the last warm, in source order; undefined after a manual `set`. */
371
+ private _loaded: string[] | undefined;
323
372
  private _state: LayerState = 'cold';
324
373
  private _hash: string = '';
325
374
  private _lastWarmed: number | null = null;
@@ -496,6 +545,32 @@ export class ContextLayer {
496
545
  return this._content;
497
546
  }
498
547
 
548
+ /**
549
+ * What the current content is made of: each source's segments (or the source's whole text as one
550
+ * part, `unattributed:<source id>`, when it cannot attribute it), joined as the content was. Content written with
551
+ * `set` is one part, `layer-content:<id>`. Concatenated, always exactly `content`.
552
+ */
553
+ segments(): SourceSegment[] {
554
+ const whole = (): SourceSegment[] =>
555
+ this._content ? [{ subject: `layer-content:${this.id}`, text: this._content }] : [];
556
+ const loaded = this._loaded;
557
+ if (!loaded || loaded.length !== this._sources.length) return whole();
558
+ const out: SourceSegment[] = [];
559
+ loaded.forEach((text, i) => {
560
+ if (i > 0) out.push('\n\n');
561
+ const source = this._sources[i]!;
562
+ const own = source.segments?.();
563
+ if (own && segmentsText(own) === text) out.push(...own);
564
+ else if (text)
565
+ out.push({
566
+ subject: `unattributed:${source.id}`,
567
+ text,
568
+ ...(source.focusable ? { retrieved: true } : {}),
569
+ });
570
+ });
571
+ return segmentsText(out) === this._content ? out : whole();
572
+ }
573
+
499
574
  get lastAccessed(): number | null {
500
575
  return this._lastAccessed;
501
576
  }
@@ -595,6 +670,7 @@ export class ContextLayer {
595
670
  }
596
671
 
597
672
  this._content = parts.join('\n\n');
673
+ this._loaded = parts;
598
674
  this._hash = computeHash(this._content);
599
675
  this._version = undefined; // even identical unversioned source bytes are a new write
600
676
  this._selection = reports.length
@@ -632,6 +708,7 @@ export class ContextLayer {
632
708
  set(content: string, author: string = 'system'): void {
633
709
  const previous = this._content;
634
710
  this._content = content;
711
+ this._loaded = undefined;
635
712
  this._hash = computeHash(content);
636
713
  this._selection = undefined; // manual content has no selection provenance
637
714
  this._version = undefined; // a new write carries no version until its owner marks it
@@ -31,6 +31,8 @@ export interface CorrectionEvent {
31
31
  readonly timestamp: string;
32
32
  /** What the agent said right before the correction ('' at the start of a session). */
33
33
  readonly before: string;
34
+ /** The person's previous message: what the agent was answering when it earned the correction. */
35
+ readonly ask?: string;
34
36
  /** The person's message. */
35
37
  readonly correction: string;
36
38
  /** The strongest signal, and every signal that fired. */
@@ -167,6 +169,7 @@ export function extractCorrections(
167
169
  const minScore = options.minScore ?? 0;
168
170
  const corrections: CorrectionEvent[] = [];
169
171
  let before = '';
172
+ let ask = '';
170
173
  let pendingInterruption = false;
171
174
  let secretsScrubbed = 0;
172
175
  // An identical message resent within ten minutes is one correction, not a repeat.
@@ -181,6 +184,8 @@ export function extractCorrections(
181
184
  if (!text.trim()) continue;
182
185
  const detection = detectCorrection(text, before, { interrupted: pendingInterruption });
183
186
  pendingInterruption = false;
187
+ const previousAsk = ask;
188
+ ask = text.trim();
184
189
  const key = text.toLowerCase().replace(/\s+/g, ' ').trim();
185
190
  const at = Date.parse(turn.timestamp);
186
191
  const sent = recent.get(key);
@@ -190,12 +195,14 @@ export function extractCorrections(
190
195
  // Scrub before truncating, so a cut can't leave part of a secret unmatched.
191
196
  const scrubbedBefore = scrubSecrets(before);
192
197
  const scrubbedText = scrubSecrets(text.trim());
193
- secretsScrubbed += scrubbedBefore.hits + scrubbedText.hits;
198
+ const scrubbedAsk = scrubSecrets(previousAsk);
199
+ secretsScrubbed += scrubbedBefore.hits + scrubbedText.hits + scrubbedAsk.hits;
194
200
  const event = {
195
201
  ...(turn.id ? { messageId: turn.id } : {}),
196
202
  ...(turn.sourceRef ? { sourceRef: turn.sourceRef } : {}),
197
203
  timestamp: turn.timestamp,
198
204
  before: scrubbedBefore.text.slice(-2000),
205
+ ...(scrubbedAsk.text ? { ask: scrubbedAsk.text.slice(0, 4000) } : {}),
199
206
  correction: scrubbedText.text.slice(0, 4000),
200
207
  signal: detection.signal,
201
208
  signals: detection.signals,
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The experiment gate (`.claude/skills/experiment/SKILL.md`), shared by Foundry's eval scripts and
3
+ * Oracle's replay runner: a wide run (more than TINY_MOMENTS moments, or whatever else the caller
4
+ * counts as wide) needs `--experiment <design.md>` whose pre-registration sections are filled and
5
+ * whose `<design>.review.md` says `verdict: go` for this exact design (`design-sha256:` of its
6
+ * bytes). A tiny test runs without one.
7
+ */
8
+ import { createHash } from 'node:crypto';
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { resolve } from 'node:path';
11
+
12
+ export const TINY_MOMENTS = 3;
13
+
14
+ /** The `## ` headings of `.claude/skills/experiment/design.md`, every one required. */
15
+ export const DESIGN_SECTIONS = [
16
+ 'Question',
17
+ 'Variable',
18
+ 'Arms',
19
+ 'Fixtures and power',
20
+ 'Baseline',
21
+ 'Gates',
22
+ 'Cost ceiling and stop rules',
23
+ 'Provenance',
24
+ 'What would make the result wrong',
25
+ ] as const;
26
+
27
+ export type ExperimentRecord = { design: string; designSha256: string; review: string };
28
+
29
+ export const reviewPathOf = (design: string) => design.replace(/\.md$/, '.review.md');
30
+
31
+ const sha256 = (path: string) => createHash('sha256').update(readFileSync(path)).digest('hex');
32
+
33
+ /** Why the design may not run wide; empty when it may. */
34
+ export const experimentProblems = (designPath: string): string[] => {
35
+ const design = resolve(designPath);
36
+ if (!design.endsWith('.md')) return [`${design}: the design is a .md file`];
37
+ if (!existsSync(design)) return [`${design}: no such design`];
38
+ const problems: string[] = [];
39
+ const text = readFileSync(design, 'utf8');
40
+ const sections = new Map<string, string>();
41
+ let current: string | undefined;
42
+ for (const line of text.split('\n')) {
43
+ const heading = /^##\s+(.+?)\s*$/.exec(line);
44
+ if (heading) {
45
+ current = heading[1]?.toLowerCase();
46
+ if (current) sections.set(current, sections.get(current) ?? '');
47
+ } else if (/^#\s/.test(line)) current = undefined;
48
+ else if (current) sections.set(current, `${sections.get(current) ?? ''}${line}\n`);
49
+ }
50
+ for (const name of DESIGN_SECTIONS) {
51
+ const body = sections.get(name.toLowerCase());
52
+ if (body === undefined) problems.push(`missing section "## ${name}"`);
53
+ else if (!body.replace(/<!--[\s\S]*?-->/g, '').trim())
54
+ problems.push(`section "## ${name}" is empty`);
55
+ }
56
+ if (text.includes('<fill')) problems.push('unfilled <fill: …> placeholders remain');
57
+ const review = reviewPathOf(design);
58
+ if (!existsSync(review)) return [...problems, `no adversarial review at ${review}`];
59
+ const reviewText = readFileSync(review, 'utf8');
60
+ if (!/^verdict:\s*go\s*$/im.test(reviewText))
61
+ problems.push(`${review} has no "verdict: go" line`);
62
+ const reviewed = /^design-sha256:\s*([0-9a-f]{64})\s*$/im.exec(reviewText)?.[1];
63
+ if (!reviewed) problems.push(`${review} has no "design-sha256: <hex>" line`);
64
+ else if (reviewed !== sha256(design))
65
+ problems.push('the design changed after its review (design-sha256 differs): review it again');
66
+ return problems;
67
+ };
68
+
69
+ /** Throws unless the run may go ahead; returns the provenance to record when a design was named. */
70
+ export const requireExperiment = (
71
+ experiment: string | undefined,
72
+ wide: boolean,
73
+ ): ExperimentRecord | undefined => {
74
+ if (!experiment) {
75
+ if (!wide) return undefined;
76
+ throw Error(
77
+ `a wide run (more than ${TINY_MOMENTS} moments) needs --experiment <design.md> ` +
78
+ 'with a reviewed pre-registration: see .claude/skills/experiment/SKILL.md',
79
+ );
80
+ }
81
+ const problems = experimentProblems(experiment);
82
+ if (problems.length)
83
+ throw Error(
84
+ `--experiment ${experiment} may not run:\n - ${problems.join('\n - ')}\n` +
85
+ 'see .claude/skills/experiment/SKILL.md',
86
+ );
87
+ const design = resolve(experiment);
88
+ return { design, designSha256: sha256(design), review: reviewPathOf(design) };
89
+ };
@@ -0,0 +1,237 @@
1
+ import {
2
+ type Authorship,
3
+ CONCEPT_CLUSTERS,
4
+ type CorrectionTarget,
5
+ MISS_KINDS,
6
+ type MissKind,
7
+ } from './warden-probes';
8
+
9
+ // The Prospector's reading of written review: PR reviews and ticket comments where the person said the
10
+ // work should have used, extended or ported something that already existed. Data and pure functions
11
+ // only: callers fetch the text and make the model call.
12
+
13
+ /** One piece of written review, with what it was about. */
14
+ export interface ReviewMoment {
15
+ readonly id: string;
16
+ /** Where it was written, e.g. `PR #2116 (CHANGES_REQUESTED review, inline)` or `ZLT-4321 comment`. */
17
+ readonly where: string;
18
+ /** What the work was: PR title and description, or the issue's title and description. */
19
+ readonly work: string;
20
+ /** The code it was written on, when inline (path, line, diff hunk). */
21
+ readonly code?: string;
22
+ /** The message it replies to, when it is a reply. */
23
+ readonly replyTo?: string;
24
+ readonly text: string;
25
+ /** `directive`: a description the person wrote of work still to do (a ticket, a project), not a review of done work. */
26
+ readonly mode?: 'review' | 'directive';
27
+ }
28
+
29
+ export type FindingScope = 'architectural' | 'code';
30
+
31
+ /** How much a finding counts in scoring: generic code-correctness findings are lightly down-weighted (Aron, 2026-10-15). */
32
+ export const SCOPE_WEIGHT: Readonly<Record<FindingScope, number>> = {
33
+ architectural: 1,
34
+ code: 0.75,
35
+ };
36
+
37
+ /** One place the reviewer pushed toward something that already existed. */
38
+ export interface ReviewFinding {
39
+ readonly target: CorrectionTarget;
40
+ /** The reviewer's own words that say it, verbatim, at most 300 characters. */
41
+ readonly excerpt: string;
42
+ /** `architectural`: about which system, primitive or boundary to build on; `code`: a generic code-correctness fix. */
43
+ readonly scope?: FindingScope;
44
+ }
45
+
46
+ export interface ReviewReading {
47
+ readonly findings: readonly ReviewFinding[];
48
+ /** The model's read of the voice: typed by a person, or written by an AI agent. A weak signal. */
49
+ readonly voice?: 'human' | 'agent';
50
+ }
51
+
52
+ /**
53
+ * Words a reuse finding almost always carries. A change-request review is read whole; everything else
54
+ * is read only when it matches (the method of Zealot's core-primitives findings, ZLT-5638).
55
+ */
56
+ export const REUSE_HINT =
57
+ /\b(?:already (?:exists?|have|has|does|handles?|in|imported|installed|there)|existing|exists|re-?use|reimplement\w*|re-implement\w*|hand-?roll\w*|hand-?written|duplicat\w*|copy|copies|copied|parallel|second (?:copy|version|implementation)|third (?:copy|type system)|instead of|port(?:ed|ing)? (?:from|it|this|the)|from (?:the )?template|template'?s|primitive|extend(?:ing)? (?:the|it)|use (?:the |our )?[`\w]|we have|there'?s (?:a|an|already)|same (?:shape|thing|as)|like \w+ does|convention|pattern|wrapper|shared|generic|lodash|helper|util|registry|consolidate|fold (?:into|it)|one place|single source)\b/i;
58
+
59
+ export const REVIEW_FINDINGS_PROMPT = `You read code review written by one reviewer, Aron, on pull requests and tickets. For each item, find every place where he says the work should have USED, EXTENDED or PORTED something that ALREADY EXISTS instead of what was built: a primitive (helper, wrapper, hook, component, class, module, package export, library already installed), a system (an existing subsystem or flow), a pattern (an established way the codebase does a thing, e.g. a route template or a module convention), or a rule (a standing convention or ruling, e.g. "no select on internal reads").
60
+
61
+ A finding needs both: the existing thing, and what was done instead. Not findings: bug reports, missing tests, style nits, new requirements, questions, praise, status updates, an author's replies about what they fixed, and anything that points at something not yet built.
62
+
63
+ For each finding:
64
+ - name: the existing thing as the codebase names it, at most 80 characters (e.g. "paginate() + filterLens", "Skeleton component", "lodash omit", "PolymorphismRegistry"). Name the thing, not the advice.
65
+ - kind: "primitive" | "system" | "pattern" | "rule"
66
+ - concept: its concept cluster; use one of the given clusters when one fits, else a new short kebab-case cluster.
67
+ - where: the file, package or doc it lives in, only when the item says so.
68
+ - builtInstead: what the work did instead, one line, at most 160 characters. For a directive item, what the work would otherwise have built, or "" when the item doesn't say.
69
+ - miss: why it was missed, judged from the item: "unaware" (didn't know it existed), "misfit" (knew it, worked around a gap instead of extending it), "wrapper" (wrapped it under a new name), "unclear".
70
+ - excerpt: Aron's words that say it, verbatim, at most 300 characters.
71
+ - scope: "architectural" when it is about which system, primitive, module boundary or pattern to build on; "code" when it is a generic code-correctness fix (a library call, a small helper, an idiom).
72
+
73
+ Items marked "Mode: directive" are descriptions Aron wrote of work still to be done (a ticket, a project). There a finding is a place where he directs the work to use, extend or port something that already exists, by name.
74
+
75
+ Also judge the voice of each item: "human" when it reads as typed by a person (terse, informal, lowercase, typos, first-person asides), "agent" when it reads as written by an AI coding agent (headed sections, bold labels, numbered findings, dense backticked citations, "Fixed in <sha>" replies, polished em-dash prose).
76
+
77
+ Respond with JSON only: { "results": { "<id>": { "voice": "human" | "agent", "findings": [ { "name": string, "kind": string, "concept": string, "where"?: string, "builtInstead": string, "miss": string, "excerpt": string, "scope": "architectural" | "code" } ] } } } with "findings": [] when there are none.`;
78
+
79
+ /** The reader's input for a batch of review moments. */
80
+ export function reviewFindingMessages(
81
+ moments: readonly ReviewMoment[],
82
+ ): { role: 'system' | 'user'; content: string }[] {
83
+ const body = moments
84
+ .map((m) =>
85
+ [
86
+ `## ${m.id}`,
87
+ `Where: ${m.where}`,
88
+ ...(m.mode === 'directive' ? ['Mode: directive'] : []),
89
+ `### The work\n${m.work.slice(0, 1200)}`,
90
+ ...(m.code ? [`### Code it was written on\n${m.code.slice(-1500)}`] : []),
91
+ ...(m.replyTo ? [`### Replying to\n${m.replyTo.slice(0, 800)}`] : []),
92
+ `### Aron wrote\n${m.text.slice(0, 4000)}`,
93
+ ].join('\n'),
94
+ )
95
+ .join('\n\n');
96
+ return [
97
+ { role: 'system', content: REVIEW_FINDINGS_PROMPT },
98
+ {
99
+ role: 'user',
100
+ content: `Concept clusters: ${CONCEPT_CLUSTERS.join(', ')}\n\nRead each item below.\n\n${body}`,
101
+ },
102
+ ];
103
+ }
104
+
105
+ const KINDS = new Set(['primitive', 'system', 'pattern', 'rule']);
106
+ const kebab = (text: string) =>
107
+ text
108
+ .trim()
109
+ .toLowerCase()
110
+ .replace(/[^a-z0-9]+/g, '-')
111
+ .replace(/^-|-$/g, '');
112
+
113
+ /** One reading per id; undefined when missing or malformed (the caller retries it alone). */
114
+ export function parseReviewFindings(
115
+ content: string,
116
+ ids: readonly string[],
117
+ ): (ReviewReading | undefined)[] {
118
+ let results: Record<string, { voice?: unknown; findings?: unknown }> = {};
119
+ try {
120
+ results =
121
+ (
122
+ JSON.parse(content.slice(content.indexOf('{'), content.lastIndexOf('}') + 1)) as {
123
+ results?: typeof results;
124
+ }
125
+ ).results ?? {};
126
+ } catch {
127
+ return ids.map(() => undefined);
128
+ }
129
+ return ids.map((id) => {
130
+ const item = results[id];
131
+ if (!item || !Array.isArray(item.findings)) return undefined;
132
+ const findings: ReviewFinding[] = [];
133
+ for (const raw of item.findings as Record<string, unknown>[]) {
134
+ if (!raw || typeof raw.name !== 'string' || !raw.name.trim()) continue;
135
+ if (typeof raw.concept !== 'string' || !kebab(raw.concept)) continue;
136
+ const miss = MISS_KINDS.includes(raw.miss as MissKind) ? (raw.miss as MissKind) : 'unclear';
137
+ findings.push({
138
+ target: {
139
+ name: raw.name.trim().slice(0, 120),
140
+ kind: (KINDS.has(String(raw.kind)) ? raw.kind : 'pattern') as CorrectionTarget['kind'],
141
+ concept: kebab(raw.concept),
142
+ ...(typeof raw.where === 'string' && raw.where.trim()
143
+ ? { where: raw.where.trim().slice(0, 160) }
144
+ : {}),
145
+ ...(typeof raw.builtInstead === 'string' && raw.builtInstead.trim()
146
+ ? { builtInstead: raw.builtInstead.trim().slice(0, 240) }
147
+ : {}),
148
+ miss,
149
+ },
150
+ excerpt: typeof raw.excerpt === 'string' ? raw.excerpt.trim().slice(0, 300) : '',
151
+ ...(raw.scope === 'architectural' || raw.scope === 'code' ? { scope: raw.scope } : {}),
152
+ });
153
+ }
154
+ return {
155
+ findings,
156
+ ...(item.voice === 'human' || item.voice === 'agent' ? { voice: item.voice } : {}),
157
+ };
158
+ });
159
+ }
160
+
161
+ /** Markers an agent leaves in what it posts, and the trace of text typed into a web form. */
162
+ export const AUTHORSHIP_MARKERS = {
163
+ /** An agent's signature line. */
164
+ footer:
165
+ /(?:generated (?:by|with) \[?claude|🤖 generated|co-authored-by: claude|^_?generated by .{0,40}_?$)/im,
166
+ /** Claude Code's terminal glyphs: an agent's output pasted in by hand. */
167
+ pasted: /(?:^|\n)\s*[⏺⎿]/,
168
+ /** CRLF line endings: typed (or edited) in the GitHub web UI; gh and the API post LF. */
169
+ webForm: /\r\n/,
170
+ } as const;
171
+
172
+ /** What the person's own sessions say about a comment: an agent posted it, or the person typed it. */
173
+ export interface SessionEvidence {
174
+ readonly posted?: boolean;
175
+ /** The text appears in another tool call: a body file an agent drafted, or a quote. Weak. */
176
+ readonly drafted?: boolean;
177
+ readonly typed?: boolean;
178
+ }
179
+
180
+ /**
181
+ * Who wrote a comment posted under the person's account. Strong evidence decides; a model's read of
182
+ * the voice is the weak fallback, then plain style. A typed prompt outranks an agent posting it: the
183
+ * person dictated the words.
184
+ */
185
+ export function authorshipOf(
186
+ text: string,
187
+ evidence: SessionEvidence = {},
188
+ voice?: 'human' | 'agent',
189
+ ): Authorship {
190
+ const signals: string[] = [];
191
+ if (evidence.typed) signals.push('session:typed');
192
+ if (evidence.posted) signals.push('session:posted');
193
+ if (evidence.drafted) signals.push('session:drafted');
194
+ if (AUTHORSHIP_MARKERS.footer.test(text)) signals.push('marker:footer');
195
+ if (AUTHORSHIP_MARKERS.pasted.test(text)) signals.push('marker:pasted');
196
+ if (AUTHORSHIP_MARKERS.webForm.test(text)) signals.push('marker:web-form');
197
+ const style = styleOf(text);
198
+ signals.push(`style:${style}`);
199
+ if (voice) signals.push(`voice:${voice}`);
200
+ if (evidence.typed) return { by: 'human', confidence: 'strong', signals };
201
+ if (evidence.posted || signals.includes('marker:footer') || signals.includes('marker:pasted'))
202
+ return { by: 'agent', confidence: 'strong', signals };
203
+ if (signals.includes('marker:web-form')) return { by: 'human', confidence: 'strong', signals };
204
+ // A draft can also be an agent quoting the person's comment back, so it only outweighs style.
205
+ return { by: evidence.drafted ? 'agent' : (voice ?? style), confidence: 'weak', signals };
206
+ }
207
+
208
+ /** Plain style: an agent's headings, bold labels, numbered findings and dense citations. */
209
+ export function styleOf(text: string): 'human' | 'agent' {
210
+ let agent = 0;
211
+ if (/^#{1,4} /m.test(text)) agent += 2;
212
+ if (/\*\*[^*\n]{2,60}\*\*/.test(text)) agent++;
213
+ if (/^\s*\d+\. \*\*/m.test(text)) agent += 2;
214
+ if ((text.match(/`[^`\n]+`/g) ?? []).length >= 4) agent++;
215
+ if ((text.match(/—/g) ?? []).length >= 2) agent++;
216
+ if (/^(?:Fixed in|Addressed in|Done in) [0-9a-f]{7}/im.test(text)) agent += 2;
217
+ if (text.length > 900) agent++;
218
+ let human = 0;
219
+ if (/^[a-z]/.test(text.trim())) human += 2;
220
+ if (!/[.!?:`)]\s*$/.test(text.trim())) human++;
221
+ if (/\b(?:im|dont|cant|doesnt|isnt|wont|u|ur|pls|thx|lol|imo|tbh)\b/.test(text)) human += 2;
222
+ if (text.length < 200) human++;
223
+ return agent > human ? 'agent' : 'human';
224
+ }
225
+
226
+ /**
227
+ * A distinctive run of a comment that survives JSON and shell quoting unchanged, to find the session
228
+ * that posted it. Undefined when the text has no such run.
229
+ */
230
+ export function evidenceSnippet(text: string, length = 48): string | undefined {
231
+ let best = '';
232
+ for (const run of text.split(/[^A-Za-z0-9 ,.;:()/_=+-]+/))
233
+ if (run.trim().length > best.length) best = run.trim();
234
+ if (best.length < Math.min(length, 32)) return undefined;
235
+ const start = Math.max(0, Math.floor((best.length - length) / 2));
236
+ return best.slice(start, start + length).trim();
237
+ }